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::sync::Arc;
48use streaming::{
49    consume_streaming_component_root, ensure_no_pending_streaming_root,
50    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
51    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
52    StreamingRenderState,
53};
54pub use streaming::{
55    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
56    SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
57    MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
58};
59use thiserror::Error;
60use webui_expressions::{evaluate_with_resolver, ExpressionError};
61use webui_protocol::{
62    web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
63    InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
64};
65use webui_state::find_value_by_dotted_path_ref;
66
67/// Error types for the WebUI handler.
68#[derive(Debug, Error)]
69pub enum HandlerError {
70    #[error("Rendering error: {0}")]
71    Rendering(String),
72
73    /// Host-supplied state JSON could not be parsed or validated.
74    ///
75    /// This is a caller input error, not a render failure. Bindings map it to
76    /// their own state-error type, so it must stay distinguishable from
77    /// [`HandlerError::Rendering`] without inspecting the message text.
78    #[error("invalid state JSON: {0}")]
79    InvalidState(String),
80
81    #[error("Rendering invariant error: {0}")]
82    Invariant(String),
83
84    #[error("Missing fragment: {0}")]
85    MissingFragment(String),
86
87    #[error("Missing data field: {0}")]
88    MissingData(String),
89
90    #[error("Type error: {0}")]
91    TypeError(String),
92
93    #[error("Protocol error: {0}")]
94    Protocol(#[from] webui_protocol::ProtocolError),
95
96    #[error("Evaluation error: {0}")]
97    Evaluation(String),
98
99    #[error("I/O error: {0}")]
100    Io(#[from] std::io::Error),
101
102    #[error("Writer error: {0}")]
103    Writer(String),
104
105    #[error("Plugin data error: {0}")]
106    PluginData(String),
107
108    /// The HTTP client disconnected before the render completed.
109    ///
110    /// Streaming `ResponseWriter` implementations return this from
111    /// `write()` once their channel/socket is closed, so the handler
112    /// can abort the render rather than do CPU work that has nowhere
113    /// to go. Allocation-free (the variant carries no payload).
114    #[error("client disconnected")]
115    ClientDisconnected,
116
117    /// The streaming writer's flush exceeded its configured deadline.
118    ///
119    /// Indicates a slow/unresponsive consumer (slow-loris client,
120    /// stuck proxy, etc.). The render thread is freed; downstream
121    /// telemetry should distinguish this from `ClientDisconnected`
122    /// so ops can alert on slow-client attacks.
123    #[error("streaming flush timed out")]
124    StreamTimeout,
125
126    /// A streaming-boundary signal was malformed or arrived out of order.
127    ///
128    /// The payload is boxed so this cold, streaming-only variant does not
129    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
130    /// threaded through the entire hot legacy render path — with two inline
131    /// `String`s.
132    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
133    StreamingBoundary(Box<StreamingBoundaryError>),
134
135    /// A streaming render ended without its structural `body_end` signal.
136    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
137    MissingStreamingBodyEnd,
138
139    /// Streaming initialization cannot be emitted before document content.
140    #[error(
141        "streaming protocol is missing the required `head_start` signal before `{before}`; \
142         rebuild the protocol with streaming-boundary parser support"
143    )]
144    MissingStreamingHeadStart {
145        /// Structural point that made initialization too late.
146        before: &'static str,
147    },
148
149    /// A malformed protocol emitted streaming initialization more than once.
150    #[error("streaming protocol emitted duplicate `head_start` signals")]
151    DuplicateStreamingHeadStart,
152}
153
154/// Boxed payload for [`HandlerError::StreamingBoundary`].
155///
156/// Kept behind a `Box` so the streaming-validation variant contributes only a
157/// pointer-sized payload to [`HandlerError`], keeping the common
158/// `Result<(), HandlerError>` small on the ordinary render path where these
159/// boundary errors never occur.
160#[derive(Debug)]
161pub struct StreamingBoundaryError {
162    /// The logical structural token (the internal namespace is stripped).
163    pub signal: String,
164    /// Actionable validation failure.
165    pub reason: String,
166}
167
168pub type Result<T> = std::result::Result<T, HandlerError>;
169
170#[cold]
171#[inline(never)]
172fn invalid_fragment_range_error(
173    range: &std::ops::Range<usize>,
174    fragment_count: usize,
175) -> HandlerError {
176    HandlerError::Invariant(format!(
177        "fragment range {}..{} exceeds fragment count {fragment_count}",
178        range.start, range.end
179    ))
180}
181
182#[cold]
183#[inline(never)]
184fn route_style_plan_missing_error() -> HandlerError {
185    HandlerError::Invariant(
186        "matched route style targets were computed without a route chain".to_string(),
187    )
188}
189
190#[cold]
191#[inline(never)]
192fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
193    HandlerError::Invariant(format!(
194        "matched route style target count {targets} does not match route count {routes}"
195    ))
196}
197
198/// Interface for writing rendered output
199pub trait ResponseWriter {
200    /// Write content to the output
201    fn write(&mut self, content: &str) -> Result<()>;
202
203    /// Write one complete quoted HTML attribute.
204    #[doc(hidden)]
205    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
206        self.write(" ")?;
207        self.write(name)?;
208        self.write("=\"")?;
209        self.write(value)?;
210        self.write("\"")
211    }
212
213    /// Write one complete boolean HTML attribute.
214    #[doc(hidden)]
215    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
216        self.write(" ")?;
217        self.write(name)
218    }
219
220    /// Finalize the output
221    fn end(&mut self) -> Result<()>;
222
223    /// Hand buffered bytes to the transport at a committed streaming boundary.
224    ///
225    /// Internal hook used only by the progressive streaming render path so the
226    /// request-local sink can flush its concrete transport without a shared-cell
227    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
228    /// the no-op default; hosts must not override or depend on this — implement
229    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
230    #[doc(hidden)]
231    fn stream_flush(&mut self) -> Result<()> {
232        Ok(())
233    }
234
235    #[doc(hidden)]
236    fn stream_begin_component(&mut self) -> Result<()> {
237        Err(HandlerError::Invariant(
238            "component opening buffering requires a streaming sink".to_string(),
239        ))
240    }
241
242    #[doc(hidden)]
243    fn stream_mark_component_root(&mut self) -> Result<()> {
244        Err(HandlerError::Invariant(
245            "component root buffering requires a streaming sink".to_string(),
246        ))
247    }
248
249    #[doc(hidden)]
250    fn stream_commit_component(
251        &mut self,
252        span_id: Option<u32>,
253        enclosing_span_id: Option<u32>,
254        deferred: bool,
255    ) -> Result<()> {
256        let _ = (span_id, enclosing_span_id, deferred);
257        Err(HandlerError::Invariant(
258            "component opening commit requires a streaming sink".to_string(),
259        ))
260    }
261}
262
263/// Serialize every application state key except a sorted base projection.
264///
265/// Streaming uses this only when a prior keyed projection is a proven subset
266/// of the current full-state projection. The browser merges the emitted delta
267/// over that exact prior range state.
268struct StateWithoutSelectedKeys<'a> {
269    value: &'a Value,
270    keys: KeyView<'a>,
271}
272
273impl Serialize for StateWithoutSelectedKeys<'_> {
274    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
275    where
276        S: serde::Serializer,
277    {
278        let Value::Object(map) = self.value else {
279            return serializer.serialize_map(Some(0))?.end();
280        };
281        let mut out = serializer.serialize_map(None)?;
282        for (key, value) in map {
283            if key != STATE_INJECT_KEY && !self.keys.contains(key) {
284                out.serialize_entry(key, value)?;
285            }
286        }
287        out.end()
288    }
289}
290
291/// A response writer that can hand buffered bytes to its transport immediately.
292///
293/// Progressive hydration requires this semantic flush at every committed
294/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
295pub trait FlushWriter: ResponseWriter {
296    /// Hand all currently buffered bytes to the underlying transport.
297    fn flush(&mut self) -> Result<()>;
298}
299
300/// Options controlling how the handler renders a protocol.
301///
302/// The handler performs server-side route matching: matched routes are rendered
303/// visible with content; non-matched routes are rendered hidden and empty.
304pub struct RenderOptions<'a> {
305    /// The fragment ID to start rendering from (e.g., `"index.html"`).
306    pub entry_id: &'a str,
307    /// The URL path to match routes against (e.g., `"/contacts/42"`).
308    pub request_path: &'a str,
309    /// Optional CSP nonce for inline `<script>` tags.
310    /// When set, all inline scripts include `nonce="VALUE"` and a
311    /// `<meta name="webui-nonce">` tag is emitted for the client router.
312    pub nonce: Option<&'a str>,
313    /// Optional HTML to emit immediately before the document's
314    /// `</head>` close. Used for per-request `<link rel="preload">`
315    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
316    /// Inserted at the structural `head_end` boundary identified by
317    /// the parser — never matched against a byte pattern, so cannot
318    /// be tricked by `</head>` literals appearing in HTML comments,
319    /// `srcdoc` attributes, or inline scripts.
320    pub head_inject: Option<&'a str>,
321    /// Optional HTML to emit immediately before the document's
322    /// `</body>` close. Used for dev livereload `<script>`, analytics
323    /// snippets, OpenTelemetry trace IDs, etc.
324    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
325    pub body_inject: Option<&'a str>,
326}
327
328/// Reserved top-level state key carrying host-supplied boundary HTML.
329///
330/// The leading `$` marks this key as reserved host metadata rather than
331/// ordinary application state.
332///
333/// Recognized members, each an optional string:
334///
335/// | Member | Emitted at |
336/// | --- | --- |
337/// | `headEnd` | immediately before `</head>` |
338/// | `bodyStart` | immediately after `<body>` |
339/// | `bodyEnd` | immediately before `</body>` |
340///
341/// The key is always honored — it is part of the render state the host
342/// already supplies — and is stripped from the client hydration payload.
343pub const STATE_INJECT_KEY: &str = "$webui";
344
345/// Host-supplied boundary HTML resolved once per render from
346/// [`STATE_INJECT_KEY`].
347///
348/// Resolution happens when the render context is built, so each structural
349/// hook costs one `Option` check instead of a state map lookup. Every field
350/// borrows from the caller's state value — no clone, no allocation.
351#[derive(Clone, Copy, Default)]
352pub(crate) struct StateInject<'state> {
353    pub(crate) head_end: Option<&'state str>,
354    pub(crate) body_start: Option<&'state str>,
355    pub(crate) body_end: Option<&'state str>,
356}
357
358impl<'state> StateInject<'state> {
359    /// Resolve the reserved namespace from a render state value.
360    ///
361    /// Returns the empty set when the key is absent, or when any member is
362    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
363    /// key is an optional side channel, and a render must not fail because a
364    /// host wrote the wrong shape into it.
365    pub(crate) fn resolve(state: &'state Value) -> Self {
366        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
367            return Self::default();
368        };
369        let field = |name: &str| {
370            map.get(name)
371                .and_then(Value::as_str)
372                .filter(|html| !html.is_empty())
373        };
374        Self {
375            head_end: field("headEnd"),
376            body_start: field("bodyStart"),
377            body_end: field("bodyEnd"),
378        }
379    }
380}
381
382impl<'a> RenderOptions<'a> {
383    /// Create render options for the given entry fragment and request path.
384    #[must_use]
385    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
386        Self {
387            entry_id,
388            request_path,
389            nonce: None,
390            head_inject: None,
391            body_inject: None,
392        }
393    }
394
395    /// Set the CSP nonce for inline scripts. Pass an empty string to
396    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
397    /// content="">` would be browser-ignored noise.
398    #[must_use]
399    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
400        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
401        self
402    }
403
404    /// Set HTML to emit immediately before `</head>`.
405    /// Pass an empty string to disable (`None` semantics).
406    ///
407    /// # Safety (XSS warning)
408    ///
409    /// The provided HTML is written verbatim — **no HTML escaping is
410    /// performed**. Callers MUST ensure the content is fully trusted
411    /// (typically a `&'static str` or build-time-derived bytes such as
412    /// dev livereload script, image preload `<link>` tags, or A/B test
413    /// markers). Passing user-controlled or attacker-influenced content
414    /// here is a direct cross-site scripting vulnerability. If your
415    /// caller path may include untrusted data, escape with the host's
416    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
417    /// **before** calling this builder.
418    #[must_use]
419    pub fn with_head_inject(mut self, html: &'a str) -> Self {
420        self.head_inject = if html.is_empty() { None } else { Some(html) };
421        self
422    }
423
424    /// Set HTML to emit immediately before `</body>`.
425    /// Pass an empty string to disable (`None` semantics).
426    ///
427    /// # Safety (XSS warning)
428    ///
429    /// Same contract as [`with_head_inject`](Self::with_head_inject):
430    /// the HTML is written verbatim with **no escaping**, so callers
431    /// MUST ensure the content is fully trusted. Untrusted content is
432    /// a direct XSS vector.
433    #[must_use]
434    pub fn with_body_inject(mut self, html: &'a str) -> Self {
435        self.body_inject = if html.is_empty() { None } else { Some(html) };
436        self
437    }
438}
439
440/// The main WebUI handler that processes protocols and renders them.
441///
442/// The handler is stateless: plugin instances are created per-render from
443/// the stored factory function, allowing concurrent renders with `&self`.
444pub struct WebUIHandler {
445    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
446}
447
448#[derive(Clone, Copy, Eq, PartialEq)]
449enum StyleClosureInstall {
450    Static,
451    Routed,
452}
453
454pub(crate) struct ShadowStyleRoot {
455    component_index: u32,
456    static_closure_emitted: bool,
457    routed_resources: Vec<u32>,
458}
459
460struct LoopBinding<'protocol, 'state> {
461    name: &'protocol str,
462    value: &'state Value,
463}
464
465#[derive(Clone, Copy)]
466struct VisibleLoopScope {
467    start: usize,
468    end: usize,
469}
470
471impl VisibleLoopScope {
472    const EMPTY: Self = Self { start: 0, end: 0 };
473}
474
475#[derive(Clone, Copy)]
476struct LocalValueSources<'ctx, 'protocol, 'state> {
477    owned: &'ctx HashMap<String, Value>,
478    borrowed: &'ctx BorrowedScope<'protocol, 'state>,
479}
480
481#[derive(Default)]
482struct BorrowedScope<'protocol, 'state> {
483    inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
484    inline_len: usize,
485    overflow: Vec<(&'protocol str, &'state Value)>,
486}
487
488impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
489    fn get(&self, name: &str) -> Option<&'state Value> {
490        if self.inline_len == 0 {
491            return None;
492        }
493        if let Some((entry_name, value)) = self.inline[0].as_ref() {
494            if *entry_name == name {
495                return Some(*value);
496            }
497        }
498        for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
499            if *entry_name == name {
500                return Some(*value);
501            }
502        }
503        self.overflow
504            .iter()
505            .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
506    }
507
508    fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
509        for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
510            if *entry_name == name {
511                return Some(std::mem::replace(current, value));
512            }
513        }
514        for (entry_name, current) in &mut self.overflow {
515            if *entry_name == name {
516                return Some(std::mem::replace(current, value));
517            }
518        }
519        if self.inline_len < INLINE_SCOPE_SLOTS {
520            self.inline[self.inline_len] = Some((name, value));
521            self.inline_len += 1;
522        } else {
523            self.overflow.push((name, value));
524        }
525        None
526    }
527
528    fn remove(&mut self, name: &str) -> Option<&'state Value> {
529        if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
530            entry
531                .as_ref()
532                .is_some_and(|(entry_name, _)| *entry_name == name)
533        }) {
534            let removed = self.inline[index].take().map(|(_, value)| value);
535            if let Some(entry) = self.overflow.pop() {
536                self.inline[index] = Some(entry);
537            } else {
538                self.inline_len -= 1;
539                self.inline[index] = self.inline[self.inline_len].take();
540            }
541            return removed;
542        }
543        let index = self
544            .overflow
545            .iter()
546            .position(|(entry_name, _)| *entry_name == name)?;
547        Some(self.overflow.swap_remove(index).1)
548    }
549
550    fn clear(&mut self) {
551        for entry in &mut self.inline[..self.inline_len] {
552            *entry = None;
553        }
554        self.inline_len = 0;
555        self.overflow.clear();
556    }
557
558    fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
559        for (name, value) in self.inline[..self.inline_len].iter().flatten() {
560            target.insert((*name).to_owned(), (*value).clone());
561        }
562        for (name, value) in &self.overflow {
563            target.insert((*name).to_owned(), (*value).clone());
564        }
565    }
566}
567
568/// A fragment list paired with the render metadata prepared for it when the
569/// runtime [`Protocol`] was loaded.
570///
571/// `fragments` still points at the protocol's own storage, so passing this by
572/// value never copies or clones a fragment graph.
573#[derive(Clone, Copy)]
574pub(crate) struct RenderFragmentList<'protocol> {
575    fragments: &'protocol [WebUIFragment],
576    metadata: &'protocol [RenderFragmentMetadata],
577    attr_names: &'protocol str,
578    contains_boundary: bool,
579    /// True when this list contains at least one `<webui-route>` fragment.
580    /// Renders skip the sibling route pre-scan entirely when it is false.
581    has_routes: bool,
582}
583
584impl<'protocol> RenderFragmentList<'protocol> {
585    /// Render slot the fragment at `index` descends into, if any.
586    fn target(self, index: usize) -> Option<usize> {
587        let target = self.metadata.get(index)?.target;
588        (target != NO_RENDER_SLOT).then_some(target as usize)
589    }
590
591    /// Canonical camelCase component prop name prepared for an attribute fragment.
592    fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
593        let prepared = self.metadata.get(index)?;
594        if prepared.attr_start == NO_ATTR_NAME {
595            return None;
596        }
597        let start = prepared.attr_start as usize;
598        self.attr_names
599            .get(start..start + prepared.attr_len as usize)
600    }
601}
602
603/// Sentinel for "this fragment does not descend into another fragment list".
604const NO_RENDER_SLOT: u32 = u32::MAX;
605/// Sentinel for "this fragment is not an attribute fragment".
606const NO_ATTR_NAME: u32 = u32::MAX;
607
608/// Per-fragment values hoisted out of the render loop at protocol load time.
609///
610/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
611/// protocol keeps one contiguous arena instead of one heap block per fragment.
612#[derive(Clone, Copy)]
613struct RenderFragmentMetadata {
614    /// Numeric slot of the fragment list this fragment renders into, avoiding a
615    /// string hash lookup per component, loop, condition, and template attribute.
616    /// [`NO_RENDER_SLOT`] when the fragment renders inline.
617    target: u32,
618    /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
619    attr_start: u32,
620    attr_len: u32,
621}
622
623/// Build-time render plan for every fragment list in a protocol.
624///
625/// Built once when a [`Protocol`] is created and shared immutably by every
626/// render. Fragment IDs are the same `Arc<str>` values the protocol already
627/// interns, metadata lives in one flat arena, and the fragment graphs
628/// themselves are never duplicated.
629pub(crate) struct RenderFragmentIndex {
630    ids: Box<[Arc<str>]>,
631    metadata: Box<[RenderFragmentMetadata]>,
632    /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
633    ranges: Box<[u32]>,
634    /// Every prepared component prop name concatenated into one allocation.
635    attr_names: Box<str>,
636    /// One bit per fragment list: does it contain a route fragment?
637    route_presence: Box<[u64]>,
638}
639
640/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
641///
642/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
643/// a large protocol is never eagerly walked to serve a small render.
644pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
645    cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
646    /// Memo slots for protocols with more fragment lists than the inline cache
647    /// holds, allocated on first use so a small render never pays for it and a
648    /// large one never falls back to hashing the fragment ID on every descent.
649    spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
650    index: &'protocol RenderFragmentIndex,
651    protocol: &'protocol WebUIProtocol,
652}
653
654impl RenderFragmentIndex {
655    /// Prepare the render plan for `protocol`.
656    ///
657    /// `ids` must be the protocol's sorted fragment IDs and `slots` its ID→slot
658    /// map, so render slots and the runtime [`Protocol`]'s continuation slots are
659    /// the same numbering. Reusing the map keeps preparation to one hash lookup
660    /// per reference instead of a string binary search.
661    pub(crate) fn new(
662        protocol: &WebUIProtocol,
663        ids: &[Arc<str>],
664        slots: &HashMap<Arc<str>, u32>,
665    ) -> Self {
666        let total_fragments = protocol
667            .fragments
668            .values()
669            .map(|list| list.fragments.len())
670            .sum();
671        let mut metadata = Vec::with_capacity(total_fragments);
672        let mut ranges = Vec::with_capacity(ids.len() + 1);
673        // Most attribute names are short; reserving up front keeps the shared
674        // arena from repeatedly reallocating and copying as it is filled.
675        let mut attr_names = String::with_capacity(total_fragments * 8);
676        let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
677
678        for (slot, id) in ids.iter().enumerate() {
679            // Record counts are bounded by the compiled graph, well inside u32.
680            #[allow(clippy::cast_possible_truncation)]
681            ranges.push(metadata.len() as u32);
682            let Some(list) = protocol.fragments.get(id.as_ref()) else {
683                continue;
684            };
685            let mut has_routes = false;
686            for fragment in &list.fragments {
687                let inner = fragment.fragment.as_ref();
688                if matches!(inner, Some(Fragment::Route(_))) {
689                    has_routes = true;
690                }
691                let target = fragment_target_id(fragment)
692                    .and_then(|target| slots.get(target).copied())
693                    .unwrap_or(NO_RENDER_SLOT);
694                let (attr_start, attr_len) = match inner {
695                    // `attr_skip` attributes are never collected into component
696                    // props, so preparing a name for them would be pure load-time
697                    // cost for something no render ever reads.
698                    Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
699                        #[allow(clippy::cast_possible_truncation)]
700                        let start = attr_names.len() as u32;
701                        let before = attr_names.len();
702                        push_component_attr_name(&mut attr_names, component_attr_source(attribute));
703                        #[allow(clippy::cast_possible_truncation)]
704                        let len = (attr_names.len() - before) as u32;
705                        (start, len)
706                    }
707                    _ => (NO_ATTR_NAME, 0),
708                };
709                metadata.push(RenderFragmentMetadata {
710                    target,
711                    attr_start,
712                    attr_len,
713                });
714            }
715            if has_routes {
716                route_presence[slot / 64] |= 1u64 << (slot % 64);
717            }
718        }
719        #[allow(clippy::cast_possible_truncation)]
720        ranges.push(metadata.len() as u32);
721
722        Self {
723            ids: ids.to_vec().into_boxed_slice(),
724            metadata: metadata.into_boxed_slice(),
725            ranges: ranges.into_boxed_slice(),
726            attr_names: attr_names.into_boxed_str(),
727            route_presence: route_presence.into_boxed_slice(),
728        }
729    }
730
731    /// Resolve a fragment ID to its stable numeric render slot.
732    pub(crate) fn index(&self, id: &str) -> Option<usize> {
733        self.ids
734            .binary_search_by(|candidate| candidate.as_ref().cmp(id))
735            .ok()
736    }
737
738    pub(crate) fn resolve<'protocol>(
739        &'protocol self,
740        protocol: &'protocol WebUIProtocol,
741    ) -> ResolvedRenderFragmentIndex<'protocol> {
742        ResolvedRenderFragmentIndex {
743            cache: std::array::from_fn(|_| Cell::new(None)),
744            spilled: OnceCell::new(),
745            index: self,
746            protocol,
747        }
748    }
749}
750
751impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
752    pub(crate) fn index(&self, id: &str) -> Option<usize> {
753        self.index.index(id)
754    }
755
756    /// Memo slot for `index`, spilling past the inline cache on demand.
757    fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
758        if index < INLINE_RENDER_FRAGMENT_LISTS {
759            return self.cache.get(index);
760        }
761        let spilled = self.spilled.get_or_init(|| {
762            let overflow = self
763                .index
764                .ids
765                .len()
766                .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
767            (0..overflow).map(|_| Cell::new(None)).collect()
768        });
769        spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
770    }
771
772    pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
773        let id = self.index.ids.get(index)?;
774        let fragment_list = match self.slot(index) {
775            Some(slot) => match slot.get() {
776                Some(cached) => cached,
777                None => {
778                    let resolved = self.protocol.fragments.get(id.as_ref())?;
779                    slot.set(Some(resolved));
780                    resolved
781                }
782            },
783            None => self.protocol.fragments.get(id.as_ref())?,
784        };
785        let start = *self.index.ranges.get(index)? as usize;
786        let end = *self.index.ranges.get(index + 1)? as usize;
787        let metadata = self.index.metadata.get(start..end)?;
788        let has_routes = self
789            .index
790            .route_presence
791            .get(index / 64)
792            .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
793        Some(RenderFragmentList {
794            fragments: &fragment_list.fragments,
795            metadata,
796            attr_names: &self.index.attr_names,
797            contains_boundary: fragment_list.contains_boundary,
798            has_routes,
799        })
800    }
801
802    #[cfg(test)]
803    pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
804        self.index(id).and_then(|index| self.list(index))
805    }
806}
807
808/// Name that a component prop is derived from for an attribute fragment.
809///
810/// The render path strips a leading `:` twice on the complex-value branch and
811/// once on every other branch. Every field that selects the branch is a
812/// build-time constant, so the choice is made here and the prepared name stays
813/// byte-identical to the per-render conversion it replaces.
814fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
815    let takes_complex_branch = attribute.condition_tree.is_none()
816        && attribute.template.is_empty()
817        && !attribute.value.is_empty()
818        && !attribute.raw_value
819        && attribute.complex;
820    if takes_complex_branch {
821        attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
822    } else {
823        &attribute.name
824    }
825}
826
827/// Fragment ID a fragment descends into, or `None` when it renders inline.
828fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
829    match fragment.fragment.as_ref()? {
830        Fragment::Component(component) => Some(&component.fragment_id),
831        Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
832        Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
833        Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
834            Some(&attribute.template)
835        }
836        _ => None,
837    }
838}
839
840/// Context object for processing WebUI fragments
841pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
842    pub(crate) protocol: &'protocol WebUIProtocol,
843    /// Render plan prepared once when the runtime [`Protocol`] was loaded.
844    /// Fragment lists resolve through numeric slots instead of per-fragment
845    /// string hashing.
846    pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
847    /// Build-constant component asset style metadata serialized once when the
848    /// runtime [`Protocol`] is created.
849    pub(crate) component_asset_style_manifest: &'protocol str,
850    /// Deduplicated document-scoped component asset styles for Light DOM.
851    pub(crate) component_asset_style_links: &'protocol str,
852    pub(crate) state: &'state Value,
853    pub(crate) writer: &'output mut dyn ResponseWriter,
854    pub(crate) local_vars: HashMap<String, Value>,
855    /// Component-local values that still point into immutable request state.
856    local_borrowed_vars: BorrowedScope<'protocol, 'state>,
857    /// Borrowed loop bindings, in lexical order.
858    loop_vars: Vec<LoopBinding<'protocol, 'state>>,
859    /// Range of `loop_vars` visible to the current fragment scope. Component
860    /// bodies hide outer loop monikers while still allowing their own loops.
861    visible_loop_scope: VisibleLoopScope,
862    /// Accumulates component attribute values between attrStart and the component fragment.
863    pub(crate) component_attrs: HashMap<String, Value>,
864    /// State-backed component attributes accumulated without cloning.
865    component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
866    /// True only while parser-produced component opening-tag attributes are
867    /// being accumulated. Native element attributes render directly and never
868    /// enter `component_attrs`.
869    collecting_component_attrs: bool,
870    /// URL path for server-side route matching. Borrowed from
871    /// `RenderOptions<'a>::request_path` — zero-copy.
872    pub(crate) request_path: &'protocol str,
873    /// Base path for resolving relative route paths (`./`).
874    /// Updated as the handler descends into nested matched routes.
875    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
876    /// descent owns the recomputed path.
877    pub(crate) route_base: Cow<'protocol, str>,
878    /// Component names visited during rendering (for selective f-template emission
879    /// and CSS module dedup — only the first render of each component emits
880    /// its `<script type="importmap">` data-URI tag).
881    pub(crate) rendered_components: HashSet<String>,
882    /// Per-render plugin instance created from the handler's factory.
883    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
884    /// Current position in the route tree for outlet-based rendering.
885    /// Contains the children of the currently matched route fragment.
886    pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
887    /// Entry fragment ID — used to compute the initial inventory at head_end.
888    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
889    pub(crate) entry_id: &'protocol str,
890    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
891    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
892    pub(crate) nonce: Option<&'protocol str>,
893    /// Component-name → bit-position map built once when the runtime
894    /// [`Protocol`] is created and shared by every render.
895    pub(crate) component_index: &'protocol HashMap<String, u32>,
896    /// Style-resource ID → request-local dedup bit built with [`Protocol`].
897    pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
898    /// Inline style resources whose CSS contains a case-insensitive `</style`.
899    ///
900    /// Computed once when [`Protocol`] loads so repeated component instances can
901    /// write ordinary CSS without rescanning identical bytes on every request.
902    pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
903    /// Component tag → covering bundle chunk, built once per render.
904    ///
905    /// Empty (and allocation-free) for unbundled builds. Every style delivery
906    /// path resolves closure members through this so they all agree on what a
907    /// chunk already ships.
908    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
909    /// CSS strategy declared by the compiled protocol.
910    pub(crate) css_strategy: webui_protocol::CssStrategy,
911    /// HTML emitted at the structural `head_end` boundary (before
912    /// `</head>`), after the built-in nonce/CSS emissions.
913    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
914    /// (no per-render clone — saves an allocation when the host passes
915    /// a `&'static str` such as a dev livereload script).
916    pub(crate) head_inject: Option<&'protocol str>,
917    /// HTML emitted at the structural `body_end` boundary (before
918    /// `</body>`), after the built-in template metadata emissions.
919    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
920    pub(crate) body_inject: Option<&'protocol str>,
921    /// Host HTML resolved once per render from the reserved
922    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
923    /// emissions at each structural boundary, and after the corresponding
924    /// `RenderOptions` inject at `head_end` and `body_end`.
925    pub(crate) state_inject: StateInject<'state>,
926    /// Tracks whether the `head_end` hook has already fired in this
927    /// render. Defends against malformed protocols that emit the
928    /// signal more than once (e.g., a template with multiple `<head>`
929    /// tags) — without this, host-supplied `head_inject` HTML, CSS
930    /// resources, and the CSP `<meta>` nonce would be
931    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
932    pub(crate) head_end_emitted: bool,
933    /// Tracks whether the `body_start` hook has already fired in this
934    /// render. Defends against malformed protocols emitting the signal
935    /// twice — without this, state-supplied `bodyStart` HTML would be
936    /// duplicated.
937    pub(crate) body_start_emitted: bool,
938    /// Tracks whether compiler-owned component asset styles were emitted at
939    /// either the head or body fallback boundary.
940    pub(crate) component_asset_styles_emitted: bool,
941    /// Tracks whether the `body_end` hook has already fired in this
942    /// render. Defends against malformed protocols emitting the
943    /// signal twice — without this, hydration `<script>` blocks and
944    /// host-supplied `body_inject` would be duplicated.
945    pub(crate) body_end_emitted: bool,
946    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
947    pub(crate) route_index: &'protocol CompiledRouteIndex,
948    /// Counter for `data-ri` attributes on matched route elements.
949    /// Incremented each time a matched route is rendered, allowing O(1) element
950    /// binding on the client side instead of DOM-walking.
951    pub(crate) route_chain_index: usize,
952    /// Matched route metadata computed at most once when head styles or body
953    /// bootstrap first need it, then shared by streaming checkpoints.
954    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
955    /// Whether each matched route closure installs into the Document CSS tree.
956    ///
957    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
958    pub(crate) route_document_style_targets: Vec<bool>,
959    /// Request-reachable components in deterministic first-discovery order.
960    ///
961    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
962    /// `body_end` for hydration metadata so the fragment graph is not walked
963    /// twice per request.
964    pub(crate) reachable_components: Option<Vec<String>>,
965    /// Present only for the opt-in progressive streaming render path.
966    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
967    /// Reusable JSON serialization scratch buffer, owned by the render context.
968    /// The bootstrap/field/template helpers borrow it so each render reuses one
969    /// buffer across every serialized field and every streaming checkpoint
970    /// instead of allocating per value. It grows lazily on first serialization
971    /// and is dropped with the context at request end — no per-thread
972    /// high-water buffer is retained between requests.
973    pub(crate) json_scratch: Vec<u8>,
974    /// Small request-local pool of cleared scope maps reused across sibling
975    /// component roots. `process_component` recycles each finished component's
976    /// local/attr map here instead of dropping it, so a sibling reuses the
977    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
978    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
979    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
980    /// Resources delivered into the Document CSS tree. The empty set does not
981    /// allocate, and streaming retains it across checkpoints.
982    pub(crate) document_style_resources: HashSet<String>,
983    /// Active Shadow roots and their route-activated resource indexes. The
984    /// route vector allocates only when a matched Light route contributes CSS.
985    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
986    /// Capacity-preserving pool for borrowed component scope maps.
987    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
988}
989
990/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
991/// authored double- or triple-brace expressions because it closes the binding.
992pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
993
994pub(crate) fn structural_signal_value(
995    signal: &webui_protocol::WebUIFragmentSignal,
996) -> Option<&str> {
997    if !signal.raw {
998        return None;
999    }
1000    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1001}
1002
1003pub(crate) fn write_interaction_marker(
1004    tag: &str,
1005    context: &mut WebUIProcessContext<'_, '_, '_>,
1006) -> Result<()> {
1007    if matches!(
1008        context
1009            .protocol
1010            .components
1011            .get(tag)
1012            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1013        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1014    ) {
1015        context.writer.write(" data-webui-interaction")?;
1016    }
1017    Ok(())
1018}
1019
1020/// Find the end of a leading doctype without treating quoted `>` bytes in
1021/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
1022fn doctype_prefix_end(raw: &str) -> Option<usize> {
1023    const PREFIX: &[u8] = b"<!doctype";
1024
1025    let bytes = raw.as_bytes();
1026    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1027    while bytes
1028        .get(cursor)
1029        .is_some_and(|byte| byte.is_ascii_whitespace())
1030    {
1031        cursor += 1;
1032    }
1033
1034    let prefix_end = cursor.checked_add(PREFIX.len())?;
1035    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1036        return None;
1037    }
1038    let separator = *bytes.get(prefix_end)?;
1039    if separator != b'>' && !separator.is_ascii_whitespace() {
1040        return None;
1041    }
1042
1043    let mut quote = None;
1044    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1045        match quote {
1046            Some(active) if byte == active => quote = None,
1047            Some(_) => {}
1048            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1049            None if byte == b'>' => return Some(prefix_end + offset + 1),
1050            None => {}
1051        }
1052    }
1053    None
1054}
1055
1056/// Maximum scope maps retained in the request-local pool. Small: sibling
1057/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1058const SCOPE_POOL_CAP: usize = 8;
1059/// Borrowed component scopes typically contain only a handful of attributes.
1060const INLINE_SCOPE_SLOTS: usize = 4;
1061/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1062/// a typical app touches in one render while keeping the per-render zeroing cost
1063/// negligible; larger protocols fall back to the protocol map for the overflow
1064/// slots instead of eagerly materializing every list.
1065/// Inline memo slots for fragment lists.
1066///
1067/// Deliberately small: the array is zeroed on every render, so a shallow render
1068/// touching two fragments should not pay to clear slots it will never read.
1069/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1070/// memoized without charging that cost to the common shallow case.
1071const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1072const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1073
1074struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1075
1076impl Serialize for ComponentAssetStyleManifest<'_> {
1077    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1078    where
1079        S: serde::Serializer,
1080    {
1081        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1082        for entry in self.0 {
1083            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1084        }
1085        map.end()
1086    }
1087}
1088
1089fn serialize_component_asset_style_manifest(
1090    preloads: &[ComponentAssetStylePreload],
1091) -> std::result::Result<String, serde_json::Error> {
1092    if preloads.is_empty() {
1093        return Ok(String::new());
1094    }
1095    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1096    if json.contains("</") {
1097        Ok(json.replace("</", "<\\/"))
1098    } else {
1099        Ok(json)
1100    }
1101}
1102
1103fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1104    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1105    let mut seen = HashSet::with_capacity(href_count);
1106    let mut links = String::with_capacity(href_count.saturating_mul(48));
1107    for entry in preloads {
1108        for href in &entry.style_hrefs {
1109            if !seen.insert(href.as_str()) {
1110                continue;
1111            }
1112            links.push_str("<link rel=\"stylesheet\" href=\"");
1113            push_escaped_html_attribute(&mut links, href);
1114            links.push_str("\">");
1115        }
1116    }
1117    links
1118}
1119
1120fn push_escaped_html_attribute(output: &mut String, value: &str) {
1121    let mut start = 0;
1122    for (index, ch) in value.char_indices() {
1123        let escaped = match ch {
1124            '&' => Some("&amp;"),
1125            '"' => Some("&quot;"),
1126            '<' => Some("&lt;"),
1127            '>' => Some("&gt;"),
1128            _ => None,
1129        };
1130        let Some(entity) = escaped else {
1131            continue;
1132        };
1133        if start < index {
1134            output.push_str(&value[start..index]);
1135        }
1136        output.push_str(entity);
1137        start = index + ch.len_utf8();
1138    }
1139    if start < value.len() {
1140        output.push_str(&value[start..]);
1141    }
1142}
1143
1144/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1145/// empty. A fresh `HashMap` does not allocate until its first insert.
1146fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1147    pool.pop().unwrap_or_default()
1148}
1149
1150/// Return a spent scope map to the pool, clearing it but retaining its bucket
1151/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1152/// retained memory stays bounded.
1153fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1154    if pool.len() < SCOPE_POOL_CAP {
1155        map.clear();
1156        pool.push(map);
1157    }
1158}
1159
1160fn take_borrowed_scope<'protocol, 'state>(
1161    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1162) -> BorrowedScope<'protocol, 'state> {
1163    pool.pop().unwrap_or_default()
1164}
1165
1166fn recycle_borrowed_scope<'protocol, 'state>(
1167    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1168    mut scope: BorrowedScope<'protocol, 'state>,
1169) {
1170    if pool.len() < SCOPE_POOL_CAP {
1171        scope.clear();
1172        pool.push(scope);
1173    }
1174}
1175
1176pub(crate) enum WebUiBootstrapState<'a> {
1177    Complete {
1178        value: &'a Value,
1179        selection: StateSelection<'a>,
1180    },
1181    Reference {
1182        record_sequence: usize,
1183        value: &'a Value,
1184        delta: Option<StateSelection<'a>>,
1185    },
1186}
1187
1188pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1189    pub(crate) declaration_id: Option<u32>,
1190    pub(crate) enclosing_span_instance_id: Option<u32>,
1191    pub(crate) state: WebUiBootstrapState<'a>,
1192    pub(crate) chain: &'a [Value],
1193    pub(crate) inventory: &'a str,
1194    pub(crate) nonce: Option<&'a str>,
1195    pub(crate) css_hrefs: &'a [&'a str],
1196    pub(crate) style_specs: &'a [&'a str],
1197    pub(crate) component_styles: &'a ComponentStyles,
1198    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1199}
1200
1201/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1202///
1203/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1204/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1205/// `tabindex`) via the shared lookup table.
1206///
1207/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1208/// this, so it remains only as the single-name form of
1209/// [`push_component_attr_name`] for tests.
1210#[cfg(test)]
1211fn component_attr_name(name: &str) -> String {
1212    let mut buffer = String::new();
1213    push_component_attr_name(&mut buffer, name);
1214    buffer
1215}
1216
1217/// Append the canonical camelCase component prop name for `name` to `buffer`.
1218///
1219/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1220/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1221/// like `readonly`, `tabindex`) through the shared lookup table.
1222///
1223/// Used while preparing the render fragment index so a protocol with thousands
1224/// of attribute fragments grows one arena instead of allocating per name.
1225fn push_component_attr_name(buffer: &mut String, name: &str) {
1226    let stripped = name.strip_prefix(':').unwrap_or(name);
1227    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1228    // straight into the shared arena. That function returns an owned `String`,
1229    // so calling it here would allocate and drop one buffer per attribute while
1230    // preparing a protocol.
1231    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1232        buffer.push_str(property);
1233        return;
1234    }
1235    buffer.reserve(stripped.len());
1236    let mut capitalize_next = false;
1237    for ch in stripped.chars() {
1238        if ch == '-' {
1239            capitalize_next = true;
1240        } else if capitalize_next {
1241            buffer.extend(ch.to_uppercase());
1242            capitalize_next = false;
1243        } else {
1244            buffer.push(ch);
1245        }
1246    }
1247}
1248
1249#[cold]
1250#[inline(never)]
1251fn missing_component_attr_name_error() -> HandlerError {
1252    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1253}
1254
1255/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1256pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1257    if n == 0 {
1258        return writer.write("0");
1259    }
1260    // Max digits for a 64-bit usize is 20.
1261    let mut buf = [0u8; 20];
1262    let mut pos = buf.len();
1263    while n > 0 {
1264        pos -= 1;
1265        // n % 10 is always in 0..=9, fits in u8 without truncation.
1266        #[allow(clippy::cast_possible_truncation)]
1267        let digit = (n % 10) as u8;
1268        buf[pos] = b'0' + digit;
1269        n /= 10;
1270    }
1271    // Digits are always valid ASCII/UTF-8.
1272    match std::str::from_utf8(&buf[pos..]) {
1273        Ok(s) => writer.write(s),
1274        Err(_) => writer.write("0"),
1275    }
1276}
1277
1278pub(crate) fn write_script_safe_json<T>(
1279    writer: &mut dyn ResponseWriter,
1280    scratch: &mut Vec<u8>,
1281    value: &T,
1282) -> Result<()>
1283where
1284    T: Serialize + ?Sized,
1285{
1286    // Serialize into the caller's request-local `scratch`. The streaming path
1287    // emits one bootstrap envelope per committed boundary and the ordinary
1288    // body_end bootstrap serializes several fields, so reusing one buffer across
1289    // the render avoids a fresh allocation per value. The buffer grows lazily on
1290    // first use (no allocation until serialization needs it) and is dropped with
1291    // the render context — capacity is reused within a request but never
1292    // retained across requests.
1293    scratch.clear();
1294    serde_json::to_writer(&mut *scratch, value)
1295        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1296    let json = std::str::from_utf8(scratch)
1297        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1298    write_script_safe_json_str(writer, json)
1299}
1300
1301fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1302    let mut start = 0;
1303    while start < json.len() {
1304        let rest = &json[start..];
1305        let Some(offset) = rest.find("</") else {
1306            writer.write(rest)?;
1307            return Ok(());
1308        };
1309
1310        if offset > 0 {
1311            writer.write(&rest[..offset])?;
1312        }
1313        writer.write("<\\/")?;
1314        start += offset + 2;
1315    }
1316    Ok(())
1317}
1318
1319fn write_json_field_name(
1320    writer: &mut dyn ResponseWriter,
1321    wrote_field: &mut bool,
1322    name: &str,
1323) -> Result<()> {
1324    if *wrote_field {
1325        writer.write(",")?;
1326    }
1327    *wrote_field = true;
1328    writer.write("\"")?;
1329    writer.write(name)?;
1330    writer.write("\":")
1331}
1332
1333fn write_json_field<T>(
1334    writer: &mut dyn ResponseWriter,
1335    scratch: &mut Vec<u8>,
1336    wrote_field: &mut bool,
1337    name: &str,
1338    value: &T,
1339) -> Result<()>
1340where
1341    T: Serialize + ?Sized,
1342{
1343    write_json_field_name(writer, wrote_field, name)?;
1344    write_script_safe_json(writer, scratch, value)
1345}
1346
1347/// Serialize wrapper that projects an SSR state object down to only the
1348/// keys present in the build-time hydration allowlist.
1349///
1350/// This is the runtime half of the projected-hydration design: instead of
1351/// serializing the entire application state (potentially megabytes) on every
1352/// full-HTML render, only the fields a component actually hydrates are
1353/// emitted. The request allowlist conservatively includes every reachable
1354/// component's hydration keys so no field a component needs is dropped.
1355///
1356/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1357/// by compiled client metadata is browser-facing, so hosts must never place
1358/// secrets in browser render state.
1359///
1360/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1361/// is smaller: hydration keys with direct map lookup for wide states, or state
1362/// entries with binary-search membership for compact states. Non-object states
1363/// carry nothing hydratable and serialize as an empty object.
1364struct ProjectedState<'a> {
1365    value: &'a Value,
1366    keys: KeyView<'a>,
1367}
1368
1369impl Serialize for ProjectedState<'_> {
1370    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1371    where
1372        S: serde::Serializer,
1373    {
1374        let Value::Object(map) = self.value else {
1375            return serializer.serialize_map(Some(0))?.end();
1376        };
1377
1378        let mut out = serializer.serialize_map(None)?;
1379        if self.keys.len() < map.len() {
1380            let mut previous = None;
1381            for key in self.keys.iter() {
1382                if key == STATE_INJECT_KEY {
1383                    continue;
1384                }
1385                if previous == Some(key) {
1386                    continue;
1387                }
1388                previous = Some(key);
1389                if let Some(value) = map.get(key) {
1390                    out.serialize_entry(key, value)?;
1391                }
1392            }
1393        } else {
1394            for (key, value) in map {
1395                if key == STATE_INJECT_KEY {
1396                    continue;
1397                }
1398                if self.keys.contains(key.as_str()) {
1399                    out.serialize_entry(key, value)?;
1400                }
1401            }
1402        }
1403        out.end()
1404    }
1405}
1406
1407/// Write the SSR `state` into the bootstrap block according to the protocol's
1408/// build-time selection and escape it for safe embedding in a `<script>`.
1409///
1410/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1411/// payload — a large state with a small hydratable surface — serde ever only
1412/// touches the projected subset. Serialization reuses the proven
1413/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1414/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1415/// when every key is hydratable and collapses to a few bytes when it is not.
1416/// Buffering the projected bytes and escaping once is measurably faster than
1417/// streaming through a per-token `io::Write` adapter, and the projected buffer
1418/// is tiny in the common case.
1419/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1420/// omitted.
1421///
1422/// The reserved key carries host-supplied boundary HTML, not application
1423/// state, so it must never reach the client hydration payload — shipping it
1424/// would both duplicate the markup as a JSON string and expose the host's
1425/// inject channel to client code. Filtering happens during serialization so
1426/// the state tree is never cloned.
1427struct StateWithoutReservedKey<'a> {
1428    value: &'a Value,
1429}
1430
1431impl Serialize for StateWithoutReservedKey<'_> {
1432    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1433    where
1434        S: serde::Serializer,
1435    {
1436        let Value::Object(map) = self.value else {
1437            return self.value.serialize(serializer);
1438        };
1439        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1440        for (key, value) in map {
1441            if key == STATE_INJECT_KEY {
1442                continue;
1443            }
1444            out.serialize_entry(key, value)?;
1445        }
1446        out.end()
1447    }
1448}
1449
1450/// Write the complete state, stripping the reserved inject key when present.
1451///
1452/// The membership test is a single map lookup and the common case — no
1453/// reserved key — takes the original zero-overhead path unchanged.
1454fn write_full_state(
1455    writer: &mut dyn ResponseWriter,
1456    scratch: &mut Vec<u8>,
1457    state: &Value,
1458) -> Result<()> {
1459    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1460        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1461    }
1462    write_script_safe_json(writer, scratch, state)
1463}
1464
1465pub(crate) fn write_selected_state(
1466    writer: &mut dyn ResponseWriter,
1467    scratch: &mut Vec<u8>,
1468    state: &Value,
1469    selection: &StateSelection<'_>,
1470) -> Result<()> {
1471    let keys = match selection {
1472        StateSelection::Full => return write_full_state(writer, scratch, state),
1473        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1474        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1475        StateSelection::FullExceptKeyIds(selection) => {
1476            return write_script_safe_json(
1477                writer,
1478                scratch,
1479                &StateWithoutSelectedKeys {
1480                    value: state,
1481                    keys: KeyView::Ids(*selection),
1482                },
1483            );
1484        }
1485    };
1486    if keys.is_empty() {
1487        return writer.write("{}");
1488    }
1489
1490    // Projection membership may use binary search, so a mis-sorted key set
1491    // would silently drop hydration keys. The key allowlist is produced sorted +
1492    // deduped at build time; this guard makes hand-built protocols that violate
1493    // the invariant fail loudly in tests at zero release cost.
1494    debug_assert!(
1495        keys.iter()
1496            .zip(keys.iter().skip(1))
1497            .all(|(left, right)| left <= right),
1498        "hydration keys must be sorted for binary-search projection"
1499    );
1500    if let Value::Object(map) = state {
1501        let selects_entire_map =
1502            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1503        if selects_entire_map {
1504            return write_full_state(writer, scratch, state);
1505        }
1506    }
1507    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1508}
1509
1510// Covers the common route surface without trusting protocol-derived counts for
1511// an eager allocation; larger key sets grow only as actual keys are visited.
1512pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1513
1514/// Request-scoped state selection derived from reachable component metadata.
1515pub(crate) enum StateSelection<'a> {
1516    /// Preserve the complete state value.
1517    Full,
1518    /// Project an object to a sorted, deduplicated key allowlist.
1519    Keys(Vec<&'a str>),
1520    /// Project using the streaming continuation's interned hydration key IDs.
1521    ///
1522    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1523    /// sorted key slice and the projection needs no borrowed-string buffer at
1524    /// all — the streaming record keeps its scratch as plain integers that
1525    /// survive every semantic step.
1526    KeyIds(HydrationKeySelection<'a>),
1527    /// Preserve full state except keys inherited from a referenced range state.
1528    FullExceptKeyIds(HydrationKeySelection<'a>),
1529}
1530
1531/// A projection expressed as interned hydration key IDs.
1532#[derive(Clone, Copy)]
1533pub(crate) struct HydrationKeySelection<'a> {
1534    pub(crate) ids: &'a [u32],
1535    pub(crate) index: &'a ComponentReachabilityIndex,
1536}
1537
1538impl<'a> HydrationKeySelection<'a> {
1539    fn key(self, position: usize) -> Option<&'a str> {
1540        let id = self.ids.get(position).copied()?;
1541        self.index.hydration_key(id)
1542    }
1543}
1544
1545/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1546#[derive(Clone, Copy)]
1547enum KeyView<'a> {
1548    Borrowed(&'a [&'a str]),
1549    Ids(HydrationKeySelection<'a>),
1550}
1551
1552impl<'a> KeyView<'a> {
1553    fn len(self) -> usize {
1554        match self {
1555            Self::Borrowed(keys) => keys.len(),
1556            Self::Ids(selection) => selection.ids.len(),
1557        }
1558    }
1559
1560    fn is_empty(self) -> bool {
1561        self.len() == 0
1562    }
1563
1564    fn get(self, position: usize) -> Option<&'a str> {
1565        match self {
1566            Self::Borrowed(keys) => keys.get(position).copied(),
1567            Self::Ids(selection) => selection.key(position),
1568        }
1569    }
1570
1571    fn iter(self) -> impl Iterator<Item = &'a str> {
1572        (0..self.len()).map_while(move |position| self.get(position))
1573    }
1574
1575    /// Sorted-membership probe used when the state object is smaller than the
1576    /// allowlist.
1577    fn contains(self, key: &str) -> bool {
1578        match self {
1579            Self::Borrowed(keys) => keys
1580                .binary_search_by(|candidate| str::cmp(candidate, key))
1581                .is_ok(),
1582            Self::Ids(selection) => selection
1583                .ids
1584                .binary_search_by(|id| {
1585                    selection
1586                        .index
1587                        .hydration_key(*id)
1588                        .map_or(std::cmp::Ordering::Less, |candidate| {
1589                            str::cmp(candidate, key)
1590                        })
1591                })
1592                .is_ok(),
1593        }
1594    }
1595}
1596
1597#[derive(Clone, Copy)]
1598enum ComponentStateSurface {
1599    Hydration,
1600    Navigation,
1601}
1602
1603/// Select initial state for the components reachable on this request path.
1604///
1605/// Non-WebUI protocols preserve full state without walking component surfaces.
1606/// WebUI protocols project exact surfaces, while any unknown surface restores
1607/// the full state for correctness.
1608pub(crate) fn collect_hydration_state<'a, 'b>(
1609    protocol: &'a WebUIProtocol,
1610    components: impl IntoIterator<Item = &'b str>,
1611) -> StateSelection<'a> {
1612    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1613        return StateSelection::Full;
1614    }
1615    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1616}
1617
1618/// Fill a reusable hydration key-ID allowlist for a streaming record.
1619///
1620/// Components arrive as inventory indexes, so the projection never resolves a
1621/// component name, never hashes it against the compiled component map, and
1622/// never sorts strings: the interned runs concatenate and the integer sort
1623/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1624/// requires sending full state instead.
1625pub(crate) fn collect_hydration_key_ids_into(
1626    protocol: &WebUIProtocol,
1627    index: &ComponentReachabilityIndex,
1628    components: impl IntoIterator<Item = u32>,
1629    ids: &mut Vec<u32>,
1630) -> bool {
1631    ids.clear();
1632    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1633        return true;
1634    }
1635    for component in components {
1636        if index.extend_hydration_keys(component, ids) {
1637            ids.clear();
1638            return true;
1639        }
1640    }
1641    ids.sort_unstable();
1642    ids.dedup();
1643    false
1644}
1645
1646/// Select state for client-created components reachable during navigation.
1647pub(crate) fn collect_navigation_state<'a, 'b>(
1648    protocol: &'a WebUIProtocol,
1649    components: impl IntoIterator<Item = &'b str>,
1650) -> StateSelection<'a> {
1651    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1652}
1653
1654fn collect_component_state<'a, 'b>(
1655    protocol: &'a WebUIProtocol,
1656    components: impl IntoIterator<Item = &'b str>,
1657    surface: ComponentStateSurface,
1658) -> StateSelection<'a> {
1659    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1660    if collect_component_state_into(protocol, components, surface, &mut keys) {
1661        StateSelection::Full
1662    } else {
1663        StateSelection::Keys(keys)
1664    }
1665}
1666
1667/// Fill a reusable state-key allowlist. Returns `true` when correctness
1668/// requires sending full state instead of the collected keys.
1669fn collect_component_state_into<'a, 'b>(
1670    protocol: &'a WebUIProtocol,
1671    components: impl IntoIterator<Item = &'b str>,
1672    surface: ComponentStateSurface,
1673    keys: &mut Vec<&'a str>,
1674) -> bool {
1675    keys.clear();
1676    for name in components {
1677        let Some(component) = protocol.components.get(name) else {
1678            return true;
1679        };
1680        let (mode, component_keys) = match surface {
1681            ComponentStateSurface::Hydration => {
1682                (component.hydration_mode, &component.hydration_keys)
1683            }
1684            ComponentStateSurface::Navigation => (
1685                match component.navigation_mode {
1686                    Some(mode) => mode,
1687                    None if !component.navigation_keys.is_empty() => {
1688                        StateProjectionMode::Keys as i32
1689                    }
1690                    None => return true,
1691                },
1692                &component.navigation_keys,
1693            ),
1694        };
1695        if mode == StateProjectionMode::All as i32 {
1696            return true;
1697        }
1698        if mode == StateProjectionMode::Keys as i32
1699            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1700        {
1701            keys.extend(component_keys.iter().map(String::as_str));
1702        } else if mode != StateProjectionMode::None as i32 {
1703            return true;
1704        }
1705    }
1706    keys.sort_unstable();
1707    keys.dedup();
1708    false
1709}
1710
1711pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1712    writer: &mut dyn ResponseWriter,
1713    scratch: &mut Vec<u8>,
1714    bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1715) -> Result<()> {
1716    let mut wrote_field = false;
1717
1718    writer.write("{")?;
1719    if let Some(declaration_id) = bootstrap.declaration_id {
1720        write_json_field(
1721            writer,
1722            scratch,
1723            &mut wrote_field,
1724            "declarationId",
1725            &declaration_id,
1726        )?;
1727    }
1728    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1729        write_json_field(
1730            writer,
1731            scratch,
1732            &mut wrote_field,
1733            "enclosingSpanInstanceId",
1734            &span_id,
1735        )?;
1736    }
1737    if !bootstrap.chain.is_empty() {
1738        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1739    }
1740    // Definitions must be visible before template metadata can cause the
1741    // runtime to create a component root.
1742    write_json_field(
1743        writer,
1744        scratch,
1745        &mut wrote_field,
1746        "componentStyles",
1747        bootstrap.component_styles,
1748    )?;
1749    if !bootstrap.css_hrefs.is_empty() {
1750        write_json_field(
1751            writer,
1752            scratch,
1753            &mut wrote_field,
1754            "css",
1755            bootstrap.css_hrefs,
1756        )?;
1757    }
1758    write_json_field(
1759        writer,
1760        scratch,
1761        &mut wrote_field,
1762        "inventory",
1763        bootstrap.inventory,
1764    )?;
1765    if let Some(nonce) = bootstrap.nonce {
1766        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1767    }
1768    match bootstrap.state {
1769        WebUiBootstrapState::Complete { value, selection } => {
1770            write_json_field_name(writer, &mut wrote_field, "state")?;
1771            write_selected_state(writer, scratch, value, &selection)?;
1772        }
1773        WebUiBootstrapState::Reference {
1774            record_sequence,
1775            value,
1776            delta,
1777        } => {
1778            write_json_field(
1779                writer,
1780                scratch,
1781                &mut wrote_field,
1782                "stateRef",
1783                &record_sequence,
1784            )?;
1785            if let Some(delta) = delta {
1786                write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1787                write_selected_state(writer, scratch, value, &delta)?;
1788            }
1789        }
1790    }
1791    if !bootstrap.style_specs.is_empty() {
1792        write_json_field(
1793            writer,
1794            scratch,
1795            &mut wrote_field,
1796            "styles",
1797            bootstrap.style_specs,
1798        )?;
1799    }
1800    if bootstrap
1801        .templates
1802        .iter()
1803        .any(|template| !template.template_json.is_empty())
1804    {
1805        write_json_field_name(writer, &mut wrote_field, "templates")?;
1806        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1807    }
1808    writer.write("}")
1809}
1810
1811fn write_webui_data_block(
1812    writer: &mut dyn ResponseWriter,
1813    scratch: &mut Vec<u8>,
1814    bootstrap: WebUiBootstrap<'_>,
1815) -> Result<()> {
1816    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1817    if let Some(nonce) = bootstrap.nonce {
1818        writer.write(" nonce=\"")?;
1819        writer.write(nonce)?;
1820        writer.write("\"")?;
1821    }
1822
1823    writer.write(">")?;
1824    write_webui_bootstrap(writer, scratch, bootstrap)?;
1825    writer.write("</script>\n")
1826}
1827
1828fn write_component_asset_style_manifest(
1829    writer: &mut dyn ResponseWriter,
1830    manifest: &str,
1831    nonce: Option<&str>,
1832) -> Result<()> {
1833    if manifest.is_empty() {
1834        return Ok(());
1835    }
1836    writer.write("<script type=\"application/json\" id=\"")?;
1837    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1838    writer.write("\"")?;
1839    if let Some(nonce) = nonce {
1840        writer.write(" nonce=\"")?;
1841        writer.write(&crate::html_encode::encode_safe(nonce))?;
1842        writer.write("\"")?;
1843    }
1844    writer.write(">")?;
1845    writer.write(manifest)?;
1846    writer.write("</script>")
1847}
1848
1849fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1850    if context.component_asset_styles_emitted {
1851        return Ok(());
1852    }
1853    context.component_asset_styles_emitted = true;
1854    context.writer.write(context.component_asset_style_links)?;
1855    write_component_asset_style_manifest(
1856        context.writer,
1857        context.component_asset_style_manifest,
1858        context.nonce,
1859    )
1860}
1861
1862fn write_webui_template_json_map(
1863    writer: &mut dyn ResponseWriter,
1864    scratch: &mut Vec<u8>,
1865    templates: &[WebUiTemplatePayload<'_>],
1866) -> Result<()> {
1867    writer.write("{")?;
1868    let mut wrote = false;
1869    for template in templates {
1870        if template.template_json.is_empty() {
1871            continue;
1872        }
1873        if wrote {
1874            writer.write(",")?;
1875        }
1876        wrote = true;
1877        write_script_safe_json(writer, scratch, template.tag_name)?;
1878        writer.write(":")?;
1879        write_script_safe_json_str(writer, template.template_json)?;
1880    }
1881    writer.write("}")
1882}
1883
1884fn resolve_value_from_sources<'ctx, 'state>(
1885    path: &str,
1886    loop_vars: &'ctx [LoopBinding<'_, 'state>],
1887    visible_loop_scope: VisibleLoopScope,
1888    local_values: LocalValueSources<'ctx, '_, 'state>,
1889    state: &'state Value,
1890) -> Option<Cow<'ctx, Value>>
1891where
1892    'state: 'ctx,
1893{
1894    if let Some(binding) = loop_vars
1895        .get(visible_loop_scope.start..visible_loop_scope.end)
1896        .and_then(|bindings| bindings.last())
1897    {
1898        let name = binding.name;
1899        if path.len() == name.len() && path == name {
1900            return Some(Cow::Borrowed(binding.value));
1901        }
1902        if path.len() > name.len()
1903            && path.as_bytes().get(name.len()) == Some(&b'.')
1904            && path.starts_with(name)
1905        {
1906            if let Some(value) =
1907                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1908            {
1909                return Some(value);
1910            }
1911            return find_value_by_dotted_path_ref(path, state);
1912        }
1913    }
1914
1915    if let Some(first_part) = path.split('.').next() {
1916        if let Some(local_value) = local_values.borrowed.get(first_part) {
1917            if first_part.len() == path.len() {
1918                return Some(Cow::Borrowed(local_value));
1919            }
1920            let remaining = &path[first_part.len() + 1..];
1921            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1922                return Some(value);
1923            }
1924            return find_value_by_dotted_path_ref(path, state);
1925        }
1926        if let Some(local_value) = local_values.owned.get(first_part) {
1927            if first_part.len() == path.len() {
1928                return Some(Cow::Borrowed(local_value));
1929            }
1930            let remaining = &path[first_part.len() + 1..];
1931            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1932                return Some(value);
1933            }
1934            return find_value_by_dotted_path_ref(path, state);
1935        }
1936
1937        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1938            .iter()
1939            .rev()
1940        {
1941            if binding.name != first_part {
1942                continue;
1943            }
1944            if first_part.len() == path.len() {
1945                return Some(Cow::Borrowed(binding.value));
1946            }
1947            let remaining = &path[first_part.len() + 1..];
1948            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1949                return Some(value);
1950            }
1951            return find_value_by_dotted_path_ref(path, state);
1952        }
1953    }
1954
1955    find_value_by_dotted_path_ref(path, state)
1956}
1957
1958fn resolve_borrowed_collection<'state>(
1959    path: &str,
1960    loop_vars: &[LoopBinding<'_, 'state>],
1961    visible_loop_scope: VisibleLoopScope,
1962    local_values: LocalValueSources<'_, '_, 'state>,
1963    state: &'state Value,
1964) -> Option<&'state [Value]> {
1965    let first_part = path.split('.').next()?;
1966    if let Some(binding) = loop_vars
1967        .get(visible_loop_scope.start..visible_loop_scope.end)
1968        .and_then(|bindings| bindings.last())
1969        .filter(|binding| binding.name == first_part)
1970    {
1971        if first_part.len() == path.len() {
1972            return binding.value.as_array().map(Vec::as_slice);
1973        }
1974        let remaining = &path[first_part.len() + 1..];
1975        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1976            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1977            Some(Cow::Owned(_)) => None,
1978            None => borrowed_state_array(path, state),
1979        };
1980    }
1981    if let Some(local_value) = local_values.borrowed.get(first_part) {
1982        let local_match = if first_part.len() == path.len() {
1983            Some(Cow::Borrowed(local_value))
1984        } else {
1985            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1986        };
1987        return match local_match {
1988            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1989            Some(Cow::Owned(_)) => None,
1990            None => borrowed_state_array(path, state),
1991        };
1992    }
1993    if let Some(local_value) = local_values.owned.get(first_part) {
1994        let local_match = if first_part.len() == path.len() {
1995            Some(Cow::Borrowed(local_value))
1996        } else {
1997            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1998        };
1999        if local_match.is_some() {
2000            return None;
2001        }
2002        return borrowed_state_array(path, state);
2003    }
2004
2005    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2006        .iter()
2007        .rev()
2008    {
2009        if binding.name != first_part {
2010            continue;
2011        }
2012        if first_part.len() == path.len() {
2013            return binding.value.as_array().map(Vec::as_slice);
2014        }
2015        let remaining = &path[first_part.len() + 1..];
2016        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2017            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2018            Some(Cow::Owned(_)) => None,
2019            None => borrowed_state_array(path, state),
2020        };
2021    }
2022
2023    borrowed_state_array(path, state)
2024}
2025
2026fn resolve_state_backed_value<'state>(
2027    path: &str,
2028    loop_vars: &[LoopBinding<'_, 'state>],
2029    visible_loop_scope: VisibleLoopScope,
2030    local_values: LocalValueSources<'_, '_, 'state>,
2031    state: &'state Value,
2032) -> Option<&'state Value> {
2033    let first_part = path.split('.').next()?;
2034    if let Some(binding) = loop_vars
2035        .get(visible_loop_scope.start..visible_loop_scope.end)
2036        .and_then(|bindings| bindings.last())
2037        .filter(|binding| binding.name == first_part)
2038    {
2039        if first_part.len() == path.len() {
2040            return Some(binding.value);
2041        }
2042        let remaining = &path[first_part.len() + 1..];
2043        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2044            Some(Cow::Borrowed(value)) => Some(value),
2045            Some(Cow::Owned(_)) => None,
2046            None => borrowed_state_value(path, state),
2047        };
2048    }
2049    if let Some(local_value) = local_values.borrowed.get(first_part) {
2050        if first_part.len() == path.len() {
2051            return Some(local_value);
2052        }
2053        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2054            Some(Cow::Borrowed(value)) => Some(value),
2055            Some(Cow::Owned(_)) => None,
2056            None => borrowed_state_value(path, state),
2057        };
2058    }
2059    if let Some(local_value) = local_values.owned.get(first_part) {
2060        if first_part.len() == path.len() {
2061            return None;
2062        }
2063        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2064            Some(_) => None,
2065            None => borrowed_state_value(path, state),
2066        };
2067    }
2068
2069    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2070        .iter()
2071        .rev()
2072    {
2073        if binding.name != first_part {
2074            continue;
2075        }
2076        if first_part.len() == path.len() {
2077            return Some(binding.value);
2078        }
2079        let remaining = &path[first_part.len() + 1..];
2080        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2081            Some(Cow::Borrowed(value)) => Some(value),
2082            Some(Cow::Owned(_)) => None,
2083            None => borrowed_state_value(path, state),
2084        };
2085    }
2086
2087    borrowed_state_value(path, state)
2088}
2089
2090fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2091    match find_value_by_dotted_path_ref(path, state) {
2092        Some(Cow::Borrowed(value)) => Some(value),
2093        Some(Cow::Owned(_)) | None => None,
2094    }
2095}
2096
2097fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2098    match find_value_by_dotted_path_ref(path, state)? {
2099        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2100        Cow::Owned(_) => None,
2101    }
2102}
2103
2104impl WebUIHandler {
2105    /// Create a new WebUI handler with no plugin.
2106    pub fn new() -> Self {
2107        Self {
2108            plugin_factory: None,
2109        }
2110    }
2111
2112    /// Create a new WebUI handler with a plugin factory.
2113    ///
2114    /// Each render call creates a fresh plugin instance from the factory,
2115    /// enabling concurrent renders with `&self`.
2116    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2117        Self {
2118            plugin_factory: Some(factory),
2119        }
2120    }
2121
2122    #[cfg(test)]
2123    fn handle(
2124        &self,
2125        document: &WebUIProtocol,
2126        state: &Value,
2127        options: &RenderOptions<'_>,
2128        writer: &mut dyn ResponseWriter,
2129    ) -> Result<()> {
2130        let protocol = Protocol::new(document.clone());
2131        self.render(&protocol, state, options, writer)
2132    }
2133
2134    /// Process a fragment by its prepared render slot, falling back to an ID
2135    /// lookup when the caller has no prepared slot (handler-generated hosts).
2136    ///
2137    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2138    /// such as loop iteration variables. This is separate from the global `state`.
2139    fn process_fragment_target<'data>(
2140        &self,
2141        target: Option<usize>,
2142        fragment_id: &str,
2143        context: &mut WebUIProcessContext<'data, '_, '_>,
2144    ) -> Result<()> {
2145        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2146            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2147        };
2148        let Some(fragment_list) = context.render_fragments.list(index) else {
2149            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2150        };
2151        self.process_fragment(fragment_list, context)
2152    }
2153
2154    /// Process a vector of fragments.
2155    ///
2156    /// The `context` maintains scope-specific variables that can be accessed by fragments
2157    /// during rendering, while `state` contains the global application state.
2158    fn process_fragment<'data>(
2159        &self,
2160        fragment_list: RenderFragmentList<'data>,
2161        context: &mut WebUIProcessContext<'data, '_, '_>,
2162    ) -> Result<()> {
2163        self.process_fragment_from(fragment_list, 0, context)
2164    }
2165
2166    fn process_fragment_from<'data>(
2167        &self,
2168        fragment_list: RenderFragmentList<'data>,
2169        start: usize,
2170        context: &mut WebUIProcessContext<'data, '_, '_>,
2171    ) -> Result<()> {
2172        let fragments = fragment_list.fragments;
2173        // Pre-scan: find the best matching route among sibling routes by specificity.
2174        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2175        // Resolves relative paths (`./`) using the current route_base.
2176        // Lists prepared without any route fragment cannot produce a match, so
2177        // the scan is skipped entirely for them.
2178        let best_route = if fragment_list.has_routes {
2179            route_renderer::find_best_route_match(
2180                fragments,
2181                context.request_path,
2182                &context.route_base,
2183                context.route_index,
2184            )
2185        } else {
2186            None
2187        };
2188        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2189    }
2190
2191    fn process_fragment_range<'data>(
2192        &self,
2193        fragment_list: RenderFragmentList<'data>,
2194        range: std::ops::Range<usize>,
2195        best_route: &Option<(String, route_matcher::RouteMatch)>,
2196        context: &mut WebUIProcessContext<'data, '_, '_>,
2197    ) -> Result<()> {
2198        let fragments = fragment_list.fragments;
2199        let Some(selected) = fragments.get(range.clone()) else {
2200            return Err(invalid_fragment_range_error(&range, fragments.len()));
2201        };
2202        for (offset, item) in selected.iter().enumerate() {
2203            let index = range.start + offset;
2204            if context.streaming.is_some() {
2205                validate_pending_streaming_root(item, context)?;
2206                validate_streaming_root_opening(&fragments[..index], item)?;
2207            }
2208            // The prepared target is only read by fragments that descend, so it
2209            // is resolved per-arm. Hoisting it here would charge every raw text
2210            // fragment for a lookup it never uses.
2211            match item.fragment.as_ref() {
2212                Some(Fragment::Raw(raw)) => {
2213                    context.writer.write(&raw.value)?;
2214                }
2215                Some(Fragment::Component(component)) => {
2216                    self.process_component(
2217                        component,
2218                        fragment_list.target(index),
2219                        ComponentHostOrigin::ParserProduced,
2220                        context,
2221                    )?;
2222                }
2223                Some(Fragment::ForLoop(for_loop)) => {
2224                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2225                }
2226                Some(Fragment::Signal(signal)) => {
2227                    self.process_signal(signal, context)?;
2228                }
2229                Some(Fragment::IfCond(if_cond)) => {
2230                    self.process_if(if_cond, fragment_list.target(index), context)?;
2231                }
2232                Some(Fragment::Attribute(attr)) => {
2233                    self.process_attribute(
2234                        attr,
2235                        fragment_list.target(index),
2236                        fragment_list.component_attr_name(index),
2237                        context,
2238                    )?;
2239                }
2240                Some(Fragment::Plugin(plugin_frag)) => {
2241                    if let Some(p) = &mut context.plugin {
2242                        p.on_element_data(&plugin_frag.data, context.writer)?;
2243                    }
2244                }
2245                Some(Fragment::Route(route_frag)) => {
2246                    self.process_route(route_frag, best_route, context)?;
2247                }
2248                Some(Fragment::Outlet(_)) => {
2249                    self.process_outlet(context)?;
2250                }
2251                Some(Fragment::Boundary(_)) => {}
2252                None => {}
2253            }
2254        }
2255        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2256    }
2257
2258    /// Process an `<outlet />` directive.
2259    ///
2260    /// Matches children from the currently active route's `children` field
2261    /// against the request path, renders the matched child `<webui-route>`
2262    /// elements directly at this position (no wrapper element).
2263    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2264        let mut children = std::mem::take(&mut context.route_children);
2265        if children.is_empty() {
2266            return Ok(());
2267        }
2268
2269        // Find the best matching child route
2270        let request_segments = route_matcher::split_request_path(context.request_path);
2271        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2272        for (idx, child) in children.iter().enumerate() {
2273            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2274                context.route_index,
2275                &child.path,
2276                &context.route_base,
2277                &request_segments,
2278                child.exact,
2279            ) {
2280                let is_better = best
2281                    .as_ref()
2282                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2283                if is_better {
2284                    best = Some((idx, m));
2285                }
2286            }
2287        }
2288
2289        // Extract grandchildren from the matched child to avoid cloning.
2290        // We swap out the children vec so we can move it into context without
2291        // cloning, then swap an empty vec back for the sibling rendering pass.
2292        let grandchildren = if let Some((idx, _)) = &best {
2293            std::mem::take(&mut children[*idx].children)
2294        } else {
2295            Vec::new()
2296        };
2297
2298        if let Some((idx, ref rm)) = best {
2299            let matched_child = &children[idx];
2300            let comp = &matched_child.fragment_id;
2301
2302            if !comp.is_empty() {
2303                let saved_route_base = context.route_base.clone();
2304                let saved_route_children = std::mem::take(&mut context.route_children);
2305
2306                if rm.consumed_segments > 0 {
2307                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
2308                        context.request_path,
2309                        rm.consumed_segments,
2310                    ));
2311                }
2312
2313                context.route_children = grandchildren;
2314
2315                // Emit matched <webui-route>
2316                context.writer.write("<webui-route")?;
2317                context.writer.write(" path=\"")?;
2318                context.writer.write(&matched_child.path)?;
2319                context.writer.write("\"")?;
2320                context.writer.write(" component=\"")?;
2321                context.writer.write(comp)?;
2322                context.writer.write("\"")?;
2323                if matched_child.exact {
2324                    context.writer.write(" exact")?;
2325                }
2326                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2327                // Emit data-ri for O(1) client-side element binding
2328                let ri = context.route_chain_index;
2329                context.route_chain_index += 1;
2330                context.writer.write(" data-ri=\"")?;
2331                write_usize(context.writer, ri)?;
2332                context.writer.write("\" active>")?;
2333
2334                if !Self::component_owns_css_tree(comp, context.protocol) {
2335                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2336                }
2337                if !matched_child.content_fragment_id.is_empty() {
2338                    self.process_fragment_target(
2339                        None,
2340                        &matched_child.content_fragment_id,
2341                        context,
2342                    )?;
2343                }
2344
2345                context.writer.write("<")?;
2346                context.writer.write(comp)?;
2347                if let Some(p) = &context.plugin {
2348                    p.write_route_component_state(context.state, context.writer)?;
2349                }
2350                write_interaction_marker(comp, context)?;
2351                prepare_generated_streaming_root(comp, context)?;
2352                context.writer.write(">")?;
2353
2354                self.process_component(
2355                    &webui_protocol::WebUIFragmentComponent {
2356                        fragment_id: comp.clone(),
2357                    },
2358                    None,
2359                    ComponentHostOrigin::HandlerGenerated,
2360                    context,
2361                )?;
2362
2363                context.writer.write("</")?;
2364                context.writer.write(comp)?;
2365                context.writer.write(">")?;
2366                context.writer.write("</webui-route>")?;
2367
2368                context.route_base = saved_route_base;
2369                context.route_children = saved_route_children;
2370            }
2371        }
2372
2373        // Render non-matched siblings as hidden
2374        for (idx, child) in children.iter().enumerate() {
2375            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2376            if !is_matched && !child.fragment_id.is_empty() {
2377                context.writer.write("<webui-route")?;
2378                context.writer.write(" path=\"")?;
2379                context.writer.write(&child.path)?;
2380                context.writer.write("\"")?;
2381                context.writer.write(" component=\"")?;
2382                context.writer.write(&child.fragment_id)?;
2383                context.writer.write("\"")?;
2384                if child.exact {
2385                    context.writer.write(" exact")?;
2386                }
2387                route_renderer::write_route_navigation_attrs(context.writer, child)?;
2388                context
2389                    .writer
2390                    .write(" style=\"display:none\"></webui-route>")?;
2391            }
2392        }
2393
2394        Ok(())
2395    }
2396
2397    /// Emit a `<script type="importmap">` tag that registers a component's
2398    /// CSS module under its specifier via a `data:text/css,…` URI.
2399    ///
2400    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2401    /// independent importmap that the browser merges at the document
2402    /// level. The per-render CSP nonce is applied when set (importmap
2403    /// scripts honor `script-src`).
2404    ///
2405    /// Example for `my-comp` with CSS `span{color:blue;}`:
2406    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2407    fn emit_css_module_importmap(
2408        &self,
2409        specifier: &str,
2410        css: &str,
2411        context: &mut WebUIProcessContext,
2412    ) -> Result<()> {
2413        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2414        context.writer.write(&tag)?;
2415        Ok(())
2416    }
2417
2418    /// Install one compiler-ordered closure into the active CSS tree.
2419    fn emit_component_style_closure(
2420        &self,
2421        root: &str,
2422        install: StyleClosureInstall,
2423        context: &mut WebUIProcessContext,
2424    ) -> Result<()> {
2425        if context.protocol.style_closures.is_empty() {
2426            return Ok(());
2427        }
2428        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2429            match context.shadow_style_roots.last() {
2430                Some(shadow_root) => {
2431                    if !shadow_root.static_closure_emitted {
2432                        return Err(HandlerError::Invariant(
2433                            "routed styles reached a ShadowRoot before its static style hook"
2434                                .to_string(),
2435                        ));
2436                    }
2437                    let root_name = context
2438                        .component_index
2439                        .iter()
2440                        .find_map(|(name, index)| {
2441                            (*index == shadow_root.component_index).then_some(name.as_str())
2442                        })
2443                        .ok_or_else(|| {
2444                            HandlerError::Invariant(
2445                                "active Shadow style root lost its protocol index".to_string(),
2446                            )
2447                        })?;
2448                    Some(
2449                        context
2450                            .protocol
2451                            .style_closures
2452                            .get(root_name)
2453                            .ok_or_else(|| {
2454                                HandlerError::Invariant(format!(
2455                            "component style closure metadata is missing Shadow root `{root_name}`"
2456                        ))
2457                            })?,
2458                    )
2459                }
2460                None => None,
2461            }
2462        } else {
2463            None
2464        };
2465        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2466            HandlerError::Invariant(format!(
2467                "component style closure metadata is missing root `{root}`"
2468            ))
2469        })?;
2470        let is_document_tree = context.shadow_style_roots.is_empty();
2471        let strategy = context.css_strategy;
2472
2473        // A bundled build delivers merged chunks; otherwise every component
2474        // delivers its own stylesheet. Both walk the closure in the same order,
2475        // so the emitted cascade is identical either way. Bundling is a
2476        // build-wide decision, so the two never mix within one protocol.
2477        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2478        let mut emitted_resources = HashSet::with_capacity(unit_count);
2479
2480        for position in 0..unit_count {
2481            let unit = context
2482                .protocol
2483                .style_closure_unit(closure, &context.style_chunk_index, position)
2484                .ok_or_else(|| {
2485                    HandlerError::Invariant(format!(
2486                        "component style closure `{root}` references out-of-range unit {position}"
2487                    ))
2488                })?;
2489            let (name, chunk) = (unit.name, unit.chunk);
2490            if !emitted_resources.insert(name) {
2491                continue;
2492            }
2493            let resource = unit.resource.ok_or_else(|| match chunk {
2494                Some(index) => HandlerError::Invariant(format!(
2495                    "component style closure `{root}` references missing style chunk {index}"
2496                )),
2497                None => HandlerError::Invariant(format!(
2498                    "component style closure `{root}` references missing resource `{name}`"
2499                )),
2500            })?;
2501            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2502                continue;
2503            }
2504            if let Some(static_closure) = shadow_static_closure {
2505                let resource_index = match chunk {
2506                    Some(index) => {
2507                        if static_closure.style_chunks.contains(&index) {
2508                            continue;
2509                        }
2510                        index
2511                    }
2512                    None => {
2513                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2514                            continue;
2515                        }
2516                        context.component_index.get(name).copied().ok_or_else(|| {
2517                            HandlerError::Invariant(format!(
2518                                "component style resource `{name}` is missing its protocol index"
2519                            ))
2520                        })?
2521                    }
2522                };
2523                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2524                    HandlerError::Invariant(
2525                        "active Shadow style root disappeared during routed style delivery"
2526                            .to_string(),
2527                    )
2528                })?;
2529                if shadow_root.routed_resources.contains(&resource_index) {
2530                    continue;
2531                }
2532                shadow_root.routed_resources.push(resource_index);
2533            }
2534
2535            match strategy {
2536                webui_protocol::CssStrategy::Link => {
2537                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2538                    context
2539                        .writer
2540                        .write(&crate::html_encode::encode_safe(resource))?;
2541                    context.writer.write("\" data-webui-resource=\"")?;
2542                    context
2543                        .writer
2544                        .write(&crate::html_encode::encode_safe(name))?;
2545                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2546                }
2547                strategy => {
2548                    context.writer.write("<style")?;
2549                    if let Some(nonce) = context.nonce {
2550                        context.writer.write(" nonce=\"")?;
2551                        context
2552                            .writer
2553                            .write(&crate::html_encode::encode_safe(nonce))?;
2554                        context.writer.write("\"")?;
2555                    }
2556                    context.writer.write(" data-webui-resource=\"")?;
2557                    context
2558                        .writer
2559                        .write(&crate::html_encode::encode_safe(name))?;
2560                    context.writer.write("\" data-webui-strategy=\"")?;
2561                    context
2562                        .writer
2563                        .write(if strategy == webui_protocol::CssStrategy::Module {
2564                            "module"
2565                        } else {
2566                            "style"
2567                        })?;
2568                    context.writer.write("\">")?;
2569                    if context.style_resources_requiring_escape.contains(name) {
2570                        crate::html_encode::write_style_text(context.writer, resource)?;
2571                    } else {
2572                        context.writer.write(resource)?;
2573                    }
2574                    context.writer.write("</style>")?;
2575                }
2576            }
2577        }
2578        Ok(())
2579    }
2580
2581    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2582        if context.route_chain.is_none() {
2583            let plan = crate::route_handler::collect_route_chain_plan(
2584                context.protocol,
2585                context.entry_id,
2586                context.request_path,
2587                context.route_index,
2588            );
2589            context.route_document_style_targets = plan.document_style_targets;
2590            context.route_chain = Some(plan.entries);
2591        }
2592    }
2593
2594    fn emit_active_route_styles(
2595        &self,
2596        preloaded: &mut Vec<u32>,
2597        context: &mut WebUIProcessContext,
2598    ) -> Result<()> {
2599        Self::ensure_request_route_chain(context);
2600        let Some(chain) = context.route_chain.take() else {
2601            return Err(route_style_plan_missing_error());
2602        };
2603        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2604        if chain.len() != document_targets.len() {
2605            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2606            context.route_document_style_targets = document_targets;
2607            context.route_chain = Some(chain);
2608            return Err(error);
2609        }
2610
2611        let result = (|| {
2612            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2613                if *targets_document {
2614                    self.emit_component_style_closure(
2615                        &entry.component,
2616                        StyleClosureInstall::Static,
2617                        context,
2618                    )?;
2619                    continue;
2620                }
2621
2622                if context.css_strategy != webui_protocol::CssStrategy::Link {
2623                    continue;
2624                }
2625                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2626            }
2627            Ok(())
2628        })();
2629
2630        context.route_document_style_targets = document_targets;
2631        context.route_chain = Some(chain);
2632        result
2633    }
2634
2635    fn emit_component_style_preloads(
2636        &self,
2637        root: &str,
2638        preloaded: &mut Vec<u32>,
2639        context: &mut WebUIProcessContext,
2640    ) -> Result<()> {
2641        let Some(closure) = context.protocol.style_closures.get(root) else {
2642            return Ok(());
2643        };
2644        // A chunk shared by several tree-local roots needs one preload.
2645        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2646        for position in 0..unit_count {
2647            let Some(unit) =
2648                context
2649                    .protocol
2650                    .style_closure_unit(closure, &context.style_chunk_index, position)
2651            else {
2652                continue;
2653            };
2654            let Some(href) = unit.resource else {
2655                continue;
2656            };
2657            let name = unit.name;
2658            if context.document_style_resources.contains(name) {
2659                continue;
2660            }
2661            let Some(&resource_index) = context.style_resource_index.get(name) else {
2662                continue;
2663            };
2664            if preloaded.contains(&resource_index) {
2665                continue;
2666            }
2667            context
2668                .writer
2669                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2670            context
2671                .writer
2672                .write(&crate::html_encode::encode_safe(href))?;
2673            context.writer.write("\">")?;
2674            preloaded.push(resource_index);
2675        }
2676        Ok(())
2677    }
2678
2679    fn emit_reachable_shadow_preloads(
2680        &self,
2681        preloaded: &mut Vec<u32>,
2682        context: &mut WebUIProcessContext,
2683    ) -> Result<()> {
2684        if context.css_strategy != webui_protocol::CssStrategy::Link {
2685            return Ok(());
2686        }
2687        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2688            crate::route_handler::collect_reachable_component_order_for_request(
2689                context.protocol,
2690                context.entry_id,
2691                context.request_path,
2692                context.route_index,
2693            )
2694        });
2695        let result = (|| {
2696            for component in &reachable {
2697                if context.protocol.component_uses_shadow_dom(component) {
2698                    self.emit_component_style_preloads(component, preloaded, context)?;
2699                }
2700            }
2701            Ok(())
2702        })();
2703        context.reachable_components = Some(reachable);
2704        result
2705    }
2706
2707    fn process_shadow_style_signal(
2708        &self,
2709        root: &str,
2710        context: &mut WebUIProcessContext,
2711    ) -> Result<()> {
2712        let Some(root_index) = context.component_index.get(root).copied() else {
2713            return Err(HandlerError::Invariant(format!(
2714                "Shadow style hook `{root}` references an unknown component"
2715            )));
2716        };
2717        if context
2718            .shadow_style_roots
2719            .last()
2720            .map(|shadow_root| shadow_root.component_index)
2721            != Some(root_index)
2722        {
2723            return Err(HandlerError::Invariant(format!(
2724                "Shadow style hook `{root}` does not match the active component root"
2725            )));
2726        }
2727        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2728        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2729            HandlerError::Invariant(
2730                "active Shadow style root disappeared after its static style hook".to_string(),
2731            )
2732        })?;
2733        shadow_root.static_closure_emitted = true;
2734        Ok(())
2735    }
2736
2737    /// Emit a component's CSS module importmap on its first render
2738    /// (deduped by `rendered_components`) into the component's light DOM,
2739    /// so the browser registers it under the component's specifier
2740    /// before the shadow root template is parsed. See
2741    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2742    ///
2743    /// Only components rendered on the current route get inline definitions;
2744    /// navigation responses carry later definitions in `componentStyles`.
2745    fn emit_css_module(
2746        &self,
2747        component: &webui_protocol::WebUIFragmentComponent,
2748        context: &mut WebUIProcessContext,
2749    ) -> Result<()> {
2750        if context.css_strategy != webui_protocol::CssStrategy::Module {
2751            return Ok(());
2752        }
2753        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2754            streaming_template_already_sent(
2755                streaming,
2756                context.component_index,
2757                &component.fragment_id,
2758            )
2759        });
2760        if !metadata_already_streamed
2761            && !context.rendered_components.contains(&component.fragment_id)
2762        {
2763            if let Some(css) = context
2764                .protocol
2765                .components
2766                .get(&component.fragment_id)
2767                .map(|c| c.css.as_str())
2768                .filter(|s| !s.is_empty())
2769            {
2770                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2771            }
2772        }
2773        Ok(())
2774    }
2775
2776    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2777    fn process_route(
2778        &self,
2779        route_frag: &webui_protocol::WebUiFragmentRoute,
2780        best_route: &Option<(String, route_matcher::RouteMatch)>,
2781        context: &mut WebUIProcessContext,
2782    ) -> Result<()> {
2783        let is_matched = best_route
2784            .as_ref()
2785            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2786
2787        context.writer.write("<webui-route")?;
2788        context.writer.write(" path=\"")?;
2789        context.writer.write(&route_frag.path)?;
2790        context.writer.write("\"")?;
2791        if !route_frag.fragment_id.is_empty() {
2792            context.writer.write(" component=\"")?;
2793            context.writer.write(&route_frag.fragment_id)?;
2794            context.writer.write("\"")?;
2795        }
2796        if route_frag.exact {
2797            context.writer.write(" exact")?;
2798        }
2799        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2800
2801        if is_matched {
2802            // Emit data-ri for O(1) client-side element binding
2803            let ri = context.route_chain_index;
2804            context.route_chain_index += 1;
2805            context.writer.write(" data-ri=\"")?;
2806            write_usize(context.writer, ri)?;
2807            context.writer.write("\" active>")?;
2808
2809            let saved_route_base = context.route_base.clone();
2810            let saved_route_children = std::mem::take(&mut context.route_children);
2811            if let Some((_, ref rm)) = best_route {
2812                context.route_base = Cow::Owned(route_matcher::compute_route_base(
2813                    context.request_path,
2814                    rm.consumed_segments,
2815                ));
2816            }
2817            context.route_children = route_frag.children.clone();
2818
2819            if !route_frag.content_fragment_id.is_empty() {
2820                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2821            }
2822
2823            if !route_frag.fragment_id.is_empty() {
2824                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2825                    self.emit_component_style_closure(
2826                        &route_frag.fragment_id,
2827                        StyleClosureInstall::Routed,
2828                        context,
2829                    )?;
2830                }
2831                let comp = webui_protocol::WebUIFragmentComponent {
2832                    fragment_id: route_frag.fragment_id.clone(),
2833                };
2834
2835                context.writer.write("<")?;
2836                context.writer.write(&route_frag.fragment_id)?;
2837                if let Some(p) = &context.plugin {
2838                    p.write_route_component_state(context.state, context.writer)?;
2839                }
2840                write_interaction_marker(&route_frag.fragment_id, context)?;
2841                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2842                context.writer.write(">")?;
2843
2844                self.process_component(
2845                    &comp,
2846                    None,
2847                    ComponentHostOrigin::HandlerGenerated,
2848                    context,
2849                )?;
2850
2851                context.writer.write("</")?;
2852                context.writer.write(&route_frag.fragment_id)?;
2853                context.writer.write(">")?;
2854            }
2855            context.route_base = saved_route_base;
2856            context.route_children = saved_route_children;
2857        } else {
2858            context.writer.write(" style=\"display:none\">")?;
2859        }
2860
2861        context.writer.write("</webui-route>")?;
2862        Ok(())
2863    }
2864
2865    /// Process a component fragment.
2866    fn process_component(
2867        &self,
2868        component: &webui_protocol::WebUIFragmentComponent,
2869        target: Option<usize>,
2870        origin: ComponentHostOrigin,
2871        context: &mut WebUIProcessContext,
2872    ) -> Result<()> {
2873        if context.streaming.is_some() {
2874            consume_streaming_component_root(&component.fragment_id, origin, context)?;
2875            // Capture only after root parity succeeds, so malformed protocols
2876            // cannot contribute unmarked hosts to a checkpoint.
2877            record_checkpoint_tag(context, &component.fragment_id);
2878        }
2879
2880        // Emit the component's CSS module importmap into its light DOM and track
2881        // the component as rendered on first encounter only. `rendered_components`
2882        // is a set, so gating the `insert` (and its `String` clone) behind the
2883        // first-encounter check avoids allocating a throwaway `String` for every
2884        // duplicate instance while keeping the set contents identical.
2885        if !context.rendered_components.contains(&component.fragment_id) {
2886            self.emit_css_module(component, context)?;
2887            context
2888                .rendered_components
2889                .insert(component.fragment_id.clone());
2890        }
2891
2892        let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2893        if owns_css_tree {
2894            Self::push_shadow_style_root(&component.fragment_id, context)?;
2895        }
2896
2897        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
2898        let saved_local_vars = std::mem::take(&mut context.local_vars);
2899        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2900        // The component's accumulated attrs become its local vars; the next
2901        // sibling accumulates into a recycled (capacity-preserving) map from the
2902        // request-local pool instead of a freshly allocated `HashMap`.
2903        let saved_component_attrs = std::mem::replace(
2904            &mut context.component_attrs,
2905            take_scope_map(&mut context.scope_pool),
2906        );
2907        let saved_component_borrowed_attrs = std::mem::replace(
2908            &mut context.component_borrowed_attrs,
2909            take_borrowed_scope(&mut context.borrowed_scope_pool),
2910        );
2911        context.local_vars = saved_component_attrs;
2912        context.local_borrowed_vars = saved_component_borrowed_attrs;
2913        context.collecting_component_attrs = false;
2914        let saved_loop_scope = context.visible_loop_scope;
2915        context.visible_loop_scope = VisibleLoopScope {
2916            start: context.loop_vars.len(),
2917            end: context.loop_vars.len(),
2918        };
2919
2920        if let Some(p) = &mut context.plugin {
2921            p.push_scope();
2922        }
2923
2924        let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2925        context.visible_loop_scope = saved_loop_scope;
2926
2927        if owns_css_tree {
2928            Self::pop_shadow_style_root(&component.fragment_id, context)?;
2929        }
2930        render_result?;
2931
2932        if let Some(p) = &mut context.plugin {
2933            p.pop_scope();
2934        }
2935
2936        // Restore parent scope, recycling this component's local map (its
2937        // accumulated attrs) back into the pool so a sibling reuses its capacity.
2938        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2939        recycle_scope_map(&mut context.scope_pool, used_locals);
2940        let used_borrowed_locals =
2941            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2942        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2943        // The attr accumulator (pulled from the pool above) is cleared for the
2944        // next sibling while retaining its bucket capacity.
2945        context.component_attrs.clear();
2946        context.component_borrowed_attrs.clear();
2947
2948        Ok(())
2949    }
2950
2951    #[inline]
2952    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2953        !protocol.style_closures.is_empty()
2954            && protocol
2955                .components
2956                .get(component)
2957                .is_some_and(|data| data.uses_shadow_dom)
2958    }
2959
2960    pub(crate) fn push_shadow_style_root(
2961        component: &str,
2962        context: &mut WebUIProcessContext,
2963    ) -> Result<()> {
2964        if !context.protocol.style_closures.contains_key(component) {
2965            return Err(HandlerError::Invariant(format!(
2966                "component style closure metadata is missing Shadow root `{component}`"
2967            )));
2968        }
2969        let root_index = context
2970            .component_index
2971            .get(component)
2972            .copied()
2973            .ok_or_else(|| {
2974                HandlerError::Invariant(format!(
2975                    "Shadow component `{component}` is missing its protocol index"
2976                ))
2977            })?;
2978        context.shadow_style_roots.push(ShadowStyleRoot {
2979            component_index: root_index,
2980            static_closure_emitted: false,
2981            routed_resources: Vec::new(),
2982        });
2983        Ok(())
2984    }
2985
2986    pub(crate) fn pop_shadow_style_root(
2987        component: &str,
2988        context: &mut WebUIProcessContext,
2989    ) -> Result<()> {
2990        context.shadow_style_roots.pop().ok_or_else(|| {
2991            HandlerError::Invariant(format!(
2992                "Shadow component `{component}` lost its active style root"
2993            ))
2994        })?;
2995        Ok(())
2996    }
2997
2998    /// Resolve a dotted path into owned state for values retained by the context.
2999    fn resolve_value_owned(
3000        &self,
3001        path: &str,
3002        context: &WebUIProcessContext<'_, '_, '_>,
3003    ) -> Option<Value> {
3004        resolve_value_from_sources(
3005            path,
3006            &context.loop_vars,
3007            context.visible_loop_scope,
3008            LocalValueSources {
3009                owned: &context.local_vars,
3010                borrowed: &context.local_borrowed_vars,
3011            },
3012            context.state,
3013        )
3014        .map(Cow::into_owned)
3015    }
3016
3017    /// Evaluate a condition expression against the current context.
3018    ///
3019    /// Uses a resolver closure that checks local variables first, then falls
3020    /// back to global state — avoiding a full clone of the state tree.
3021    /// Missing identifier operands are falsy before logical operators are applied.
3022    /// Missing predicate values make the complete condition false.
3023    fn evaluate_condition(
3024        &self,
3025        condition: &webui_protocol::ConditionExpr,
3026        context: &WebUIProcessContext,
3027    ) -> Result<bool> {
3028        let loop_vars = &context.loop_vars;
3029        let visible_loop_scope = context.visible_loop_scope;
3030        let local_values = LocalValueSources {
3031            owned: &context.local_vars,
3032            borrowed: &context.local_borrowed_vars,
3033        };
3034        let state = context.state;
3035        match evaluate_with_resolver(condition, |path| {
3036            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3037        }) {
3038            Ok(result) => Ok(result),
3039            Err(ExpressionError::MissingValue(_)) => Ok(false),
3040            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3041        }
3042    }
3043
3044    /// Process a for loop fragment.
3045    ///
3046    /// Creates a new context for each iteration that includes the current loop item.
3047    /// This allows nested templates to access both the loop variable and any parent context.
3048    /// Example: `for item in items` makes "item" available in the loop body.
3049    fn process_for_loop<'protocol, 'state>(
3050        &self,
3051        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3052        target: Option<usize>,
3053        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3054    ) -> Result<()> {
3055        if let Some(items) = resolve_borrowed_collection(
3056            &for_loop.collection,
3057            &context.loop_vars,
3058            context.visible_loop_scope,
3059            LocalValueSources {
3060                owned: &context.local_vars,
3061                borrowed: &context.local_borrowed_vars,
3062            },
3063            context.state,
3064        ) {
3065            return self.process_borrowed_for_loop(for_loop, target, items, context);
3066        }
3067        self.process_owned_for_loop(for_loop, target, context)
3068    }
3069
3070    fn process_borrowed_for_loop<'protocol, 'state>(
3071        &self,
3072        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3073        target: Option<usize>,
3074        items: &'state [Value],
3075        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3076    ) -> Result<()> {
3077        if let Some(plugin) = &mut context.plugin {
3078            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3079        }
3080
3081        let item_name = for_loop.item.as_str();
3082        let saved_value = context.local_vars.remove(item_name);
3083        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3084        let saved_scope = context.visible_loop_scope;
3085        for (index, item) in items.iter().enumerate() {
3086            if let Some(plugin) = &mut context.plugin {
3087                plugin.on_repeat_item_start(index, context.writer)?;
3088                plugin.push_scope();
3089            }
3090
3091            context.loop_vars.push(LoopBinding {
3092                name: item_name,
3093                value: item,
3094            });
3095            context.visible_loop_scope.end = context.loop_vars.len();
3096            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3097            context.loop_vars.pop();
3098            context.visible_loop_scope = saved_scope;
3099
3100            if let Some(plugin) = &mut context.plugin {
3101                plugin.pop_scope();
3102                plugin.on_repeat_item_end(index, context.writer)?;
3103            }
3104        }
3105        if let Some(value) = saved_value {
3106            context.local_vars.insert(item_name.to_string(), value);
3107        }
3108        if let Some(value) = saved_borrowed_value {
3109            context.local_borrowed_vars.insert(item_name, value);
3110        }
3111
3112        if let Some(plugin) = &mut context.plugin {
3113            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3114        }
3115        Ok(())
3116    }
3117
3118    fn process_owned_for_loop<'protocol, 'state>(
3119        &self,
3120        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3121        target: Option<usize>,
3122        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3123    ) -> Result<()> {
3124        let collection_name = &for_loop.collection;
3125
3126        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3127        // Hydration comments are always emitted regardless of collection presence.
3128        let items = match self.resolve_value_owned(collection_name, context) {
3129            Some(Value::Array(arr)) => arr,
3130            Some(_) => {
3131                return Err(HandlerError::TypeError(format!(
3132                    "Collection '{}' is not an array",
3133                    collection_name
3134                )))
3135            }
3136            None => Vec::new(),
3137        };
3138
3139        if let Some(p) = &mut context.plugin {
3140            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3141        }
3142
3143        // Hot-loop optimisation: the loop variable name is `String`-keyed
3144        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3145        // the key) on every iteration — a 1000-item loop pays 2000 String
3146        // clones for the key alone. Instead, we save the outer-scope
3147        // value (if any) ONCE before the loop, install the key ONCE with
3148        // an empty placeholder, then overwrite the value in-place each
3149        // iteration via `get_mut`. Restoration at the end happens once.
3150        let item_name = for_loop.item.as_str();
3151        let saved_value = context.local_vars.remove(item_name);
3152        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3153        // Pre-insert the key so per-iteration `get_mut` is infallible.
3154        // Cost: at most one `String::from(item_name)` for the lifetime
3155        // of the loop, regardless of iteration count.
3156        if !items.is_empty() {
3157            context
3158                .local_vars
3159                .insert(item_name.to_string(), Value::Null);
3160        }
3161        for (i, item) in items.into_iter().enumerate() {
3162            if let Some(p) = &mut context.plugin {
3163                p.on_repeat_item_start(i, context.writer)?;
3164                p.push_scope();
3165            }
3166
3167            // O(1) value swap; no key allocation.
3168            if let Some(slot) = context.local_vars.get_mut(item_name) {
3169                *slot = item;
3170            }
3171            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3172
3173            if let Some(p) = &mut context.plugin {
3174                p.pop_scope();
3175                p.on_repeat_item_end(i, context.writer)?;
3176            }
3177        }
3178        // Restore outer scope (or remove the placeholder we installed).
3179        match saved_value {
3180            Some(value) => {
3181                context.local_vars.insert(item_name.to_string(), value);
3182            }
3183            None => {
3184                context.local_vars.remove(item_name);
3185            }
3186        }
3187        if let Some(value) = saved_borrowed_value {
3188            context.local_borrowed_vars.insert(item_name, value);
3189        }
3190
3191        if let Some(p) = &mut context.plugin {
3192            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3193        }
3194
3195        Ok(())
3196    }
3197
3198    /// Process a signal fragment.
3199    ///
3200    /// Looks up the value in the context first (for local variables), then in the global state.
3201    /// This prioritization allows local variables (like loop items) to override global state.
3202    /// If the value is not found in either scope, an empty string is returned.
3203    #[inline]
3204    fn process_signal<'data>(
3205        &self,
3206        signal: &'data webui_protocol::WebUIFragmentSignal,
3207        context: &mut WebUIProcessContext<'data, '_, '_>,
3208    ) -> Result<()> {
3209        if signal.raw {
3210            self.process_raw_signal(signal, context)
3211        } else {
3212            self.process_state_signal(signal, false, context)
3213        }
3214    }
3215
3216    #[inline(never)]
3217    fn process_raw_signal<'data>(
3218        &self,
3219        signal: &'data webui_protocol::WebUIFragmentSignal,
3220        context: &mut WebUIProcessContext<'data, '_, '_>,
3221    ) -> Result<()> {
3222        let Some(structural_value) = structural_signal_value(signal) else {
3223            return self.process_state_signal(signal, !signal.raw_text_context, context);
3224        };
3225
3226        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3227            return self.process_shadow_style_signal(root, context);
3228        }
3229
3230        if context.streaming.is_some()
3231            && self.process_streaming_signal(structural_value, context)?
3232        {
3233            return Ok(());
3234        }
3235
3236        // Hook: emit nonce meta and Document-owned CSS before </head>.
3237        // Guarded by `head_end_emitted` so a malformed protocol cannot
3238        // emit nonce/preloads/inject more than once per render.
3239        if structural_value == "head_end" && !context.head_end_emitted {
3240            context.head_end_emitted = true;
3241            if let Some(nonce) = context.nonce {
3242                context
3243                    .writer
3244                    .write("<meta name=\"webui-nonce\" content=\"")?;
3245                context
3246                    .writer
3247                    .write(&crate::html_encode::encode_safe(nonce))?;
3248                context.writer.write("\">")?;
3249            }
3250
3251            // Render-policy CSS is emitted before component styles so an
3252            // authored declaration still wins on a tie.
3253            if !context.protocol.component_render_css.is_empty() {
3254                context.writer.write("<style data-webui-render-policy")?;
3255                if let Some(nonce) = context.nonce {
3256                    context.writer.write(" nonce=\"")?;
3257                    context
3258                        .writer
3259                        .write(&crate::html_encode::encode_safe(nonce))?;
3260                    context.writer.write("\"")?;
3261                }
3262                context.writer.write(">")?;
3263                context
3264                    .writer
3265                    .write(&context.protocol.component_render_css)?;
3266                context.writer.write("</style>")?;
3267            }
3268
3269            if !context.protocol.style_closures.is_empty() {
3270                let entry_id = context.entry_id;
3271                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3272                let mut preloaded = Vec::new();
3273                self.emit_active_route_styles(&mut preloaded, context)?;
3274                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3275            }
3276
3277            // Compiler-resolved `modulepreload` hints for the shared chunks
3278            // the page's module entries statically import. Those chunks are
3279            // named only inside the entry's own bytes, so without this the
3280            // browser must download and parse the entry before it can even
3281            // discover them.
3282            //
3283            // Emitted after the CSS links on purpose: CSS is render-blocking
3284            // and owns first paint, while these own first interaction, so the
3285            // stylesheet requests go out first. The list arrives pre-ordered
3286            // (largest chunk first) and pre-resolved from the build, so this
3287            // is a straight write with no per-request work.
3288            if !context.protocol.module_preloads.is_empty() {
3289                for href in &context.protocol.module_preloads {
3290                    context
3291                        .writer
3292                        .write("<link rel=\"modulepreload\" href=\"")?;
3293                    context.writer.write(href)?;
3294                    context.writer.write("\">")?;
3295                }
3296            }
3297
3298            write_component_asset_styles(context)?;
3299
3300            // Per-render `head_inject` HTML — image preloads, A/B test
3301            // markers, etc. supplied by the host via RenderOptions.
3302            // Emitted at the structural head_end boundary, after the
3303            // built-in nonce + CSS-link emissions, so host injects
3304            // appear immediately before `</head>`.
3305            if let Some(html) = context.head_inject {
3306                context.writer.write(html)?;
3307            }
3308
3309            // Reserved-state `headEnd` HTML, last at this boundary so a
3310            // host that sets both channels gets a deterministic order:
3311            // built-in emissions, then `RenderOptions`, then state.
3312            if let Some(html) = context.state_inject.head_end {
3313                context.writer.write(html)?;
3314            }
3315        }
3316
3317        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3318        // by its own dedup flag so a malformed protocol cannot duplicate it.
3319        if structural_value == "body_start" && !context.body_start_emitted {
3320            context.body_start_emitted = true;
3321            write_component_asset_styles(context)?;
3322            if let Some(html) = context.state_inject.body_start {
3323                context.writer.write(html)?;
3324            }
3325        }
3326
3327        // Hook: emit component templates and host body_inject before </body>.
3328        // Single guarded block so the dedup flag protects both the
3329        // hydration emission and the host inject from a malformed
3330        // protocol that fires `body_end` more than once per render.
3331        if structural_value == "body_end" && !context.body_end_emitted {
3332            context.body_end_emitted = true;
3333            if context.plugin.is_some() {
3334                // Emit templates for all REACHABLE components on the current route,
3335                // not just those rendered in this SSR pass. Components inside false
3336                // <if> blocks or empty <for> loops are reachable via client-side
3337                // state changes and need their templates available without a server
3338                // round-trip. The graph walker follows conditional and loop branches
3339                // unconditionally, but only descends into the matched route chain —
3340                // components on other routes are delivered via SPA partial navigation.
3341                let reachable = context
3342                    .reachable_components
3343                    .take()
3344                    .unwrap_or_else(|| {
3345                        crate::route_handler::collect_reachable_component_order_for_request(
3346                            context.protocol,
3347                            context.entry_id,
3348                            context.request_path,
3349                            context.route_index,
3350                        )
3351                    })
3352                    .into_iter()
3353                    .collect::<HashSet<_>>();
3354                let state_selection =
3355                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3356
3357                // Emit CSS module importmaps for reachable-but-unrendered
3358                // components so the framework can adopt them when an `<if>`
3359                // condition flips true client-side.
3360                if context.css_strategy == webui_protocol::CssStrategy::Module {
3361                    for name in &reachable {
3362                        if !context.rendered_components.contains(name) {
3363                            if let Some(css) = context
3364                                .protocol
3365                                .components
3366                                .get(name)
3367                                .map(|c| c.css.as_str())
3368                                .filter(|s| !s.is_empty())
3369                            {
3370                                self.emit_css_module_importmap(name, css, context)?;
3371                            }
3372                        }
3373                    }
3374                }
3375
3376                // Try to collect split WebUI template payloads. If the plugin
3377                // returns None (non-WebUI templates, e.g. FAST), fall back to
3378                // separate emission.
3379                let template_payloads = context
3380                    .plugin
3381                    .as_ref()
3382                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3383
3384                if template_payloads.is_none() {
3385                    // Non-JS templates (FAST plugins) - emit separately
3386                    if let Some(ref p) = context.plugin {
3387                        p.emit_templates(
3388                            context.protocol,
3389                            &reachable,
3390                            context.nonce,
3391                            context.writer,
3392                        )?;
3393                    }
3394                }
3395
3396                // Compute the inventory hex from actually rendered components.
3397                let inventory_hex = crate::route_handler::encode_component_inventory(
3398                    &context.rendered_components,
3399                    context.component_index,
3400                );
3401
3402                // Chain
3403                Self::ensure_request_route_chain(context);
3404                let chain = context.route_chain.as_deref().ok_or_else(|| {
3405                    HandlerError::Invariant(
3406                        "request route chain disappeared after collection".to_string(),
3407                    )
3408                })?;
3409                let chain_json: Vec<Value> = chain
3410                    .iter()
3411                    .map(crate::route_handler::RouteChainEntry::to_json)
3412                    .collect();
3413
3414                // CSS hrefs emitted during SSR (Link-strategy components)
3415                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3416                let mut css_hrefs: Vec<&str> = Vec::new();
3417                if is_link {
3418                    for name in &reachable {
3419                        if let Some(href) = context
3420                            .protocol
3421                            .components
3422                            .get(name)
3423                            .map(|c| c.css_href.as_str())
3424                            .filter(|h| !h.is_empty())
3425                        {
3426                            css_hrefs.push(href);
3427                        }
3428                    }
3429                }
3430
3431                // Module style specifiers emitted during SSR
3432                let mut style_specs: Vec<&str> = Vec::new();
3433                if context.css_strategy == webui_protocol::CssStrategy::Module {
3434                    for name in &reachable {
3435                        if context
3436                            .protocol
3437                            .components
3438                            .get(name)
3439                            .map(|c| !c.css.is_empty())
3440                            .unwrap_or(false)
3441                        {
3442                            style_specs.push(name);
3443                        }
3444                    }
3445                }
3446
3447                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3448                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3449                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3450                style_roots.push(context.entry_id);
3451                for entry in chain {
3452                    style_roots.push(entry.component.as_str());
3453                    if !entry.pending_component.is_empty() {
3454                        style_roots.push(entry.pending_component.as_str());
3455                    }
3456                    if !entry.error_component.is_empty() {
3457                        style_roots.push(entry.error_component.as_str());
3458                    }
3459                }
3460                style_roots.extend(reachable.iter().map(String::as_str));
3461                let component_styles =
3462                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3463                write_webui_data_block(
3464                    context.writer,
3465                    &mut context.json_scratch,
3466                    WebUiBootstrap {
3467                        declaration_id: None,
3468                        enclosing_span_instance_id: None,
3469                        state: WebUiBootstrapState::Complete {
3470                            value: context.state,
3471                            selection: state_selection,
3472                        },
3473                        chain: &chain_json,
3474                        inventory: &inventory_hex,
3475                        nonce: context.nonce,
3476                        css_hrefs: &css_hrefs,
3477                        style_specs: &style_specs,
3478                        component_styles: &component_styles,
3479                        templates: payloads,
3480                    },
3481                )?;
3482
3483                // Let the active plugin emit any framework-specific executable
3484                // side channel. FAST plugins default to no-op; WebUI installs
3485                // templateFns. Client packages parse #webui-data lazily.
3486                if let Some(ref plugin) = context.plugin {
3487                    plugin.emit_bootstrap_extension(
3488                        BootstrapExtensionContext {
3489                            protocol: context.protocol,
3490                            components: &reachable,
3491                            payloads,
3492                            nonce: context.nonce,
3493                        },
3494                        context.writer,
3495                    )?;
3496                }
3497            }
3498
3499            // Per-render `body_inject` HTML — dev livereload script,
3500            // analytics, etc. supplied by the host via RenderOptions.
3501            // Inside the dedup block but outside the plugin-only
3502            // sub-block above, so it fires regardless of whether a
3503            // hydration plugin is active. Appears immediately before
3504            // `</body>`.
3505            if let Some(html) = context.body_inject {
3506                context.writer.write(html)?;
3507            }
3508
3509            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3510            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3511            if let Some(html) = context.state_inject.body_end {
3512                context.writer.write(html)?;
3513            }
3514        }
3515
3516        // Structural signals are never state lookups. In particular, ordinary
3517        // rendering ignores boundary/root markers byte-for-byte, while authored
3518        // raw bindings with the same visible key remain ordinary state.
3519        Ok(())
3520    }
3521
3522    /// Process a plain (non-structural) signal by resolving its value and
3523    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3524    ///
3525    /// `owns_html_range` only controls whether the active plugin emits
3526    /// replaceable sibling markers around the value (`on_binding_start/end`);
3527    /// it never affects escaping. In particular, `signal.raw_text_context`
3528    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3529    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3530    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3531    /// escaped binding inside an HTML raw-text element is an authoring
3532    /// footgun rather than something this function can safely correct.
3533    #[inline]
3534    fn process_state_signal(
3535        &self,
3536        signal: &webui_protocol::WebUIFragmentSignal,
3537        owns_html_range: bool,
3538        context: &mut WebUIProcessContext,
3539    ) -> Result<()> {
3540        if let Some(p) = &mut context.plugin {
3541            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3542        }
3543
3544        if let Some(value) = resolve_value_from_sources(
3545            &signal.value,
3546            &context.loop_vars,
3547            context.visible_loop_scope,
3548            LocalValueSources {
3549                owned: &context.local_vars,
3550                borrowed: &context.local_borrowed_vars,
3551            },
3552            context.state,
3553        ) {
3554            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3555        }
3556
3557        if let Some(p) = &mut context.plugin {
3558            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3559        }
3560        Ok(())
3561    }
3562
3563    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3564    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3565    ///
3566    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3567    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3568    /// context: it does not know whether it is writing into a raw-text
3569    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3570    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3571    fn write_signal_value(
3572        &self,
3573        value: &Value,
3574        raw: bool,
3575        writer: &mut dyn ResponseWriter,
3576    ) -> Result<()> {
3577        if raw {
3578            match value {
3579                Value::String(s) => writer.write(s),
3580                _ => writer.write(&value.to_string()),
3581            }
3582        } else {
3583            match value {
3584                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3585                _ => {
3586                    let s = value.to_string();
3587                    writer.write(&crate::html_encode::encode_safe(&s))
3588                }
3589            }
3590        }
3591    }
3592
3593    /// Process an if condition fragment.
3594    fn process_if(
3595        &self,
3596        if_cond: &webui_protocol::WebUIFragmentIf,
3597        target: Option<usize>,
3598        context: &mut WebUIProcessContext,
3599    ) -> Result<()> {
3600        let condition = if_cond
3601            .condition
3602            .as_ref()
3603            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3604        let condition_met = self.evaluate_condition(condition, context)?;
3605
3606        if let Some(p) = &mut context.plugin {
3607            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3608        }
3609
3610        if condition_met {
3611            if let Some(p) = &mut context.plugin {
3612                p.push_scope();
3613            }
3614
3615            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3616
3617            if let Some(p) = &mut context.plugin {
3618                p.pop_scope();
3619            }
3620        }
3621
3622        if let Some(p) = &mut context.plugin {
3623            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3624        }
3625
3626        Ok(())
3627    }
3628
3629    /// Process an attribute fragment by rendering the attribute name/value pair.
3630    ///
3631    /// `template_target` and `component_name` are prepared once per protocol by
3632    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3633    /// camelCase prop-name conversion is repeated per render.
3634    fn process_attribute<'protocol, 'state>(
3635        &self,
3636        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3637        template_target: Option<usize>,
3638        component_name: Option<&'protocol str>,
3639        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3640    ) -> Result<()> {
3641        // Initialize component attribute accumulator on attrStart. Clearing the
3642        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3643        if attr.attr_start {
3644            context.component_attrs.clear();
3645            context.component_borrowed_attrs.clear();
3646            context.collecting_component_attrs = true;
3647        }
3648
3649        // Boolean attribute with condition tree
3650        if let Some(condition) = &attr.condition_tree {
3651            let condition_met = self.evaluate_condition(condition, context)?;
3652
3653            if context.collecting_component_attrs && !attr.attr_skip {
3654                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3655                context.component_borrowed_attrs.remove(name);
3656                context
3657                    .component_attrs
3658                    .insert(name.to_owned(), Value::Bool(condition_met));
3659            }
3660
3661            if condition_met {
3662                context.writer.write(" ")?;
3663                context.writer.write(&attr.name)?;
3664            }
3665            return Ok(());
3666        }
3667
3668        // Template attribute (mixed static + dynamic)
3669        if !attr.template.is_empty() {
3670            let raw_value =
3671                self.render_template_attr_value(&attr.template, template_target, context)?;
3672            let escaped = crate::html_encode::encode_safe(&raw_value);
3673            write_attr(context.writer, &attr.name, &escaped)?;
3674
3675            if context.collecting_component_attrs && !attr.attr_skip {
3676                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3677                context.component_borrowed_attrs.remove(name);
3678                context
3679                    .component_attrs
3680                    .insert(name.to_owned(), Value::String(raw_value));
3681            }
3682            return Ok(());
3683        }
3684
3685        // Simple attribute
3686        if !attr.value.is_empty() {
3687            if attr.raw_value {
3688                // Static attribute — value is the literal string
3689                write_attr(context.writer, &attr.name, &attr.value)?;
3690                if context.collecting_component_attrs && !attr.attr_skip {
3691                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3692                    context.component_borrowed_attrs.remove(name);
3693                    context
3694                        .component_attrs
3695                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3696                }
3697            } else if attr.complex {
3698                // Complex attribute — resolve value, don't render to HTML, store as state
3699                if context.collecting_component_attrs && !attr.attr_skip {
3700                    // Streaming starts borrowed too. The continuation VM
3701                    // materializes these values only when the target component
3702                    // can actually suspend; boundary-free components finish in
3703                    // this call and retain the ordinary zero-copy path.
3704                    let state_backed_value = resolve_state_backed_value(
3705                        &attr.value,
3706                        &context.loop_vars,
3707                        context.visible_loop_scope,
3708                        LocalValueSources {
3709                            owned: &context.local_vars,
3710                            borrowed: &context.local_borrowed_vars,
3711                        },
3712                        context.state,
3713                    );
3714                    if let Some(value) = state_backed_value {
3715                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3716                        context.component_attrs.remove(name);
3717                        context.component_borrowed_attrs.insert(name, value);
3718                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3719                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3720                        context.component_borrowed_attrs.remove(name);
3721                        context.component_attrs.insert(name.to_owned(), value);
3722                    }
3723                }
3724            } else {
3725                // Dynamic attribute — resolve and render
3726                // As above, a boundary-bearing continuation materializes a
3727                // borrowed component scope before it can escape this call.
3728                let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3729                    resolve_state_backed_value(
3730                        &attr.value,
3731                        &context.loop_vars,
3732                        context.visible_loop_scope,
3733                        LocalValueSources {
3734                            owned: &context.local_vars,
3735                            borrowed: &context.local_borrowed_vars,
3736                        },
3737                        context.state,
3738                    )
3739                } else {
3740                    None
3741                };
3742                // Reuse the immutable-state lookup for both HTML output and the
3743                // child scope instead of resolving every component attribute twice.
3744                let value = match state_backed_value {
3745                    Some(value) => Some(Cow::Borrowed(value)),
3746                    None => resolve_value_from_sources(
3747                        &attr.value,
3748                        &context.loop_vars,
3749                        context.visible_loop_scope,
3750                        LocalValueSources {
3751                            owned: &context.local_vars,
3752                            borrowed: &context.local_borrowed_vars,
3753                        },
3754                        context.state,
3755                    ),
3756                };
3757                // Always emit the attribute so FAST hydration markers
3758                // (`data-fe`) match the DOM node structure.
3759                match value.as_deref() {
3760                    Some(Value::String(s)) => {
3761                        write_attr(
3762                            context.writer,
3763                            &attr.name,
3764                            &crate::html_encode::encode_safe(s),
3765                        )?;
3766                    }
3767                    Some(Value::Null) | None => {
3768                        write_attr(context.writer, &attr.name, "")?;
3769                    }
3770                    Some(other) => {
3771                        let s = other.to_string();
3772                        write_attr(
3773                            context.writer,
3774                            &attr.name,
3775                            &crate::html_encode::encode_safe(&s),
3776                        )?;
3777                    }
3778                }
3779
3780                if context.collecting_component_attrs && !attr.attr_skip {
3781                    if let Some(borrowed) = state_backed_value {
3782                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3783                        context.component_attrs.remove(name);
3784                        context.component_borrowed_attrs.insert(name, borrowed);
3785                    } else {
3786                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3787                        context.component_borrowed_attrs.remove(name);
3788                        context.component_attrs.insert(
3789                            name.to_owned(),
3790                            value
3791                                .map(Cow::into_owned)
3792                                .unwrap_or(Value::String(String::new())),
3793                        );
3794                    }
3795                }
3796            }
3797        }
3798
3799        Ok(())
3800    }
3801
3802    /// Render a template attribute's fragments into a raw (unescaped) string.
3803    fn render_template_attr_value(
3804        &self,
3805        template_id: &str,
3806        target: Option<usize>,
3807        context: &WebUIProcessContext,
3808    ) -> Result<String> {
3809        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3810            return Err(HandlerError::MissingFragment(template_id.to_string()));
3811        };
3812        let fragments = context
3813            .render_fragments
3814            .list(index)
3815            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3816        let mut raw_value = String::new();
3817        for frag in fragments.fragments {
3818            match frag.fragment.as_ref() {
3819                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3820                Some(Fragment::Signal(signal)) => {
3821                    if let Some(value) = resolve_value_from_sources(
3822                        &signal.value,
3823                        &context.loop_vars,
3824                        context.visible_loop_scope,
3825                        LocalValueSources {
3826                            owned: &context.local_vars,
3827                            borrowed: &context.local_borrowed_vars,
3828                        },
3829                        context.state,
3830                    ) {
3831                        match value.as_ref() {
3832                            Value::String(s) => raw_value.push_str(s),
3833                            _ => raw_value.push_str(&value.to_string()),
3834                        }
3835                    }
3836                }
3837                _ => {}
3838            }
3839        }
3840        Ok(raw_value)
3841    }
3842
3843    /// Render the UI based on the protocol and state.
3844    pub fn render<'a>(
3845        &self,
3846        protocol: &'a Protocol,
3847        state: &'a Value,
3848        options: &RenderOptions<'a>,
3849        writer: &'a mut dyn ResponseWriter,
3850    ) -> Result<()> {
3851        protocol.ensure_style_metadata()?;
3852        let document = protocol.protocol();
3853        let render_fragments = protocol.render_fragments().resolve(document);
3854        let Some(entry_index) = render_fragments.index(options.entry_id) else {
3855            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3856        };
3857        let entry = render_fragments
3858            .list(entry_index)
3859            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3860        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3861        let has_document_head_boundary = !entry_owns_css_tree
3862            && !document.style_closures.is_empty()
3863            && entry.fragments.iter().any(|fragment| {
3864                matches!(
3865                    fragment.fragment.as_ref(),
3866                    Some(Fragment::Signal(signal))
3867                        if structural_signal_value(signal) == Some("head_end")
3868                )
3869            });
3870        let doctype_split = (!entry_owns_css_tree
3871            && !has_document_head_boundary
3872            && !document.style_closures.is_empty())
3873        .then(|| {
3874            let Some(Fragment::Raw(raw)) = entry
3875                .fragments
3876                .first()
3877                .and_then(|fragment| fragment.fragment.as_ref())
3878            else {
3879                return None;
3880            };
3881            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3882        })
3883        .flatten();
3884        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3885        let mut context = WebUIProcessContext {
3886            protocol: document,
3887            render_fragments,
3888            component_asset_style_manifest,
3889            component_asset_style_links: protocol.component_asset_style_links(),
3890            state,
3891            writer,
3892            local_vars: HashMap::new(),
3893            local_borrowed_vars: BorrowedScope::default(),
3894            loop_vars: Vec::new(),
3895            visible_loop_scope: VisibleLoopScope::EMPTY,
3896            component_attrs: HashMap::new(),
3897            component_borrowed_attrs: BorrowedScope::default(),
3898            collecting_component_attrs: false,
3899            request_path: options.request_path,
3900            route_base: Cow::Borrowed("/"),
3901            rendered_components: HashSet::new(),
3902            plugin: self.plugin_factory.map(|f| f()),
3903            route_children: Vec::new(),
3904            entry_id: options.entry_id,
3905            // Same defensive normalisation as `handle()`. See the
3906            // doc-comment there for the CSP-outage rationale.
3907            nonce: options.nonce.filter(|s| !s.is_empty()),
3908            head_inject: options.head_inject.filter(|s| !s.is_empty()),
3909            body_inject: options.body_inject.filter(|s| !s.is_empty()),
3910            state_inject: StateInject::resolve(state),
3911            head_end_emitted: false,
3912            body_start_emitted: false,
3913            component_asset_styles_emitted: false,
3914            component_index: protocol.component_index(),
3915            style_resource_index: protocol.style_resource_index(),
3916            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
3917            style_chunk_index: protocol.protocol().style_chunk_index(),
3918            css_strategy: protocol.css_strategy(),
3919            body_end_emitted: false,
3920            route_index: protocol.route_index(),
3921            route_chain_index: 0,
3922            route_chain: None,
3923            route_document_style_targets: Vec::new(),
3924            reachable_components: None,
3925            streaming: None,
3926            json_scratch: Vec::new(),
3927            scope_pool: Vec::new(),
3928            document_style_resources: HashSet::new(),
3929            shadow_style_roots: Vec::new(),
3930            borrowed_scope_pool: Vec::new(),
3931        };
3932
3933        if entry_owns_css_tree {
3934            Self::push_shadow_style_root(options.entry_id, &mut context)?;
3935        }
3936
3937        let render_result = if let Some((first_raw, split)) = doctype_split {
3938            context.writer.write(&first_raw[..split])?;
3939            self.emit_component_style_closure(
3940                options.entry_id,
3941                StyleClosureInstall::Static,
3942                &mut context,
3943            )?;
3944            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3945            context.writer.write(&first_raw[split..])?;
3946            self.process_fragment_from(entry, 1, &mut context)
3947        } else {
3948            if !entry_owns_css_tree
3949                && !has_document_head_boundary
3950                && !document.style_closures.is_empty()
3951            {
3952                self.emit_component_style_closure(
3953                    options.entry_id,
3954                    StyleClosureInstall::Static,
3955                    &mut context,
3956                )?;
3957                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3958            }
3959            self.process_fragment(entry, &mut context)
3960        };
3961
3962        if entry_owns_css_tree {
3963            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3964        }
3965        render_result?;
3966        writer.end()?;
3967
3968        Ok(())
3969    }
3970}
3971
3972impl Default for WebUIHandler {
3973    fn default() -> Self {
3974        Self::new()
3975    }
3976}
3977
3978/// Write ` name="value"` to the writer without allocating a format string.
3979fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3980    writer.write_attribute(name, value)
3981}
3982
3983#[cfg(test)]
3984fn handle(
3985    protocol: &WebUIProtocol,
3986    state: &Value,
3987    options: &RenderOptions<'_>,
3988    writer: &mut dyn ResponseWriter,
3989) -> Result<()> {
3990    let handler = WebUIHandler::new();
3991    handler.handle(protocol, state, options, writer)
3992}
3993
3994#[cfg(test)]
3995mod tests {
3996    use super::*;
3997    use crate::streaming::STREAMING_MARKER;
3998    use std::cell::RefCell;
3999    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4000    use webui_protocol::{
4001        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4002        WebUIFragmentAttribute, WebUiFragmentRoute,
4003    };
4004    use webui_test_utils::test_json;
4005
4006    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4007        WebUIFragment::signal(
4008            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4009            true,
4010        )
4011    }
4012
4013    #[test]
4014    fn borrowed_collection_reuses_state_array() {
4015        let state = test_json!({
4016            "items": [
4017                {"name": "first"},
4018                {"name": "second"}
4019            ]
4020        });
4021        let local_vars = HashMap::new();
4022        let local_borrowed_vars = BorrowedScope::default();
4023        let loop_vars = Vec::new();
4024        let items = resolve_borrowed_collection(
4025            "items",
4026            &loop_vars,
4027            VisibleLoopScope::EMPTY,
4028            LocalValueSources {
4029                owned: &local_vars,
4030                borrowed: &local_borrowed_vars,
4031            },
4032            &state,
4033        )
4034        .unwrap_or_else(|| panic!("state array should be borrowed"));
4035        let state_items = state["items"]
4036            .as_array()
4037            .unwrap_or_else(|| panic!("fixture items should be an array"));
4038        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4039    }
4040
4041    #[test]
4042    fn owned_local_collection_keeps_precedence() {
4043        let state = test_json!({"items": [{"name": "global"}]});
4044        let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
4045        let local_borrowed_vars = BorrowedScope::default();
4046        assert!(resolve_borrowed_collection(
4047            "items",
4048            &[],
4049            VisibleLoopScope::EMPTY,
4050            LocalValueSources {
4051                owned: &local_vars,
4052                borrowed: &local_borrowed_vars,
4053            },
4054            &state,
4055        )
4056        .is_none());
4057    }
4058
4059    #[test]
4060    fn borrowed_component_collection_reuses_state_array() {
4061        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4062        let contacts = &state["teams"][0]["contacts"];
4063        let mut local_borrowed_vars = BorrowedScope::default();
4064        local_borrowed_vars.insert("contacts", contacts);
4065        let local_vars = HashMap::new();
4066        let items = resolve_borrowed_collection(
4067            "contacts",
4068            &[],
4069            VisibleLoopScope::EMPTY,
4070            LocalValueSources {
4071                owned: &local_vars,
4072                borrowed: &local_borrowed_vars,
4073            },
4074            &state,
4075        )
4076        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4077        let state_items = contacts
4078            .as_array()
4079            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4080        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4081    }
4082
4083    #[test]
4084    fn nested_borrowed_collection_reuses_child_array() {
4085        let state = test_json!({
4086            "items": [{
4087                "children": [
4088                    {"name": "first"},
4089                    {"name": "second"}
4090                ]
4091            }]
4092        });
4093        let item = &state["items"][0];
4094        let loop_vars = vec![LoopBinding {
4095            name: "item",
4096            value: item,
4097        }];
4098        let local_vars = HashMap::new();
4099        let local_borrowed_vars = BorrowedScope::default();
4100        let children = resolve_borrowed_collection(
4101            "item.children",
4102            &loop_vars,
4103            VisibleLoopScope { start: 0, end: 1 },
4104            LocalValueSources {
4105                owned: &local_vars,
4106                borrowed: &local_borrowed_vars,
4107            },
4108            &state,
4109        )
4110        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4111        let state_children = item["children"]
4112            .as_array()
4113            .unwrap_or_else(|| panic!("fixture children should be an array"));
4114        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4115    }
4116
4117    #[test]
4118    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4119        let state = test_json!({
4120            "item": {"name": "global", "fallback": "global fallback"},
4121            "outer": {"name": "outer"},
4122            "inner": {"name": "inner"}
4123        });
4124        let loop_vars = vec![
4125            LoopBinding {
4126                name: "item",
4127                value: &state["outer"],
4128            },
4129            LoopBinding {
4130                name: "item",
4131                value: &state["inner"],
4132            },
4133        ];
4134        let local_vars = HashMap::new();
4135        let local_borrowed_vars = BorrowedScope::default();
4136        let sources = LocalValueSources {
4137            owned: &local_vars,
4138            borrowed: &local_borrowed_vars,
4139        };
4140
4141        assert_eq!(
4142            resolve_value_from_sources(
4143                "item.name",
4144                &loop_vars,
4145                VisibleLoopScope { start: 0, end: 2 },
4146                sources,
4147                &state,
4148            )
4149            .as_deref()
4150            .and_then(Value::as_str),
4151            Some("inner")
4152        );
4153        assert_eq!(
4154            resolve_value_from_sources(
4155                "item.fallback",
4156                &loop_vars,
4157                VisibleLoopScope { start: 0, end: 2 },
4158                sources,
4159                &state,
4160            )
4161            .as_deref()
4162            .and_then(Value::as_str),
4163            Some("global fallback")
4164        );
4165        assert_eq!(
4166            resolve_value_from_sources(
4167                "item.name",
4168                &loop_vars,
4169                VisibleLoopScope { start: 2, end: 2 },
4170                sources,
4171                &state,
4172            )
4173            .as_deref()
4174            .and_then(Value::as_str),
4175            Some("global")
4176        );
4177    }
4178
4179    #[test]
4180    fn state_backed_resolver_matches_general_borrowed_resolution() {
4181        let state = test_json!({
4182            "global": {"name": "global"},
4183            "inner": {"fallback": "global fallback"},
4184            "borrowed": {"name": "borrowed"},
4185            "items": [1, 2, 3]
4186        });
4187        let loop_values = test_json!({
4188            "outer": {"name": "outer"},
4189            "inner": {"name": "inner"}
4190        });
4191        let loop_vars = vec![
4192            LoopBinding {
4193                name: "outer",
4194                value: &loop_values["outer"],
4195            },
4196            LoopBinding {
4197                name: "inner",
4198                value: &loop_values["inner"],
4199            },
4200        ];
4201        let mut local_borrowed_vars = BorrowedScope::default();
4202        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4203        let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4204        let sources = LocalValueSources {
4205            owned: &local_vars,
4206            borrowed: &local_borrowed_vars,
4207        };
4208        let scope = VisibleLoopScope { start: 0, end: 2 };
4209
4210        for path in [
4211            "global.name",
4212            "inner.name",
4213            "inner.fallback",
4214            "outer.name",
4215            "borrowed.name",
4216        ] {
4217            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4218                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4219            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4220                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4221            let Cow::Borrowed(resolved) = resolved else {
4222                panic!("{path} should remain borrowed");
4223            };
4224            assert!(
4225                std::ptr::eq(state_backed, resolved),
4226                "{path} resolved from different sources"
4227            );
4228        }
4229
4230        assert!(
4231            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4232            "owned local values must use the general resolver"
4233        );
4234        assert!(
4235            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4236                .is_none(),
4237            "synthetic values must use the general resolver"
4238        );
4239    }
4240
4241    #[test]
4242    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4243        let values = [
4244            Value::String("a".to_string()),
4245            Value::String("b".to_string()),
4246            Value::String("c".to_string()),
4247            Value::String("d".to_string()),
4248            Value::String("e".to_string()),
4249            Value::String("f".to_string()),
4250            Value::String("replacement".to_string()),
4251        ];
4252        let mut scope = BorrowedScope::default();
4253        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4254            assert!(scope.insert(name, &values[index]).is_none());
4255        }
4256        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4257        assert_eq!(scope.overflow.len(), 2);
4258        assert_eq!(scope.get("a"), Some(&values[0]));
4259        assert_eq!(scope.get("f"), Some(&values[5]));
4260        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4261        assert_eq!(scope.get("a"), Some(&values[6]));
4262        assert_eq!(scope.remove("b"), Some(&values[1]));
4263        assert!(scope.get("b").is_none());
4264        assert_eq!(scope.get("f"), Some(&values[5]));
4265        scope.clear();
4266        assert!(scope.get("a").is_none());
4267        assert!(scope.overflow.is_empty());
4268    }
4269
4270    // A simple test writer implementation
4271    struct TestWriter {
4272        content: RefCell<String>,
4273        ended: RefCell<bool>,
4274    }
4275
4276    impl TestWriter {
4277        fn new() -> Self {
4278            Self {
4279                content: RefCell::new(String::new()),
4280                ended: RefCell::new(false),
4281            }
4282        }
4283
4284        fn get_content(&self) -> String {
4285            self.content.borrow().clone()
4286        }
4287
4288        fn is_ended(&self) -> bool {
4289            *self.ended.borrow()
4290        }
4291    }
4292
4293    impl ResponseWriter for TestWriter {
4294        fn write(&mut self, content: &str) -> Result<()> {
4295            self.content.borrow_mut().push_str(content);
4296            Ok(())
4297        }
4298
4299        fn end(&mut self) -> Result<()> {
4300            *self.ended.borrow_mut() = true;
4301            Ok(())
4302        }
4303    }
4304
4305    #[test]
4306    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4307        struct AttributeWriter {
4308            output: String,
4309        }
4310
4311        impl ResponseWriter for AttributeWriter {
4312            fn write(&mut self, content: &str) -> Result<()> {
4313                self.output.push_str(content);
4314                Ok(())
4315            }
4316
4317            crate::string_response_writer_methods!(output);
4318
4319            fn end(&mut self) -> Result<()> {
4320                Ok(())
4321            }
4322        }
4323
4324        let mut writer = AttributeWriter {
4325            output: String::new(),
4326        };
4327        write_attr(&mut writer, "data-id", "42")
4328            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4329        writer
4330            .write_boolean_attribute("disabled")
4331            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4332        assert_eq!(writer.output, " data-id=\"42\" disabled");
4333    }
4334
4335    #[test]
4336    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4337        let mut fragments = HashMap::new();
4338        fragments.insert(
4339            "index.html".to_string(),
4340            FragmentList {
4341                fragments: vec![
4342                    WebUIFragment {
4343                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4344                            name: ":data-title".to_string(),
4345                            value: "source".to_string(),
4346                            complex: true,
4347                            attr_start: true,
4348                            ..Default::default()
4349                        })),
4350                    },
4351                    WebUIFragment::component("prepared-child"),
4352                ],
4353                contains_boundary: false,
4354            },
4355        );
4356        fragments.insert(
4357            "prepared-child".to_string(),
4358            FragmentList {
4359                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4360                contains_boundary: false,
4361            },
4362        );
4363        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4364        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4365        let entry = resolved
4366            .list_by_id("index.html")
4367            .unwrap_or_else(|| panic!("entry render plan should exist"));
4368        let child_index = protocol
4369            .render_fragments()
4370            .index("prepared-child")
4371            .unwrap_or_else(|| panic!("component target should be indexed"));
4372        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4373        assert_eq!(entry.target(1), Some(child_index));
4374        assert!(!entry.has_routes);
4375        let source_entry = protocol
4376            .protocol()
4377            .fragments
4378            .get("index.html")
4379            .unwrap_or_else(|| panic!("source entry should exist"));
4380        assert!(std::ptr::eq(
4381            entry.fragments.as_ptr(),
4382            source_entry.fragments.as_ptr()
4383        ));
4384
4385        let state = test_json!({"source": "Prepared"});
4386        let options = RenderOptions::new("index.html", "/");
4387        for handler in [
4388            WebUIHandler::new(),
4389            WebUIHandler::with_plugin(|| {
4390                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4391            }),
4392        ] {
4393            let mut writer = TestWriter::new();
4394            assert!(handler
4395                .render(&protocol, &state, &options, &mut writer)
4396                .is_ok());
4397            assert!(writer.get_content().contains("Prepared"));
4398        }
4399    }
4400
4401    #[test]
4402    fn prepared_graph_records_route_presence_per_fragment_list() {
4403        let mut fragments = HashMap::new();
4404        fragments.insert(
4405            "index.html".to_string(),
4406            FragmentList {
4407                fragments: vec![WebUIFragment::route("/", "dash-page")],
4408                contains_boundary: false,
4409            },
4410        );
4411        fragments.insert(
4412            "dash-page".to_string(),
4413            FragmentList {
4414                fragments: vec![WebUIFragment::raw("dashboard")],
4415                contains_boundary: false,
4416            },
4417        );
4418        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4419        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4420        let entry = resolved
4421            .list_by_id("index.html")
4422            .unwrap_or_else(|| panic!("entry render plan should exist"));
4423        let page = resolved
4424            .list_by_id("dash-page")
4425            .unwrap_or_else(|| panic!("page render plan should exist"));
4426        // Only lists that actually contain a route pay for the sibling scan.
4427        assert!(entry.has_routes);
4428        assert!(!page.has_routes);
4429    }
4430
4431    #[test]
4432    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4433        // Preparation writes the camelCase conversion straight into the shared
4434        // arena instead of calling `attribute_to_camel`, which allocates. The two
4435        // must stay byte-identical, including the irregular lookup-table entries.
4436        let names = [
4437            "data-title",
4438            ":data-title",
4439            "aria-labelledby",
4440            "aria-valuemax",
4441            "readonly",
4442            "tabindex",
4443            "class",
4444            "for",
4445            "maxlength",
4446            "novalidate",
4447            "already-camel",
4448            "trailing-",
4449            "-leading",
4450            "multi-part-attribute-name",
4451            "single",
4452            "",
4453        ];
4454        for name in names {
4455            let stripped = name.strip_prefix(':').unwrap_or(name);
4456            assert_eq!(
4457                component_attr_name(name),
4458                webui_protocol::attrs::attribute_to_camel(stripped),
4459                "prepared name diverged for `{name}`"
4460            );
4461        }
4462    }
4463
4464    #[test]
4465    fn prepared_component_prop_names_match_per_branch_stripping() {
4466        // The render path strips a leading `:` twice on the complex-value branch
4467        // and once elsewhere. Preparation must reproduce that per branch or a
4468        // `::`-prefixed binding would change the emitted component prop name.
4469        let complex = WebUIFragmentAttribute {
4470            name: "::data-title".to_string(),
4471            value: "source".to_string(),
4472            complex: true,
4473            ..Default::default()
4474        };
4475        assert_eq!(component_attr_source(&complex), ":data-title");
4476        assert_eq!(
4477            component_attr_name(component_attr_source(&complex)),
4478            "dataTitle"
4479        );
4480
4481        // `raw_value` takes the static branch, which strips only once.
4482        let raw = WebUIFragmentAttribute {
4483            raw_value: true,
4484            ..complex.clone()
4485        };
4486        assert_eq!(component_attr_source(&raw), "::data-title");
4487
4488        // A template attribute returns before the complex branch is reached.
4489        let templated = WebUIFragmentAttribute {
4490            template: "tpl".to_string(),
4491            ..complex.clone()
4492        };
4493        assert_eq!(component_attr_source(&templated), "::data-title");
4494
4495        // An empty value never reaches the complex branch either.
4496        let empty_value = WebUIFragmentAttribute {
4497            value: String::new(),
4498            ..complex.clone()
4499        };
4500        assert_eq!(component_attr_source(&empty_value), "::data-title");
4501
4502        // Ordinary single-colon bindings are unaffected on every branch.
4503        let ordinary = WebUIFragmentAttribute {
4504            name: ":data-title".to_string(),
4505            ..complex
4506        };
4507        assert_eq!(component_attr_source(&ordinary), "data-title");
4508        assert_eq!(
4509            component_attr_name(component_attr_source(&ordinary)),
4510            "dataTitle"
4511        );
4512    }
4513
4514    #[test]
4515    fn render_slots_match_continuation_slots() {
4516        // The streaming VM reads prepared metadata with the continuation slot
4517        // directly. That is only sound while both numberings index the same
4518        // sorted fragment IDs, so pin the invariant here rather than paying for
4519        // an ID lookup per streamed fragment frame.
4520        let mut fragments = HashMap::new();
4521        for id in [
4522            "index.html",
4523            "zeta-last",
4524            "alpha-first",
4525            "middle-one",
4526            "middle-two",
4527        ] {
4528            fragments.insert(
4529                id.to_string(),
4530                FragmentList {
4531                    fragments: vec![WebUIFragment::raw(id)],
4532                    contains_boundary: false,
4533                },
4534            );
4535        }
4536        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4537        let index = protocol.render_fragments();
4538        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4539            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4540        {
4541            let id = protocol
4542                .fragment_id(slot)
4543                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4544            assert_eq!(
4545                index.index(id),
4546                Some(slot as usize),
4547                "render slot must equal continuation slot for `{id}`"
4548            );
4549            assert_eq!(protocol.fragment_slot(id), Some(slot));
4550        }
4551    }
4552
4553    #[test]
4554    fn prepared_graph_reports_missing_fragments_without_panicking() {
4555        let mut fragments = HashMap::new();
4556        fragments.insert(
4557            "index.html".to_string(),
4558            FragmentList {
4559                fragments: vec![WebUIFragment::component("absent-child")],
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        // An unresolvable reference keeps a `None` target and must not index
4569        // out of range or silently render an unrelated fragment list.
4570        assert_eq!(entry.target(0), None);
4571        assert!(resolved.list_by_id("absent-child").is_none());
4572        assert!(resolved.list(usize::MAX).is_none());
4573
4574        let state = test_json!({});
4575        let options = RenderOptions::new("index.html", "/");
4576        let mut writer = TestWriter::new();
4577        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4578            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4579            other => panic!("expected a missing fragment error, got {other:?}"),
4580        }
4581
4582        let mut writer = TestWriter::new();
4583        match WebUIHandler::new().render(
4584            &protocol,
4585            &state,
4586            &RenderOptions::new("absent-entry", "/"),
4587            &mut writer,
4588        ) {
4589            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4590            other => panic!("expected a missing entry error, got {other:?}"),
4591        }
4592    }
4593
4594    #[test]
4595    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4596        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4597        let mut fragments = HashMap::with_capacity(list_count);
4598        for index in 0..list_count {
4599            fragments.insert(
4600                format!("fragment-{index}"),
4601                FragmentList {
4602                    fragments: vec![WebUIFragment::raw("content")],
4603                    contains_boundary: false,
4604                },
4605            );
4606        }
4607        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4608        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4609        // Nothing is materialized until a slot is actually rendered.
4610        assert!(resolved.cache[0].get().is_none());
4611        assert!(resolved.spilled.get().is_none());
4612        assert!(resolved.list(0).is_some());
4613        assert!(resolved.cache[0].get().is_some());
4614        // Rendering only inline slots never allocates the spill region.
4615        assert!(resolved.spilled.get().is_none());
4616        // Slots past the inline cache spill once, then stay memoized so a deep
4617        // descent never re-hashes the fragment ID.
4618        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4619        let spilled = resolved
4620            .spilled
4621            .get()
4622            .expect("a slot past the inline cache should allocate the spill region");
4623        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4624        assert!(spilled[0].get().is_some());
4625        assert!(resolved.list(list_count - 1).is_some());
4626
4627        for (id, source) in &protocol.protocol().fragments {
4628            let prepared = resolved
4629                .list_by_id(id)
4630                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4631            // Borrowed, never duplicated: the prepared list points at protocol storage.
4632            assert!(std::ptr::eq(
4633                prepared.fragments.as_ptr(),
4634                source.fragments.as_ptr()
4635            ));
4636        }
4637    }
4638
4639    #[test]
4640    fn test_handle_raw() {
4641        // Create a simple protocol
4642        let mut fragments = HashMap::new();
4643        fragments.insert(
4644            "index.html".to_string(),
4645            FragmentList {
4646                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4647                contains_boundary: false,
4648            },
4649        );
4650
4651        let protocol = WebUIProtocol::new(fragments);
4652        let state = test_json!({});
4653
4654        // Create a test writer
4655        let mut writer = TestWriter::new();
4656
4657        // Handle the protocol
4658        assert!(
4659            handle(
4660                &protocol,
4661                &state,
4662                &RenderOptions::new("index.html", "/"),
4663                &mut writer
4664            )
4665            .is_ok(),
4666            "Failed to handle raw protocol"
4667        );
4668
4669        // Check the output
4670        assert_eq!(writer.get_content(), "Hello, WebUI!");
4671        assert!(writer.is_ended());
4672    }
4673
4674    #[test]
4675    fn test_handle_signal() {
4676        // Create a protocol with a signal
4677        let mut fragments = HashMap::new();
4678        fragments.insert(
4679            "index.html".to_string(),
4680            FragmentList {
4681                fragments: vec![
4682                    WebUIFragment::raw("Hello, "),
4683                    WebUIFragment::signal("name", false),
4684                    WebUIFragment::raw("!"),
4685                ],
4686                contains_boundary: false,
4687            },
4688        );
4689
4690        let protocol = WebUIProtocol::new(fragments);
4691        let state = test_json!({"name": "WebUI"});
4692
4693        // Create a test writer
4694        let mut writer = TestWriter::new();
4695
4696        // Handle the protocol
4697        assert!(
4698            handle(
4699                &protocol,
4700                &state,
4701                &RenderOptions::new("index.html", "/"),
4702                &mut writer
4703            )
4704            .is_ok(),
4705            "Failed to handle signal protocol"
4706        );
4707
4708        // Check the output
4709        assert_eq!(writer.get_content(), "Hello, WebUI!");
4710        assert!(writer.is_ended());
4711    }
4712
4713    #[test]
4714    fn test_handle_for_loop() {
4715        // Create a protocol with a for loop
4716        let mut fragments = HashMap::new();
4717        fragments.insert(
4718            "index.html".to_string(),
4719            FragmentList {
4720                fragments: vec![
4721                    WebUIFragment::raw("People: "),
4722                    WebUIFragment::for_loop("person", "people", "person-item"),
4723                ],
4724                contains_boundary: false,
4725            },
4726        );
4727
4728        fragments.insert(
4729            "person-item".to_string(),
4730            FragmentList {
4731                fragments: vec![
4732                    WebUIFragment::signal("person.name", false),
4733                    WebUIFragment::raw(", "),
4734                ],
4735                contains_boundary: false,
4736            },
4737        );
4738
4739        let protocol = WebUIProtocol::new(fragments);
4740        let state = test_json!({
4741            "people": [
4742                {"name": "Alice"},
4743                {"name": "Bob"},
4744                {"name": "Charlie"}
4745            ]
4746        });
4747
4748        // Create a test writer
4749        let mut writer = TestWriter::new();
4750
4751        // Handle the protocol
4752        assert!(
4753            handle(
4754                &protocol,
4755                &state,
4756                &RenderOptions::new("index.html", "/"),
4757                &mut writer
4758            )
4759            .is_ok(),
4760            "Failed to handle for loop protocol"
4761        );
4762
4763        // Check the output
4764        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4765        assert!(writer.is_ended());
4766    }
4767
4768    #[test]
4769    fn test_handle_if_condition() {
4770        // Create a protocol with an if condition
4771        let mut fragments = HashMap::new();
4772        fragments.insert(
4773            "index.html".to_string(),
4774            FragmentList {
4775                fragments: vec![
4776                    WebUIFragment::raw("Status: "),
4777                    WebUIFragment::if_cond(
4778                        webui_protocol::ConditionExpr::identifier("isActive"),
4779                        "active-content",
4780                    ),
4781                    WebUIFragment::raw("End"),
4782                ],
4783                contains_boundary: false,
4784            },
4785        );
4786
4787        fragments.insert(
4788            "active-content".to_string(),
4789            FragmentList {
4790                fragments: vec![WebUIFragment::raw("Active")],
4791                contains_boundary: false,
4792            },
4793        );
4794
4795        let protocol = WebUIProtocol::new(fragments);
4796
4797        // Test with isActive = true
4798        let state_true = test_json!({"isActive": true});
4799        let mut writer_true = TestWriter::new();
4800        assert!(
4801            handle(
4802                &protocol,
4803                &state_true,
4804                &RenderOptions::new("index.html", "/"),
4805                &mut writer_true
4806            )
4807            .is_ok(),
4808            "Failed to handle if condition (true case)"
4809        );
4810        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4811        assert!(writer_true.is_ended());
4812
4813        // Test with isActive = false
4814        let state_false = test_json!({"isActive": false});
4815        let mut writer_false = TestWriter::new();
4816        assert!(
4817            handle(
4818                &protocol,
4819                &state_false,
4820                &RenderOptions::new("index.html", "/"),
4821                &mut writer_false
4822            )
4823            .is_ok(),
4824            "Failed to handle if condition (false case)"
4825        );
4826        assert_eq!(writer_false.get_content(), "Status: End");
4827        assert!(writer_false.is_ended());
4828    }
4829
4830    #[test]
4831    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4832        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>"#;
4833        let mut parser = HtmlParser::new();
4834        parser
4835            .parse("index.html", source)
4836            .expect("parse missing-path condition fixture");
4837        let protocol = WebUIProtocol::new(parser.into_fragment_records());
4838        let state = test_json!({
4839            "items": [
4840                {"label": "Normal"},
4841                {"label": "Search", "searchPresentation": true}
4842            ]
4843        });
4844        let mut writer = TestWriter::new();
4845
4846        handle(
4847            &protocol,
4848            &state,
4849            &RenderOptions::new("index.html", "/"),
4850            &mut writer,
4851        )
4852        .expect("render missing-path condition fixture");
4853
4854        assert_eq!(
4855            writer.get_content(),
4856            "top-negated<span>Normal</span><mark>Search</mark>"
4857        );
4858    }
4859
4860    #[test]
4861    fn native_dynamic_attribute_does_not_leak_into_next_component() {
4862        let mut fragments = HashMap::new();
4863        fragments.insert(
4864            "index.html".to_string(),
4865            FragmentList {
4866                fragments: vec![
4867                    WebUIFragment::raw("<div"),
4868                    WebUIFragment {
4869                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4870                            WebUIFragmentAttribute {
4871                                name: "title".into(),
4872                                value: "nativeTitle".into(),
4873                                ..Default::default()
4874                            },
4875                        )),
4876                    },
4877                    WebUIFragment::raw("></div><my-comp>"),
4878                    WebUIFragment::component("my-comp"),
4879                    WebUIFragment::raw("</my-comp>"),
4880                ],
4881                contains_boundary: false,
4882            },
4883        );
4884        fragments.insert(
4885            "my-comp".to_string(),
4886            FragmentList {
4887                fragments: vec![WebUIFragment::signal("title", false)],
4888                contains_boundary: false,
4889            },
4890        );
4891        let protocol = WebUIProtocol::new(fragments);
4892        let state = test_json!({
4893            "nativeTitle": "native",
4894            "title": "global"
4895        });
4896        let mut writer = TestWriter::new();
4897        handle(
4898            &protocol,
4899            &state,
4900            &RenderOptions::new("index.html", "/"),
4901            &mut writer,
4902        )
4903        .unwrap_or_else(|error| panic!("render failed: {error}"));
4904        assert_eq!(
4905            writer.get_content(),
4906            "<div title=\"native\"></div><my-comp>global</my-comp>"
4907        );
4908    }
4909
4910    #[test]
4911    fn test_handle_component() {
4912        // Create a protocol with a component
4913        let mut fragments = HashMap::new();
4914        fragments.insert(
4915            "index.html".to_string(),
4916            FragmentList {
4917                fragments: vec![
4918                    WebUIFragment::raw("Component: "),
4919                    WebUIFragment::component("my-component"),
4920                ],
4921                contains_boundary: false,
4922            },
4923        );
4924
4925        fragments.insert(
4926            "my-component".to_string(),
4927            FragmentList {
4928                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
4929                contains_boundary: false,
4930            },
4931        );
4932
4933        let protocol = WebUIProtocol::new(fragments);
4934        let state = test_json!({});
4935
4936        // Create a test writer
4937        let mut writer = TestWriter::new();
4938
4939        // Handle the protocol
4940        assert!(
4941            handle(
4942                &protocol,
4943                &state,
4944                &RenderOptions::new("index.html", "/"),
4945                &mut writer
4946            )
4947            .is_ok(),
4948            "Failed to handle component protocol"
4949        );
4950
4951        // Check the output
4952        assert_eq!(
4953            writer.get_content(),
4954            "Component: <div>Component Content</div>"
4955        );
4956        assert!(writer.is_ended());
4957    }
4958
4959    #[test]
4960    fn test_missing_fragment() {
4961        // Create a protocol with a missing fragment reference
4962        let mut fragments = HashMap::new();
4963        fragments.insert(
4964            "index.html".to_string(),
4965            FragmentList {
4966                fragments: vec![WebUIFragment::component("missing-component")],
4967                contains_boundary: false,
4968            },
4969        );
4970
4971        let protocol = WebUIProtocol::new(fragments);
4972        let state = test_json!({});
4973
4974        // Create a test writer
4975        let mut writer = TestWriter::new();
4976
4977        // Handle the protocol
4978        let result = handle(
4979            &protocol,
4980            &state,
4981            &RenderOptions::new("index.html", "/"),
4982            &mut writer,
4983        );
4984
4985        // Expect an error
4986        assert!(result.is_err());
4987        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4988            assert_eq!(fragment_id, "missing-component");
4989        } else {
4990            panic!("Expected MissingFragment error");
4991        }
4992    }
4993
4994    #[test]
4995    fn test_missing_signal_renders_empty() {
4996        // A signal referencing a field absent from state should render as empty
4997        let mut fragments = HashMap::new();
4998        fragments.insert(
4999            "index.html".to_string(),
5000            FragmentList {
5001                fragments: vec![
5002                    WebUIFragment::raw("Hello, "),
5003                    WebUIFragment::signal("missing_field", false),
5004                    WebUIFragment::raw("!"),
5005                ],
5006                contains_boundary: false,
5007            },
5008        );
5009
5010        let protocol = WebUIProtocol::new(fragments);
5011        let state = test_json!({});
5012
5013        let mut writer = TestWriter::new();
5014
5015        assert!(
5016            handle(
5017                &protocol,
5018                &state,
5019                &RenderOptions::new("index.html", "/"),
5020                &mut writer
5021            )
5022            .is_ok(),
5023            "Missing signal should not produce an error"
5024        );
5025
5026        assert_eq!(writer.get_content(), "Hello, !");
5027        assert!(writer.is_ended());
5028    }
5029
5030    // ── Boolean attribute rendering tests ─────────────────────────────
5031
5032    #[test]
5033    fn test_boolean_attr_true() {
5034        let mut fragments = HashMap::new();
5035        fragments.insert(
5036            "index.html".to_string(),
5037            FragmentList {
5038                fragments: vec![
5039                    WebUIFragment::raw("<button"),
5040                    WebUIFragment::attribute_boolean(
5041                        "disabled",
5042                        ConditionExpr::identifier("isDisabled"),
5043                    ),
5044                    WebUIFragment::raw(">Click</button>"),
5045                ],
5046                contains_boundary: false,
5047            },
5048        );
5049        let protocol = WebUIProtocol::new(fragments);
5050        let state = test_json!({"isDisabled": true});
5051        let mut writer = TestWriter::new();
5052        handle(
5053            &protocol,
5054            &state,
5055            &RenderOptions::new("index.html", "/"),
5056            &mut writer,
5057        )
5058        .unwrap();
5059        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5060    }
5061
5062    #[test]
5063    fn test_boolean_attr_false() {
5064        let mut fragments = HashMap::new();
5065        fragments.insert(
5066            "index.html".to_string(),
5067            FragmentList {
5068                fragments: vec![
5069                    WebUIFragment::raw("<button"),
5070                    WebUIFragment::attribute_boolean(
5071                        "disabled",
5072                        ConditionExpr::identifier("isDisabled"),
5073                    ),
5074                    WebUIFragment::raw(">Click</button>"),
5075                ],
5076                contains_boundary: false,
5077            },
5078        );
5079        let protocol = WebUIProtocol::new(fragments);
5080        let state = test_json!({"isDisabled": false});
5081        let mut writer = TestWriter::new();
5082        handle(
5083            &protocol,
5084            &state,
5085            &RenderOptions::new("index.html", "/"),
5086            &mut writer,
5087        )
5088        .unwrap();
5089        assert_eq!(writer.get_content(), "<button>Click</button>");
5090    }
5091
5092    #[test]
5093    fn test_boolean_attr_missing() {
5094        let mut fragments = HashMap::new();
5095        fragments.insert(
5096            "index.html".to_string(),
5097            FragmentList {
5098                fragments: vec![
5099                    WebUIFragment::raw("<input type=\"checkbox\""),
5100                    WebUIFragment::attribute_boolean(
5101                        "checked",
5102                        ConditionExpr::identifier("checked"),
5103                    ),
5104                    WebUIFragment::raw(">"),
5105                ],
5106                contains_boundary: false,
5107            },
5108        );
5109        let protocol = WebUIProtocol::new(fragments);
5110        let state = test_json!({});
5111        let mut writer = TestWriter::new();
5112        handle(
5113            &protocol,
5114            &state,
5115            &RenderOptions::new("index.html", "/"),
5116            &mut writer,
5117        )
5118        .unwrap();
5119        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5120    }
5121
5122    #[test]
5123    fn test_boolean_attr_multiple() {
5124        let mut fragments = HashMap::new();
5125        fragments.insert(
5126            "index.html".to_string(),
5127            FragmentList {
5128                fragments: vec![
5129                    WebUIFragment::raw("<input type=\"checkbox\""),
5130                    WebUIFragment::attribute_boolean(
5131                        "checked",
5132                        ConditionExpr::identifier("checked"),
5133                    ),
5134                    WebUIFragment::attribute_boolean(
5135                        "disabled",
5136                        ConditionExpr::identifier("disabled"),
5137                    ),
5138                    WebUIFragment::raw(">"),
5139                ],
5140                contains_boundary: false,
5141            },
5142        );
5143        let protocol = WebUIProtocol::new(fragments);
5144        let state = test_json!({"checked": true, "disabled": false});
5145        let mut writer = TestWriter::new();
5146        handle(
5147            &protocol,
5148            &state,
5149            &RenderOptions::new("index.html", "/"),
5150            &mut writer,
5151        )
5152        .unwrap();
5153        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5154    }
5155
5156    // ── Simple attribute rendering tests ──────────────────────────────
5157
5158    #[test]
5159    fn test_attribute_with_value() {
5160        let mut fragments = HashMap::new();
5161        fragments.insert(
5162            "index.html".to_string(),
5163            FragmentList {
5164                fragments: vec![
5165                    WebUIFragment::raw("<input"),
5166                    WebUIFragment::attribute("value", "inputValue"),
5167                    WebUIFragment::raw(">"),
5168                ],
5169                contains_boundary: false,
5170            },
5171        );
5172        let protocol = WebUIProtocol::new(fragments);
5173        let state = test_json!({"inputValue": "Hello"});
5174        let mut writer = TestWriter::new();
5175        handle(
5176            &protocol,
5177            &state,
5178            &RenderOptions::new("index.html", "/"),
5179            &mut writer,
5180        )
5181        .unwrap();
5182        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5183    }
5184
5185    #[test]
5186    fn test_attribute_with_falsy_numeric() {
5187        let mut fragments = HashMap::new();
5188        fragments.insert(
5189            "index.html".to_string(),
5190            FragmentList {
5191                fragments: vec![
5192                    WebUIFragment::raw("<div name=\"test\""),
5193                    WebUIFragment::attribute("handle", "number"),
5194                    WebUIFragment::raw("></div>"),
5195                ],
5196                contains_boundary: false,
5197            },
5198        );
5199        let protocol = WebUIProtocol::new(fragments);
5200        let state = test_json!({"number": 0});
5201        let mut writer = TestWriter::new();
5202        handle(
5203            &protocol,
5204            &state,
5205            &RenderOptions::new("index.html", "/"),
5206            &mut writer,
5207        )
5208        .unwrap();
5209        assert_eq!(
5210            writer.get_content(),
5211            "<div name=\"test\" handle=\"0\"></div>"
5212        );
5213    }
5214
5215    // ── Dynamic attribute escaping for non-string JSON types ─────────
5216
5217    #[test]
5218    fn test_attribute_array_value_is_escaped() {
5219        let mut fragments = HashMap::new();
5220        fragments.insert(
5221            "index.html".to_string(),
5222            FragmentList {
5223                fragments: vec![
5224                    WebUIFragment::raw("<a"),
5225                    WebUIFragment::attribute("href", "value"),
5226                    WebUIFragment::raw(">demo</a>"),
5227                ],
5228                contains_boundary: false,
5229            },
5230        );
5231        let protocol = WebUIProtocol::new(fragments);
5232        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5233        let mut writer = TestWriter::new();
5234        handle(
5235            &protocol,
5236            &state,
5237            &RenderOptions::new("index.html", "/"),
5238            &mut writer,
5239        )
5240        .unwrap();
5241        let content = writer.get_content();
5242        // All inner double quotes must be entity-escaped so that the
5243        // browser never sees a second attribute boundary.
5244        assert!(
5245            content.contains("&quot;"),
5246            "Double quotes inside attribute value must be escaped: {content}"
5247        );
5248        // The href attribute value must be a single contiguous quoted
5249        // string — no extra attributes should appear.
5250        assert_eq!(
5251            content.matches("=\"").count(),
5252            1,
5253            "Only one attribute assignment expected: {content}"
5254        );
5255    }
5256
5257    #[test]
5258    fn test_attribute_object_value_is_escaped() {
5259        let mut fragments = HashMap::new();
5260        fragments.insert(
5261            "index.html".to_string(),
5262            FragmentList {
5263                fragments: vec![
5264                    WebUIFragment::raw("<div"),
5265                    WebUIFragment::attribute("data-cfg", "cfg"),
5266                    WebUIFragment::raw("></div>"),
5267                ],
5268                contains_boundary: false,
5269            },
5270        );
5271        let protocol = WebUIProtocol::new(fragments);
5272        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5273        let mut writer = TestWriter::new();
5274        handle(
5275            &protocol,
5276            &state,
5277            &RenderOptions::new("index.html", "/"),
5278            &mut writer,
5279        )
5280        .unwrap();
5281        let content = writer.get_content();
5282        assert!(
5283            content.contains("&quot;"),
5284            "Double quotes inside attribute value must be escaped: {content}"
5285        );
5286        assert_eq!(
5287            content.matches("=\"").count(),
5288            1,
5289            "Only one attribute assignment expected: {content}"
5290        );
5291    }
5292
5293    // ── Template attribute rendering tests ────────────────────────────
5294
5295    #[test]
5296    fn test_mixed_attribute_template() {
5297        let mut fragments = HashMap::new();
5298        fragments.insert(
5299            "index.html".to_string(),
5300            FragmentList {
5301                fragments: vec![
5302                    WebUIFragment::raw("<input"),
5303                    WebUIFragment::attribute_template("value", "attr-1"),
5304                    WebUIFragment::raw(">"),
5305                ],
5306                contains_boundary: false,
5307            },
5308        );
5309        fragments.insert(
5310            "attr-1".to_string(),
5311            FragmentList {
5312                fragments: vec![
5313                    WebUIFragment::raw("hello "),
5314                    WebUIFragment::signal("item", false),
5315                ],
5316                contains_boundary: false,
5317            },
5318        );
5319        let protocol = WebUIProtocol::new(fragments);
5320        let state = test_json!({"item": "world"});
5321        let mut writer = TestWriter::new();
5322        handle(
5323            &protocol,
5324            &state,
5325            &RenderOptions::new("index.html", "/"),
5326            &mut writer,
5327        )
5328        .unwrap();
5329        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5330    }
5331
5332    // ── Raw signal rendering test ─────────────────────────────────────
5333
5334    #[test]
5335    fn test_raw_signal_not_escaped() {
5336        let mut fragments = HashMap::new();
5337        fragments.insert(
5338            "index.html".to_string(),
5339            FragmentList {
5340                fragments: vec![
5341                    WebUIFragment::signal("html", false),
5342                    WebUIFragment::signal("html", true),
5343                ],
5344                contains_boundary: false,
5345            },
5346        );
5347        let protocol = WebUIProtocol::new(fragments);
5348        let state = test_json!({"html": "<strong>hi</strong>"});
5349        let mut writer = TestWriter::new();
5350        handle(
5351            &protocol,
5352            &state,
5353            &RenderOptions::new("index.html", "/"),
5354            &mut writer,
5355        )
5356        .unwrap();
5357        assert_eq!(
5358            writer.get_content(),
5359            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5360        );
5361    }
5362
5363    // ── Nested for loop tests ─────────────────────────────────────────
5364
5365    #[test]
5366    fn test_nested_for_loop() {
5367        let mut fragments = HashMap::new();
5368        fragments.insert(
5369            "index.html".to_string(),
5370            FragmentList {
5371                fragments: vec![
5372                    WebUIFragment::raw("<div>"),
5373                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5374                    WebUIFragment::raw("</div>"),
5375                ],
5376                contains_boundary: false,
5377            },
5378        );
5379        fragments.insert(
5380            "outer".to_string(),
5381            FragmentList {
5382                fragments: vec![
5383                    WebUIFragment::raw("<div>"),
5384                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5385                    WebUIFragment::raw("</div>"),
5386                ],
5387                contains_boundary: false,
5388            },
5389        );
5390        fragments.insert(
5391            "inner".to_string(),
5392            FragmentList {
5393                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5394                contains_boundary: false,
5395            },
5396        );
5397        let protocol = WebUIProtocol::new(fragments);
5398        let state = test_json!({
5399            "outerItems": [
5400                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5401                {"innerItems": [{"name": "C"}]}
5402            ]
5403        });
5404        let mut writer = TestWriter::new();
5405        handle(
5406            &protocol,
5407            &state,
5408            &RenderOptions::new("index.html", "/"),
5409            &mut writer,
5410        )
5411        .unwrap();
5412        assert_eq!(
5413            writer.get_content(),
5414            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5415        );
5416    }
5417
5418    #[test]
5419    fn test_nested_for_with_signals() {
5420        let mut fragments = HashMap::new();
5421        fragments.insert(
5422            "index.html".to_string(),
5423            FragmentList {
5424                fragments: vec![
5425                    WebUIFragment::raw("<div>"),
5426                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5427                    WebUIFragment::raw("</div>"),
5428                ],
5429                contains_boundary: false,
5430            },
5431        );
5432        fragments.insert(
5433            "outerTemplate".to_string(),
5434            FragmentList {
5435                fragments: vec![
5436                    WebUIFragment::raw("<div>"),
5437                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5438                    WebUIFragment::raw("</div>"),
5439                ],
5440                contains_boundary: false,
5441            },
5442        );
5443        fragments.insert(
5444            "innerTemplate".to_string(),
5445            FragmentList {
5446                fragments: vec![
5447                    WebUIFragment::raw("<span>"),
5448                    WebUIFragment::signal("innerItem.name", false),
5449                    WebUIFragment::raw("</span>"),
5450                ],
5451                contains_boundary: false,
5452            },
5453        );
5454        let protocol = WebUIProtocol::new(fragments);
5455        let state = test_json!({
5456            "outerItems": [
5457                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5458                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5459            ]
5460        });
5461        let mut writer = TestWriter::new();
5462        handle(
5463            &protocol,
5464            &state,
5465            &RenderOptions::new("index.html", "/"),
5466            &mut writer,
5467        )
5468        .unwrap();
5469        assert_eq!(
5470            writer.get_content(),
5471            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5472        );
5473    }
5474
5475    #[test]
5476    fn test_nested_for_with_global_state() {
5477        let mut fragments = HashMap::new();
5478        fragments.insert(
5479            "index.html".to_string(),
5480            FragmentList {
5481                fragments: vec![
5482                    WebUIFragment::raw("<div>"),
5483                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5484                    WebUIFragment::raw("</div>"),
5485                ],
5486                contains_boundary: false,
5487            },
5488        );
5489        fragments.insert(
5490            "outerTemplate".to_string(),
5491            FragmentList {
5492                fragments: vec![
5493                    WebUIFragment::raw("<div>"),
5494                    WebUIFragment::signal("globalOuter", false),
5495                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5496                    WebUIFragment::raw("</div>"),
5497                ],
5498                contains_boundary: false,
5499            },
5500        );
5501        fragments.insert(
5502            "innerTemplate".to_string(),
5503            FragmentList {
5504                fragments: vec![
5505                    WebUIFragment::raw("<span>"),
5506                    WebUIFragment::signal("innerItem.name", false),
5507                    WebUIFragment::signal("globalInner", false),
5508                    WebUIFragment::raw("</span>"),
5509                ],
5510                contains_boundary: false,
5511            },
5512        );
5513        let protocol = WebUIProtocol::new(fragments);
5514        let state = test_json!({
5515            "globalOuter": "GO",
5516            "globalInner": "GI",
5517            "outerItems": [
5518                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5519                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5520            ]
5521        });
5522        let mut writer = TestWriter::new();
5523        handle(
5524            &protocol,
5525            &state,
5526            &RenderOptions::new("index.html", "/"),
5527            &mut writer,
5528        )
5529        .unwrap();
5530        assert_eq!(
5531            writer.get_content(),
5532            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5533        );
5534    }
5535
5536    // ── For + If state scoping tests ──────────────────────────────────
5537
5538    #[test]
5539    fn test_if_in_for_uses_local_state() {
5540        let mut fragments = HashMap::new();
5541        fragments.insert(
5542            "index.html".to_string(),
5543            FragmentList {
5544                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5545                contains_boundary: false,
5546            },
5547        );
5548        fragments.insert(
5549            "item-tpl".to_string(),
5550            FragmentList {
5551                fragments: vec![WebUIFragment::if_cond(
5552                    ConditionExpr::identifier("item.visible"),
5553                    "visible-tpl",
5554                )],
5555                contains_boundary: false,
5556            },
5557        );
5558        fragments.insert(
5559            "visible-tpl".to_string(),
5560            FragmentList {
5561                fragments: vec![WebUIFragment::signal("item.name", false)],
5562                contains_boundary: false,
5563            },
5564        );
5565        let protocol = WebUIProtocol::new(fragments);
5566        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5567        let mut writer = TestWriter::new();
5568        handle(
5569            &protocol,
5570            &state,
5571            &RenderOptions::new("index.html", "/"),
5572            &mut writer,
5573        )
5574        .unwrap();
5575        assert_eq!(writer.get_content(), "Show");
5576    }
5577
5578    #[test]
5579    fn test_for_if_local_overrides_global() {
5580        let mut fragments = HashMap::new();
5581        fragments.insert(
5582            "index.html".to_string(),
5583            FragmentList {
5584                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5585                contains_boundary: false,
5586            },
5587        );
5588        fragments.insert(
5589            "item-tpl".to_string(),
5590            FragmentList {
5591                fragments: vec![WebUIFragment::if_cond(
5592                    ConditionExpr::identifier("item.flag"),
5593                    "show-tpl",
5594                )],
5595                contains_boundary: false,
5596            },
5597        );
5598        fragments.insert(
5599            "show-tpl".to_string(),
5600            FragmentList {
5601                fragments: vec![WebUIFragment::raw("yes")],
5602                contains_boundary: false,
5603            },
5604        );
5605        let protocol = WebUIProtocol::new(fragments);
5606        // Global flag is true, but local item.flag is false for second item
5607        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5608        let mut writer = TestWriter::new();
5609        handle(
5610            &protocol,
5611            &state,
5612            &RenderOptions::new("index.html", "/"),
5613            &mut writer,
5614        )
5615        .unwrap();
5616        assert_eq!(writer.get_content(), "yes");
5617    }
5618
5619    // ── Component attribute state tests ───────────────────────────────
5620
5621    #[test]
5622    fn test_component_attr_state_simple() {
5623        let mut fragments = HashMap::new();
5624        fragments.insert(
5625            "index.html".to_string(),
5626            FragmentList {
5627                fragments: vec![
5628                    WebUIFragment::raw("<my-comp"),
5629                    WebUIFragment {
5630                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5631                            WebUIFragmentAttribute {
5632                                name: "title".into(),
5633                                value: "Attribute Title".into(),
5634                                attr_start: true,
5635                                raw_value: true,
5636                                ..Default::default()
5637                            },
5638                        )),
5639                    },
5640                    WebUIFragment::raw(">"),
5641                    WebUIFragment::component("my-comp"),
5642                    WebUIFragment::raw("</my-comp>"),
5643                ],
5644
5645                contains_boundary: false,
5646            },
5647        );
5648        fragments.insert(
5649            "my-comp".to_string(),
5650            FragmentList {
5651                fragments: vec![
5652                    WebUIFragment::raw("<span>"),
5653                    WebUIFragment::signal("title", false),
5654                    WebUIFragment::raw("</span>"),
5655                ],
5656                contains_boundary: false,
5657            },
5658        );
5659        let protocol = WebUIProtocol::new(fragments);
5660        let state = test_json!({"title": "Global Title"});
5661        let mut writer = TestWriter::new();
5662        handle(
5663            &protocol,
5664            &state,
5665            &RenderOptions::new("index.html", "/"),
5666            &mut writer,
5667        )
5668        .unwrap();
5669        assert_eq!(
5670            writer.get_content(),
5671            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5672        );
5673    }
5674
5675    #[test]
5676    fn test_component_attr_state_template() {
5677        let mut fragments = HashMap::new();
5678        fragments.insert(
5679            "index.html".to_string(),
5680            FragmentList {
5681                fragments: vec![
5682                    WebUIFragment::raw("<my-comp"),
5683                    WebUIFragment {
5684                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5685                            WebUIFragmentAttribute {
5686                                name: "title".into(),
5687                                template: "title-attr".into(),
5688                                attr_start: true,
5689                                ..Default::default()
5690                            },
5691                        )),
5692                    },
5693                    WebUIFragment::raw(">"),
5694                    WebUIFragment::component("my-comp"),
5695                    WebUIFragment::raw("</my-comp>"),
5696                ],
5697
5698                contains_boundary: false,
5699            },
5700        );
5701        fragments.insert(
5702            "title-attr".to_string(),
5703            FragmentList {
5704                fragments: vec![
5705                    WebUIFragment::raw("hello "),
5706                    WebUIFragment::signal("item", false),
5707                ],
5708                contains_boundary: false,
5709            },
5710        );
5711        fragments.insert(
5712            "my-comp".to_string(),
5713            FragmentList {
5714                fragments: vec![
5715                    WebUIFragment::raw("<span>"),
5716                    WebUIFragment::signal("title", false),
5717                    WebUIFragment::raw("</span>"),
5718                ],
5719                contains_boundary: false,
5720            },
5721        );
5722        let protocol = WebUIProtocol::new(fragments);
5723        let state = test_json!({"item": "<world>"});
5724        let mut writer = TestWriter::new();
5725        handle(
5726            &protocol,
5727            &state,
5728            &RenderOptions::new("index.html", "/"),
5729            &mut writer,
5730        )
5731        .unwrap();
5732        assert_eq!(
5733            writer.get_content(),
5734            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
5735        );
5736    }
5737
5738    #[test]
5739    fn test_component_attr_camel_case() {
5740        let mut fragments = HashMap::new();
5741        fragments.insert(
5742            "index.html".to_string(),
5743            FragmentList {
5744                fragments: vec![
5745                    WebUIFragment::raw("<my-comp"),
5746                    WebUIFragment {
5747                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5748                            WebUIFragmentAttribute {
5749                                name: "data-title".into(),
5750                                template: "dt-attr".into(),
5751                                attr_start: true,
5752                                ..Default::default()
5753                            },
5754                        )),
5755                    },
5756                    WebUIFragment::raw(">"),
5757                    WebUIFragment::component("my-comp"),
5758                    WebUIFragment::raw("</my-comp>"),
5759                ],
5760
5761                contains_boundary: false,
5762            },
5763        );
5764        fragments.insert(
5765            "dt-attr".to_string(),
5766            FragmentList {
5767                fragments: vec![
5768                    WebUIFragment::raw("prefix "),
5769                    WebUIFragment::signal("item", false),
5770                ],
5771                contains_boundary: false,
5772            },
5773        );
5774        fragments.insert(
5775            "my-comp".to_string(),
5776            FragmentList {
5777                fragments: vec![
5778                    WebUIFragment::raw("<span>"),
5779                    WebUIFragment::signal("dataTitle", false),
5780                    WebUIFragment::raw("</span>"),
5781                ],
5782                contains_boundary: false,
5783            },
5784        );
5785        let protocol = WebUIProtocol::new(fragments);
5786        let state = test_json!({"item": "a&b"});
5787        let mut writer = TestWriter::new();
5788        handle(
5789            &protocol,
5790            &state,
5791            &RenderOptions::new("index.html", "/"),
5792            &mut writer,
5793        )
5794        .unwrap();
5795        assert_eq!(
5796            writer.get_content(),
5797            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
5798        );
5799    }
5800
5801    #[test]
5802    fn test_component_complex_attr() {
5803        let mut fragments = HashMap::new();
5804        fragments.insert(
5805            "index.html".to_string(),
5806            FragmentList {
5807                fragments: vec![
5808                    WebUIFragment::raw("<my-comp"),
5809                    WebUIFragment {
5810                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5811                            WebUIFragmentAttribute {
5812                                name: ":item".into(),
5813                                value: "complexItem".into(),
5814                                attr_start: true,
5815                                complex: true,
5816                                ..Default::default()
5817                            },
5818                        )),
5819                    },
5820                    WebUIFragment::raw(">"),
5821                    WebUIFragment::component("my-comp"),
5822                    WebUIFragment::raw("</my-comp>"),
5823                ],
5824
5825                contains_boundary: false,
5826            },
5827        );
5828        fragments.insert(
5829            "my-comp".to_string(),
5830            FragmentList {
5831                fragments: vec![
5832                    WebUIFragment::raw("<span>"),
5833                    WebUIFragment::signal("item.foo", false),
5834                    WebUIFragment::raw("</span><p>"),
5835                    WebUIFragment::signal("item.bar", false),
5836                    WebUIFragment::raw("</p>"),
5837                ],
5838                contains_boundary: false,
5839            },
5840        );
5841        let protocol = WebUIProtocol::new(fragments);
5842        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5843        let mut writer = TestWriter::new();
5844        handle(
5845            &protocol,
5846            &state,
5847            &RenderOptions::new("index.html", "/"),
5848            &mut writer,
5849        )
5850        .unwrap();
5851        assert_eq!(
5852            writer.get_content(),
5853            "<my-comp><span>1</span><p>true</p></my-comp>"
5854        );
5855    }
5856
5857    #[test]
5858    fn test_component_no_parent_pollution() {
5859        let mut fragments = HashMap::new();
5860        fragments.insert(
5861            "index.html".to_string(),
5862            FragmentList {
5863                fragments: vec![
5864                    WebUIFragment::raw("<parent"),
5865                    WebUIFragment {
5866                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5867                            WebUIFragmentAttribute {
5868                                name: "var".into(),
5869                                value: "var".into(),
5870                                attr_start: true,
5871                                ..Default::default()
5872                            },
5873                        )),
5874                    },
5875                    WebUIFragment::raw(">"),
5876                    WebUIFragment::component("parent"),
5877                    WebUIFragment::raw("</parent>"),
5878                ],
5879
5880                contains_boundary: false,
5881            },
5882        );
5883        fragments.insert(
5884            "parent".to_string(),
5885            FragmentList {
5886                fragments: vec![
5887                    WebUIFragment::raw("Before: "),
5888                    WebUIFragment::signal("var", false),
5889                    WebUIFragment::raw("<child foo"),
5890                    WebUIFragment {
5891                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5892                            WebUIFragmentAttribute {
5893                                name: "var".into(),
5894                                value: "replaced".into(),
5895                                raw_value: true,
5896                                ..Default::default()
5897                            },
5898                        )),
5899                    },
5900                    WebUIFragment::raw(">"),
5901                    WebUIFragment::component("child"),
5902                    WebUIFragment::raw("Label</child>After: "),
5903                    WebUIFragment::signal("var", false),
5904                ],
5905
5906                contains_boundary: false,
5907            },
5908        );
5909        fragments.insert(
5910            "child".to_string(),
5911            FragmentList {
5912                fragments: vec![],
5913                contains_boundary: false,
5914            },
5915        );
5916        let protocol = WebUIProtocol::new(fragments);
5917        let state = test_json!({"var": "original<&"});
5918        let mut writer = TestWriter::new();
5919        handle(
5920            &protocol,
5921            &state,
5922            &RenderOptions::new("index.html", "/"),
5923            &mut writer,
5924        )
5925        .unwrap();
5926        assert_eq!(
5927            writer.get_content(),
5928            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
5929        );
5930    }
5931
5932    #[test]
5933    fn test_component_boolean_attr_state() {
5934        let mut fragments = HashMap::new();
5935        fragments.insert(
5936            "index.html".to_string(),
5937            FragmentList {
5938                fragments: vec![
5939                    WebUIFragment::raw("<my-comp"),
5940                    WebUIFragment {
5941                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5942                            WebUIFragmentAttribute {
5943                                name: "disabled".into(),
5944                                attr_start: true,
5945                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5946                                ..Default::default()
5947                            },
5948                        )),
5949                    },
5950                    WebUIFragment::raw(">"),
5951                    WebUIFragment::component("my-comp"),
5952                    WebUIFragment::raw("</my-comp>"),
5953                ],
5954
5955                contains_boundary: false,
5956            },
5957        );
5958        fragments.insert(
5959            "my-comp".to_string(),
5960            FragmentList {
5961                fragments: vec![WebUIFragment::if_cond(
5962                    ConditionExpr::identifier("disabled"),
5963                    "show",
5964                )],
5965                contains_boundary: false,
5966            },
5967        );
5968        fragments.insert(
5969            "show".to_string(),
5970            FragmentList {
5971                fragments: vec![WebUIFragment::raw("disabled!")],
5972                contains_boundary: false,
5973            },
5974        );
5975        let protocol = WebUIProtocol::new(fragments);
5976        let state = test_json!({"isDisabled": true});
5977        let mut writer = TestWriter::new();
5978        handle(
5979            &protocol,
5980            &state,
5981            &RenderOptions::new("index.html", "/"),
5982            &mut writer,
5983        )
5984        .unwrap();
5985        assert_eq!(
5986            writer.get_content(),
5987            "<my-comp disabled>disabled!</my-comp>"
5988        );
5989    }
5990
5991    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
5992
5993    /// Helper: render a signal value through the handler and return the escaped output.
5994    fn render_signal(value: &str) -> String {
5995        let mut fragments = HashMap::new();
5996        fragments.insert(
5997            "index.html".to_string(),
5998            FragmentList {
5999                fragments: vec![WebUIFragment::signal("v", false)],
6000                contains_boundary: false,
6001            },
6002        );
6003        let protocol = WebUIProtocol::new(fragments);
6004        let state = test_json!({"v": value});
6005        let mut writer = TestWriter::new();
6006        handle(
6007            &protocol,
6008            &state,
6009            &RenderOptions::new("index.html", "/"),
6010            &mut writer,
6011        )
6012        .unwrap();
6013        writer.get_content()
6014    }
6015
6016    #[test]
6017    fn test_escape_ampersand() {
6018        assert_eq!(render_signal("&"), "&amp;");
6019    }
6020
6021    #[test]
6022    fn test_escape_less_than() {
6023        assert_eq!(render_signal("<"), "&lt;");
6024    }
6025
6026    #[test]
6027    fn test_escape_greater_than() {
6028        assert_eq!(render_signal(">"), "&gt;");
6029    }
6030
6031    #[test]
6032    fn test_escape_double_quote() {
6033        assert_eq!(render_signal("\""), "&quot;");
6034    }
6035
6036    #[test]
6037    fn test_escape_single_quote() {
6038        // encode_safe escapes ' as &#x27;
6039        let result = render_signal("'");
6040        assert_eq!(
6041            result, "&#x27;",
6042            "Expected &#x27; for single quote, got: {result}"
6043        );
6044    }
6045
6046    #[test]
6047    fn test_escape_multiple_special_chars() {
6048        let result = render_signal("<script>alert('xss');</script>");
6049        assert!(
6050            result.contains("&lt;") && result.contains("&gt;"),
6051            "Expected escaped HTML, got: {}",
6052            result
6053        );
6054        assert!(
6055            !result.contains("<script>"),
6056            "Should not contain raw <script> tag"
6057        );
6058    }
6059
6060    #[test]
6061    fn test_escape_no_special_chars() {
6062        assert_eq!(render_signal("Hello World"), "Hello World");
6063    }
6064
6065    #[test]
6066    fn test_escape_empty_string() {
6067        assert_eq!(render_signal(""), "");
6068    }
6069
6070    #[test]
6071    fn test_escape_special_at_beginning() {
6072        let result = render_signal("<Hello");
6073        assert!(
6074            result.starts_with("&lt;"),
6075            "Expected &lt; at start, got: {}",
6076            result
6077        );
6078    }
6079
6080    #[test]
6081    fn test_escape_special_at_end() {
6082        let result = render_signal("Hello>");
6083        assert!(
6084            result.ends_with("&gt;"),
6085            "Expected &gt; at end, got: {}",
6086            result
6087        );
6088    }
6089
6090    #[test]
6091    fn test_escape_special_in_middle() {
6092        let result = render_signal("Hel&lo");
6093        assert!(
6094            result.contains("&amp;"),
6095            "Expected &amp; in middle, got: {}",
6096            result
6097        );
6098    }
6099
6100    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6101
6102    #[test]
6103    fn test_boolean_attr_truthy_values() {
6104        // checked: 1
6105        {
6106            let mut fragments = HashMap::new();
6107            fragments.insert(
6108                "index.html".to_string(),
6109                FragmentList {
6110                    fragments: vec![
6111                        WebUIFragment::raw("<input"),
6112                        WebUIFragment::attribute_boolean(
6113                            "checked",
6114                            ConditionExpr::identifier("checked"),
6115                        ),
6116                        WebUIFragment::raw(">"),
6117                    ],
6118                    contains_boundary: false,
6119                },
6120            );
6121            let protocol = WebUIProtocol::new(fragments);
6122            let state = test_json!({"checked": 1});
6123            let mut writer = TestWriter::new();
6124            handle(
6125                &protocol,
6126                &state,
6127                &RenderOptions::new("index.html", "/"),
6128                &mut writer,
6129            )
6130            .unwrap();
6131            assert_eq!(writer.get_content(), "<input checked>");
6132        }
6133        // checked: "yes"
6134        {
6135            let mut fragments = HashMap::new();
6136            fragments.insert(
6137                "index.html".to_string(),
6138                FragmentList {
6139                    fragments: vec![
6140                        WebUIFragment::raw("<input"),
6141                        WebUIFragment::attribute_boolean(
6142                            "checked",
6143                            ConditionExpr::identifier("checked"),
6144                        ),
6145                        WebUIFragment::raw(">"),
6146                    ],
6147                    contains_boundary: false,
6148                },
6149            );
6150            let protocol = WebUIProtocol::new(fragments);
6151            let state = test_json!({"checked": "yes"});
6152            let mut writer = TestWriter::new();
6153            handle(
6154                &protocol,
6155                &state,
6156                &RenderOptions::new("index.html", "/"),
6157                &mut writer,
6158            )
6159            .unwrap();
6160            assert_eq!(writer.get_content(), "<input checked>");
6161        }
6162        // checked: {} (empty object is truthy)
6163        {
6164            let mut fragments = HashMap::new();
6165            fragments.insert(
6166                "index.html".to_string(),
6167                FragmentList {
6168                    fragments: vec![
6169                        WebUIFragment::raw("<input"),
6170                        WebUIFragment::attribute_boolean(
6171                            "checked",
6172                            ConditionExpr::identifier("checked"),
6173                        ),
6174                        WebUIFragment::raw(">"),
6175                    ],
6176                    contains_boundary: false,
6177                },
6178            );
6179            let protocol = WebUIProtocol::new(fragments);
6180            let state = test_json!({"checked": {}});
6181            let mut writer = TestWriter::new();
6182            handle(
6183                &protocol,
6184                &state,
6185                &RenderOptions::new("index.html", "/"),
6186                &mut writer,
6187            )
6188            .unwrap();
6189            // Empty object is falsy in this expression evaluator
6190            assert_eq!(writer.get_content(), "<input>");
6191        }
6192        // checked: "false" (string "false" is truthy)
6193        {
6194            let mut fragments = HashMap::new();
6195            fragments.insert(
6196                "index.html".to_string(),
6197                FragmentList {
6198                    fragments: vec![
6199                        WebUIFragment::raw("<input"),
6200                        WebUIFragment::attribute_boolean(
6201                            "checked",
6202                            ConditionExpr::identifier("checked"),
6203                        ),
6204                        WebUIFragment::raw(">"),
6205                    ],
6206                    contains_boundary: false,
6207                },
6208            );
6209            let protocol = WebUIProtocol::new(fragments);
6210            let state = test_json!({"checked": "false"});
6211            let mut writer = TestWriter::new();
6212            handle(
6213                &protocol,
6214                &state,
6215                &RenderOptions::new("index.html", "/"),
6216                &mut writer,
6217            )
6218            .unwrap();
6219            assert_eq!(writer.get_content(), "<input checked>");
6220        }
6221    }
6222
6223    #[test]
6224    fn test_boolean_attr_falsy_values() {
6225        // checked: 0
6226        {
6227            let mut fragments = HashMap::new();
6228            fragments.insert(
6229                "index.html".to_string(),
6230                FragmentList {
6231                    fragments: vec![
6232                        WebUIFragment::raw("<input"),
6233                        WebUIFragment::attribute_boolean(
6234                            "checked",
6235                            ConditionExpr::identifier("checked"),
6236                        ),
6237                        WebUIFragment::raw(">"),
6238                    ],
6239                    contains_boundary: false,
6240                },
6241            );
6242            let protocol = WebUIProtocol::new(fragments);
6243            let state = test_json!({"checked": 0});
6244            let mut writer = TestWriter::new();
6245            handle(
6246                &protocol,
6247                &state,
6248                &RenderOptions::new("index.html", "/"),
6249                &mut writer,
6250            )
6251            .unwrap();
6252            assert_eq!(writer.get_content(), "<input>");
6253        }
6254        // checked: ""
6255        {
6256            let mut fragments = HashMap::new();
6257            fragments.insert(
6258                "index.html".to_string(),
6259                FragmentList {
6260                    fragments: vec![
6261                        WebUIFragment::raw("<input"),
6262                        WebUIFragment::attribute_boolean(
6263                            "checked",
6264                            ConditionExpr::identifier("checked"),
6265                        ),
6266                        WebUIFragment::raw(">"),
6267                    ],
6268                    contains_boundary: false,
6269                },
6270            );
6271            let protocol = WebUIProtocol::new(fragments);
6272            let state = test_json!({"checked": ""});
6273            let mut writer = TestWriter::new();
6274            handle(
6275                &protocol,
6276                &state,
6277                &RenderOptions::new("index.html", "/"),
6278                &mut writer,
6279            )
6280            .unwrap();
6281            assert_eq!(writer.get_content(), "<input>");
6282        }
6283        // checked: false
6284        {
6285            let mut fragments = HashMap::new();
6286            fragments.insert(
6287                "index.html".to_string(),
6288                FragmentList {
6289                    fragments: vec![
6290                        WebUIFragment::raw("<input"),
6291                        WebUIFragment::attribute_boolean(
6292                            "checked",
6293                            ConditionExpr::identifier("checked"),
6294                        ),
6295                        WebUIFragment::raw(">"),
6296                    ],
6297                    contains_boundary: false,
6298                },
6299            );
6300            let protocol = WebUIProtocol::new(fragments);
6301            let state = test_json!({"checked": false});
6302            let mut writer = TestWriter::new();
6303            handle(
6304                &protocol,
6305                &state,
6306                &RenderOptions::new("index.html", "/"),
6307                &mut writer,
6308            )
6309            .unwrap();
6310            assert_eq!(writer.get_content(), "<input>");
6311        }
6312        // no checked key at all
6313        {
6314            let mut fragments = HashMap::new();
6315            fragments.insert(
6316                "index.html".to_string(),
6317                FragmentList {
6318                    fragments: vec![
6319                        WebUIFragment::raw("<input"),
6320                        WebUIFragment::attribute_boolean(
6321                            "checked",
6322                            ConditionExpr::identifier("checked"),
6323                        ),
6324                        WebUIFragment::raw(">"),
6325                    ],
6326                    contains_boundary: false,
6327                },
6328            );
6329            let protocol = WebUIProtocol::new(fragments);
6330            let state = test_json!({});
6331            let mut writer = TestWriter::new();
6332            handle(
6333                &protocol,
6334                &state,
6335                &RenderOptions::new("index.html", "/"),
6336                &mut writer,
6337            )
6338            .unwrap();
6339            assert_eq!(writer.get_content(), "<input>");
6340        }
6341    }
6342
6343    #[test]
6344    fn test_boolean_attr_expression_true() {
6345        let mut fragments = HashMap::new();
6346        fragments.insert(
6347            "index.html".to_string(),
6348            FragmentList {
6349                fragments: vec![
6350                    WebUIFragment::raw("<button"),
6351                    WebUIFragment::attribute_boolean(
6352                        "disabled",
6353                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6354                    ),
6355                    WebUIFragment::raw(">Click</button>"),
6356                ],
6357                contains_boundary: false,
6358            },
6359        );
6360        let protocol = WebUIProtocol::new(fragments);
6361        let state = test_json!({"itemCount": 5});
6362        let mut writer = TestWriter::new();
6363        handle(
6364            &protocol,
6365            &state,
6366            &RenderOptions::new("index.html", "/"),
6367            &mut writer,
6368        )
6369        .unwrap();
6370        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6371    }
6372
6373    #[test]
6374    fn test_boolean_attr_expression_false() {
6375        let mut fragments = HashMap::new();
6376        fragments.insert(
6377            "index.html".to_string(),
6378            FragmentList {
6379                fragments: vec![
6380                    WebUIFragment::raw("<button"),
6381                    WebUIFragment::attribute_boolean(
6382                        "disabled",
6383                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6384                    ),
6385                    WebUIFragment::raw(">Click</button>"),
6386                ],
6387                contains_boundary: false,
6388            },
6389        );
6390        let protocol = WebUIProtocol::new(fragments);
6391        let state = test_json!({"itemCount": 3});
6392        let mut writer = TestWriter::new();
6393        handle(
6394            &protocol,
6395            &state,
6396            &RenderOptions::new("index.html", "/"),
6397            &mut writer,
6398        )
6399        .unwrap();
6400        assert_eq!(writer.get_content(), "<button>Click</button>");
6401    }
6402
6403    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6404
6405    #[test]
6406    fn test_nested_component_attr_capture() {
6407        let mut fragments = HashMap::new();
6408        fragments.insert(
6409            "index.html".to_string(),
6410            FragmentList {
6411                fragments: vec![
6412                    WebUIFragment::raw("<parent-component"),
6413                    WebUIFragment {
6414                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6415                            WebUIFragmentAttribute {
6416                                name: "title".into(),
6417                                template: "parent-title".into(),
6418                                attr_start: true,
6419                                ..Default::default()
6420                            },
6421                        )),
6422                    },
6423                    WebUIFragment::raw(">"),
6424                    WebUIFragment::component("parent-component"),
6425                    WebUIFragment::raw("</parent-component>"),
6426                ],
6427
6428                contains_boundary: false,
6429            },
6430        );
6431        fragments.insert(
6432            "parent-title".to_string(),
6433            FragmentList {
6434                fragments: vec![
6435                    WebUIFragment::raw("Hello "),
6436                    WebUIFragment::signal("who", false),
6437                ],
6438                contains_boundary: false,
6439            },
6440        );
6441        fragments.insert(
6442            "parent-component".to_string(),
6443            FragmentList {
6444                fragments: vec![
6445                    WebUIFragment::raw("<child-component"),
6446                    WebUIFragment {
6447                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6448                            WebUIFragmentAttribute {
6449                                name: "title".into(),
6450                                template: "child-title".into(),
6451                                attr_start: true,
6452                                ..Default::default()
6453                            },
6454                        )),
6455                    },
6456                    WebUIFragment::raw(">"),
6457                    WebUIFragment::component("child-component"),
6458                    WebUIFragment::raw("</child-component>"),
6459                ],
6460
6461                contains_boundary: false,
6462            },
6463        );
6464        fragments.insert(
6465            "child-title".to_string(),
6466            FragmentList {
6467                fragments: vec![
6468                    WebUIFragment::raw("Child of "),
6469                    WebUIFragment::signal("title", false),
6470                ],
6471                contains_boundary: false,
6472            },
6473        );
6474        fragments.insert(
6475            "child-component".to_string(),
6476            FragmentList {
6477                fragments: vec![
6478                    WebUIFragment::raw("<span>"),
6479                    WebUIFragment::signal("title", false),
6480                    WebUIFragment::raw("</span>"),
6481                ],
6482                contains_boundary: false,
6483            },
6484        );
6485        let protocol = WebUIProtocol::new(fragments);
6486        let state = test_json!({"who": "<world>"});
6487        let mut writer = TestWriter::new();
6488        handle(
6489            &protocol,
6490            &state,
6491            &RenderOptions::new("index.html", "/"),
6492            &mut writer,
6493        )
6494        .unwrap();
6495        assert_eq!(
6496            writer.get_content(),
6497            "<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>"
6498        );
6499    }
6500
6501    #[test]
6502    fn test_grandchild_attr_propagation() {
6503        let mut fragments = HashMap::new();
6504        fragments.insert(
6505            "index.html".to_string(),
6506            FragmentList {
6507                fragments: vec![
6508                    WebUIFragment::raw("<parent-component"),
6509                    WebUIFragment {
6510                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6511                            WebUIFragmentAttribute {
6512                                name: "title".into(),
6513                                template: "p-title".into(),
6514                                attr_start: true,
6515                                ..Default::default()
6516                            },
6517                        )),
6518                    },
6519                    WebUIFragment::raw(">"),
6520                    WebUIFragment::component("parent-component"),
6521                    WebUIFragment::raw("</parent-component>"),
6522                ],
6523
6524                contains_boundary: false,
6525            },
6526        );
6527        fragments.insert(
6528            "p-title".to_string(),
6529            FragmentList {
6530                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6531                contains_boundary: false,
6532            },
6533        );
6534        fragments.insert(
6535            "parent-component".to_string(),
6536            FragmentList {
6537                fragments: vec![
6538                    WebUIFragment::raw("<child-component"),
6539                    WebUIFragment {
6540                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6541                            WebUIFragmentAttribute {
6542                                name: "title".into(),
6543                                template: "c-title".into(),
6544                                attr_start: true,
6545                                ..Default::default()
6546                            },
6547                        )),
6548                    },
6549                    WebUIFragment::raw(">"),
6550                    WebUIFragment::component("child-component"),
6551                    WebUIFragment::raw("</child-component>"),
6552                ],
6553
6554                contains_boundary: false,
6555            },
6556        );
6557        fragments.insert(
6558            "c-title".to_string(),
6559            FragmentList {
6560                fragments: vec![
6561                    WebUIFragment::raw("C("),
6562                    WebUIFragment::signal("title", false),
6563                    WebUIFragment::raw(")-"),
6564                    WebUIFragment::signal("cExtra", false),
6565                ],
6566                contains_boundary: false,
6567            },
6568        );
6569        fragments.insert(
6570            "child-component".to_string(),
6571            FragmentList {
6572                fragments: vec![
6573                    WebUIFragment::raw("<grandchild-component"),
6574                    WebUIFragment {
6575                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6576                            WebUIFragmentAttribute {
6577                                name: "title".into(),
6578                                value: "title".into(),
6579                                attr_start: true,
6580                                ..Default::default()
6581                            },
6582                        )),
6583                    },
6584                    WebUIFragment::raw(">"),
6585                    WebUIFragment::component("grandchild-component"),
6586                    WebUIFragment::raw("</grandchild-component>"),
6587                ],
6588
6589                contains_boundary: false,
6590            },
6591        );
6592        fragments.insert(
6593            "grandchild-component".to_string(),
6594            FragmentList {
6595                fragments: vec![
6596                    WebUIFragment::raw("<span>"),
6597                    WebUIFragment::signal("title", false),
6598                    WebUIFragment::raw("</span>"),
6599                ],
6600                contains_boundary: false,
6601            },
6602        );
6603        let protocol = WebUIProtocol::new(fragments);
6604        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6605        let mut writer = TestWriter::new();
6606        handle(
6607            &protocol,
6608            &state,
6609            &RenderOptions::new("index.html", "/"),
6610            &mut writer,
6611        )
6612        .unwrap();
6613        assert_eq!(
6614            writer.get_content(),
6615            "<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>"
6616        );
6617    }
6618
6619    #[test]
6620    fn test_for_loop_component_attr() {
6621        let mut fragments = HashMap::new();
6622        fragments.insert(
6623            "index.html".to_string(),
6624            FragmentList {
6625                fragments: vec![
6626                    WebUIFragment::raw("<parent-component"),
6627                    WebUIFragment {
6628                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6629                            WebUIFragmentAttribute {
6630                                name: "title".into(),
6631                                template: "parent-title-loop".into(),
6632                                attr_start: true,
6633                                ..Default::default()
6634                            },
6635                        )),
6636                    },
6637                    WebUIFragment::raw(">"),
6638                    WebUIFragment::component("parent-component"),
6639                    WebUIFragment::raw("</parent-component>"),
6640                ],
6641
6642                contains_boundary: false,
6643            },
6644        );
6645        fragments.insert(
6646            "parent-title-loop".to_string(),
6647            FragmentList {
6648                fragments: vec![
6649                    WebUIFragment::raw("Parent:"),
6650                    WebUIFragment::signal("who", false),
6651                ],
6652                contains_boundary: false,
6653            },
6654        );
6655        fragments.insert(
6656            "parent-component".to_string(),
6657            FragmentList {
6658                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6659                contains_boundary: false,
6660            },
6661        );
6662        fragments.insert(
6663            "child-loop".to_string(),
6664            FragmentList {
6665                fragments: vec![
6666                    WebUIFragment::raw("<child-component"),
6667                    WebUIFragment {
6668                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6669                            WebUIFragmentAttribute {
6670                                name: "title".into(),
6671                                template: "child-title-loop".into(),
6672                                attr_start: true,
6673                                ..Default::default()
6674                            },
6675                        )),
6676                    },
6677                    WebUIFragment::raw(">"),
6678                    WebUIFragment::component("child-component"),
6679                    WebUIFragment::raw("</child-component>"),
6680                ],
6681
6682                contains_boundary: false,
6683            },
6684        );
6685        fragments.insert(
6686            "child-title-loop".to_string(),
6687            FragmentList {
6688                fragments: vec![
6689                    WebUIFragment::raw("Hi "),
6690                    WebUIFragment::signal("item.name", false),
6691                    WebUIFragment::raw(" / "),
6692                    WebUIFragment::signal("title", false),
6693                ],
6694                contains_boundary: false,
6695            },
6696        );
6697        fragments.insert(
6698            "child-component".to_string(),
6699            FragmentList {
6700                fragments: vec![
6701                    WebUIFragment::raw("<span>"),
6702                    WebUIFragment::signal("title", false),
6703                    WebUIFragment::raw("</span>"),
6704                ],
6705                contains_boundary: false,
6706            },
6707        );
6708        let protocol = WebUIProtocol::new(fragments);
6709        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6710        let mut writer = TestWriter::new();
6711        handle(
6712            &protocol,
6713            &state,
6714            &RenderOptions::new("index.html", "/"),
6715            &mut writer,
6716        )
6717        .unwrap();
6718        assert_eq!(
6719            writer.get_content(),
6720            "<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>"
6721        );
6722    }
6723
6724    #[test]
6725    fn test_multiple_template_attrs() {
6726        let mut fragments = HashMap::new();
6727        fragments.insert(
6728            "index.html".to_string(),
6729            FragmentList {
6730                fragments: vec![
6731                    WebUIFragment::raw("<my-component"),
6732                    WebUIFragment {
6733                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6734                            WebUIFragmentAttribute {
6735                                name: "title".into(),
6736                                template: "attr-title".into(),
6737                                attr_start: true,
6738                                ..Default::default()
6739                            },
6740                        )),
6741                    },
6742                    WebUIFragment {
6743                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6744                            WebUIFragmentAttribute {
6745                                name: "data-title".into(),
6746                                template: "attr-data-title".into(),
6747                                attr_start: false,
6748                                ..Default::default()
6749                            },
6750                        )),
6751                    },
6752                    WebUIFragment {
6753                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6754                            WebUIFragmentAttribute {
6755                                name: "aria-label".into(),
6756                                template: "attr-aria-label".into(),
6757                                attr_start: false,
6758                                ..Default::default()
6759                            },
6760                        )),
6761                    },
6762                    WebUIFragment::raw(">"),
6763                    WebUIFragment::component("my-component"),
6764                    WebUIFragment::raw("</my-component>"),
6765                ],
6766
6767                contains_boundary: false,
6768            },
6769        );
6770        fragments.insert(
6771            "attr-title".to_string(),
6772            FragmentList {
6773                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6774                contains_boundary: false,
6775            },
6776        );
6777        fragments.insert(
6778            "attr-data-title".to_string(),
6779            FragmentList {
6780                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6781                contains_boundary: false,
6782            },
6783        );
6784        fragments.insert(
6785            "attr-aria-label".to_string(),
6786            FragmentList {
6787                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6788                contains_boundary: false,
6789            },
6790        );
6791        fragments.insert(
6792            "my-component".to_string(),
6793            FragmentList {
6794                fragments: vec![
6795                    WebUIFragment::raw("<span>"),
6796                    WebUIFragment::signal("title", false),
6797                    WebUIFragment::raw("|"),
6798                    WebUIFragment::signal("dataTitle", false),
6799                    WebUIFragment::raw("|"),
6800                    WebUIFragment::signal("ariaLabel", false),
6801                    WebUIFragment::raw("</span>"),
6802                ],
6803                contains_boundary: false,
6804            },
6805        );
6806        let protocol = WebUIProtocol::new(fragments);
6807        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6808        let mut writer = TestWriter::new();
6809        handle(
6810            &protocol,
6811            &state,
6812            &RenderOptions::new("index.html", "/"),
6813            &mut writer,
6814        )
6815        .unwrap();
6816        assert_eq!(
6817            writer.get_content(),
6818            "<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>"
6819        );
6820    }
6821
6822    #[test]
6823    fn test_attr_priority_over_global() {
6824        let mut fragments = HashMap::new();
6825        fragments.insert(
6826            "index.html".to_string(),
6827            FragmentList {
6828                fragments: vec![
6829                    WebUIFragment::raw("<my-component"),
6830                    WebUIFragment {
6831                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6832                            WebUIFragmentAttribute {
6833                                name: "title".into(),
6834                                value: "Attribute Title".into(),
6835                                raw_value: true,
6836                                attr_start: true,
6837                                ..Default::default()
6838                            },
6839                        )),
6840                    },
6841                    WebUIFragment::raw(">"),
6842                    WebUIFragment::component("my-component"),
6843                    WebUIFragment::raw("</my-component>"),
6844                ],
6845
6846                contains_boundary: false,
6847            },
6848        );
6849        fragments.insert(
6850            "my-component".to_string(),
6851            FragmentList {
6852                fragments: vec![
6853                    WebUIFragment::raw("<span>"),
6854                    WebUIFragment::signal("title", false),
6855                    WebUIFragment::raw("</span>"),
6856                ],
6857                contains_boundary: false,
6858            },
6859        );
6860        let protocol = WebUIProtocol::new(fragments);
6861        let state = test_json!({"title": "Global Title"});
6862        let mut writer = TestWriter::new();
6863        handle(
6864            &protocol,
6865            &state,
6866            &RenderOptions::new("index.html", "/"),
6867            &mut writer,
6868        )
6869        .unwrap();
6870        assert_eq!(
6871            writer.get_content(),
6872            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6873        );
6874    }
6875
6876    #[test]
6877    fn test_attr_priority_over_local_and_global() {
6878        let mut fragments = HashMap::new();
6879        fragments.insert(
6880            "index.html".to_string(),
6881            FragmentList {
6882                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6883                contains_boundary: false,
6884            },
6885        );
6886        fragments.insert(
6887            "loop".to_string(),
6888            FragmentList {
6889                fragments: vec![
6890                    WebUIFragment::raw("<my-component"),
6891                    WebUIFragment {
6892                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6893                            WebUIFragmentAttribute {
6894                                name: "title".into(),
6895                                value: "Attribute Title".into(),
6896                                raw_value: true,
6897                                attr_start: true,
6898                                ..Default::default()
6899                            },
6900                        )),
6901                    },
6902                    WebUIFragment::raw(">"),
6903                    WebUIFragment::component("my-component"),
6904                    WebUIFragment::raw("</my-component>"),
6905                ],
6906
6907                contains_boundary: false,
6908            },
6909        );
6910        fragments.insert(
6911            "my-component".to_string(),
6912            FragmentList {
6913                fragments: vec![
6914                    WebUIFragment::raw("<span>"),
6915                    WebUIFragment::signal("title", false),
6916                    WebUIFragment::raw("</span>"),
6917                ],
6918                contains_boundary: false,
6919            },
6920        );
6921        let protocol = WebUIProtocol::new(fragments);
6922        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6923        let mut writer = TestWriter::new();
6924        handle(
6925            &protocol,
6926            &state,
6927            &RenderOptions::new("index.html", "/"),
6928            &mut writer,
6929        )
6930        .unwrap();
6931        assert_eq!(
6932            writer.get_content(),
6933            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6934        );
6935    }
6936
6937    #[test]
6938    fn test_boolean_attr_first_component_attr() {
6939        let mut fragments = HashMap::new();
6940        fragments.insert(
6941            "index.html".to_string(),
6942            FragmentList {
6943                fragments: vec![
6944                    WebUIFragment::raw("<my-component"),
6945                    WebUIFragment {
6946                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6947                            WebUIFragmentAttribute {
6948                                name: "disabled".into(),
6949                                attr_start: true,
6950                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6951                                ..Default::default()
6952                            },
6953                        )),
6954                    },
6955                    WebUIFragment {
6956                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6957                            WebUIFragmentAttribute {
6958                                name: "label".into(),
6959                                value: "Component Label".into(),
6960                                raw_value: true,
6961                                attr_start: false,
6962                                ..Default::default()
6963                            },
6964                        )),
6965                    },
6966                    WebUIFragment::raw(">"),
6967                    WebUIFragment::component("my-component"),
6968                    WebUIFragment::raw("</my-component>"),
6969                ],
6970
6971                contains_boundary: false,
6972            },
6973        );
6974        fragments.insert(
6975            "my-component".to_string(),
6976            FragmentList {
6977                fragments: vec![
6978                    WebUIFragment::if_cond(
6979                        ConditionExpr::identifier("disabled"),
6980                        "disabledTemplate",
6981                    ),
6982                    WebUIFragment::raw("<span>"),
6983                    WebUIFragment::signal("label", false),
6984                    WebUIFragment::raw("</span>"),
6985                ],
6986                contains_boundary: false,
6987            },
6988        );
6989        fragments.insert(
6990            "disabledTemplate".to_string(),
6991            FragmentList {
6992                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6993                contains_boundary: false,
6994            },
6995        );
6996        let protocol = WebUIProtocol::new(fragments);
6997        let state = test_json!({"isDisabled": true});
6998        let mut writer = TestWriter::new();
6999        handle(
7000            &protocol,
7001            &state,
7002            &RenderOptions::new("index.html", "/"),
7003            &mut writer,
7004        )
7005        .unwrap();
7006        assert_eq!(
7007            writer.get_content(),
7008            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7009        );
7010    }
7011
7012    #[test]
7013    fn test_hyphenated_attr_camelcase() {
7014        let mut fragments = HashMap::new();
7015        fragments.insert(
7016            "index.html".to_string(),
7017            FragmentList {
7018                fragments: vec![
7019                    WebUIFragment::raw("<my-component"),
7020                    WebUIFragment {
7021                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7022                            WebUIFragmentAttribute {
7023                                name: "key-hyphen".into(),
7024                                value: "Local Value".into(),
7025                                raw_value: true,
7026                                attr_start: true,
7027                                ..Default::default()
7028                            },
7029                        )),
7030                    },
7031                    WebUIFragment::raw(">"),
7032                    WebUIFragment::component("my-component"),
7033                    WebUIFragment::raw("</my-component>"),
7034                ],
7035
7036                contains_boundary: false,
7037            },
7038        );
7039        fragments.insert(
7040            "my-component".to_string(),
7041            FragmentList {
7042                fragments: vec![
7043                    WebUIFragment::raw("<span>"),
7044                    WebUIFragment::signal("keyHyphen", false),
7045                    WebUIFragment::raw("</span>"),
7046                ],
7047                contains_boundary: false,
7048            },
7049        );
7050        let protocol = WebUIProtocol::new(fragments);
7051        let state = test_json!({"keyHyphen": "Global Value"});
7052        let mut writer = TestWriter::new();
7053        handle(
7054            &protocol,
7055            &state,
7056            &RenderOptions::new("index.html", "/"),
7057            &mut writer,
7058        )
7059        .unwrap();
7060        assert_eq!(
7061            writer.get_content(),
7062            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7063        );
7064    }
7065
7066    #[test]
7067    fn test_skipped_component_attrs() {
7068        // Skipped attributes: class, style, role, data-*, aria-*
7069        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
7070        // These render on the HTML element but are NOT passed into component attribute state.
7071        let mut fragments = HashMap::new();
7072        fragments.insert(
7073            "index.html".to_string(),
7074            FragmentList {
7075                fragments: vec![
7076                    WebUIFragment::raw("<test-component"),
7077                    // Skipped: class
7078                    WebUIFragment {
7079                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7080                            WebUIFragmentAttribute {
7081                                name: "class".into(),
7082                                value: "skippedClass".into(),
7083                                attr_start: true,
7084                                attr_skip: true,
7085                                ..Default::default()
7086                            },
7087                        )),
7088                    },
7089                    // Skipped: style
7090                    WebUIFragment {
7091                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7092                            WebUIFragmentAttribute {
7093                                name: "style".into(),
7094                                value: "skippedStyle".into(),
7095                                attr_skip: true,
7096                                ..Default::default()
7097                            },
7098                        )),
7099                    },
7100                    // Skipped: role
7101                    WebUIFragment {
7102                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7103                            WebUIFragmentAttribute {
7104                                name: "role".into(),
7105                                value: "skippedRole".into(),
7106                                attr_skip: true,
7107                                ..Default::default()
7108                            },
7109                        )),
7110                    },
7111                    // Skipped: data-testid (data-* prefix)
7112                    WebUIFragment {
7113                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7114                            WebUIFragmentAttribute {
7115                                name: "data-testid".into(),
7116                                value: "skippedDataTestid".into(),
7117                                attr_skip: true,
7118                                ..Default::default()
7119                            },
7120                        )),
7121                    },
7122                    // Skipped: aria-label (aria-* prefix)
7123                    WebUIFragment {
7124                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7125                            WebUIFragmentAttribute {
7126                                name: "aria-label".into(),
7127                                value: "skippedAriaLabel".into(),
7128                                attr_skip: true,
7129                                ..Default::default()
7130                            },
7131                        )),
7132                    },
7133                    // NOT skipped: title
7134                    WebUIFragment {
7135                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7136                            WebUIFragmentAttribute {
7137                                name: "title".into(),
7138                                value: "title".into(),
7139                                ..Default::default()
7140                            },
7141                        )),
7142                    },
7143                    WebUIFragment::raw(">"),
7144                    WebUIFragment::component("test-component"),
7145                    WebUIFragment::raw("</test-component>"),
7146                ],
7147
7148                contains_boundary: false,
7149            },
7150        );
7151        fragments.insert(
7152            "test-component".to_string(),
7153            FragmentList {
7154                fragments: vec![
7155                    WebUIFragment::raw("<span>"),
7156                    WebUIFragment::signal("title", false),
7157                    WebUIFragment::raw("-"),
7158                    WebUIFragment::signal("class", false),
7159                    WebUIFragment::raw("-"),
7160                    WebUIFragment::signal("style", false),
7161                    WebUIFragment::raw("-"),
7162                    WebUIFragment::signal("role", false),
7163                    WebUIFragment::raw("-"),
7164                    WebUIFragment::signal("dataTestid", false),
7165                    WebUIFragment::raw("-"),
7166                    WebUIFragment::signal("ariaLabel", false),
7167                    WebUIFragment::raw("</span>"),
7168                ],
7169                contains_boundary: false,
7170            },
7171        );
7172        let protocol = WebUIProtocol::new(fragments);
7173        let state = test_json!({
7174            "title": "Hello",
7175            "skippedClass": "my-class",
7176            "skippedStyle": "color:red",
7177            "skippedRole": "button",
7178            "skippedDataTestid": "test-id",
7179            "skippedAriaLabel": "label-text"
7180        });
7181        let mut writer = TestWriter::new();
7182        handle(
7183            &protocol,
7184            &state,
7185            &RenderOptions::new("index.html", "/"),
7186            &mut writer,
7187        )
7188        .unwrap();
7189        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7190        // The component's signals for skipped attrs resolve to empty strings.
7191        // Only "title" (non-skipped) is accessible.
7192        assert_eq!(
7193            writer.get_content(),
7194            "<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>"
7195        );
7196    }
7197
7198    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7199
7200    #[test]
7201    fn test_attr_inherit_parent_to_child() {
7202        let mut fragments = HashMap::new();
7203        fragments.insert(
7204            "index.html".to_string(),
7205            FragmentList {
7206                fragments: vec![
7207                    WebUIFragment::raw("<parent-component"),
7208                    WebUIFragment {
7209                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7210                            WebUIFragmentAttribute {
7211                                name: "title".into(),
7212                                value: "Parent Title".into(),
7213                                raw_value: true,
7214                                attr_start: true,
7215                                ..Default::default()
7216                            },
7217                        )),
7218                    },
7219                    WebUIFragment::raw(">"),
7220                    WebUIFragment::component("parent-component"),
7221                    WebUIFragment::raw("</parent-component>"),
7222                ],
7223
7224                contains_boundary: false,
7225            },
7226        );
7227        fragments.insert(
7228            "parent-component".to_string(),
7229            FragmentList {
7230                fragments: vec![
7231                    WebUIFragment::raw("<h1>"),
7232                    WebUIFragment::signal("title", false),
7233                    WebUIFragment::raw("</h1><child-component"),
7234                    WebUIFragment {
7235                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7236                            WebUIFragmentAttribute {
7237                                name: "title".into(),
7238                                value: "title".into(),
7239                                attr_start: true,
7240                                ..Default::default()
7241                            },
7242                        )),
7243                    },
7244                    WebUIFragment::raw(">"),
7245                    WebUIFragment::component("child-component"),
7246                    WebUIFragment::raw("</child-component>"),
7247                ],
7248
7249                contains_boundary: false,
7250            },
7251        );
7252        fragments.insert(
7253            "child-component".to_string(),
7254            FragmentList {
7255                fragments: vec![
7256                    WebUIFragment::raw("<h2>"),
7257                    WebUIFragment::signal("title", false),
7258                    WebUIFragment::raw("</h2>"),
7259                ],
7260                contains_boundary: false,
7261            },
7262        );
7263        let protocol = WebUIProtocol::new(fragments);
7264        let state = test_json!({});
7265        let mut writer = TestWriter::new();
7266        handle(
7267            &protocol,
7268            &state,
7269            &RenderOptions::new("index.html", "/"),
7270            &mut writer,
7271        )
7272        .unwrap();
7273        assert_eq!(
7274            writer.get_content(),
7275            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7276        );
7277    }
7278
7279    #[test]
7280    fn test_attr_inherit_deep() {
7281        let mut fragments = HashMap::new();
7282        fragments.insert(
7283            "index.html".to_string(),
7284            FragmentList {
7285                fragments: vec![
7286                    WebUIFragment::raw("<parent-component"),
7287                    WebUIFragment {
7288                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7289                            WebUIFragmentAttribute {
7290                                name: "title".into(),
7291                                value: "Parent Title".into(),
7292                                raw_value: true,
7293                                attr_start: true,
7294                                ..Default::default()
7295                            },
7296                        )),
7297                    },
7298                    WebUIFragment::raw(">"),
7299                    WebUIFragment::component("parent-component"),
7300                    WebUIFragment::raw("</parent-component>"),
7301                ],
7302
7303                contains_boundary: false,
7304            },
7305        );
7306        fragments.insert(
7307            "parent-component".to_string(),
7308            FragmentList {
7309                fragments: vec![
7310                    WebUIFragment::raw("<child-component"),
7311                    WebUIFragment {
7312                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7313                            WebUIFragmentAttribute {
7314                                name: "title".into(),
7315                                value: "Child Title".into(),
7316                                raw_value: true,
7317                                attr_start: true,
7318                                ..Default::default()
7319                            },
7320                        )),
7321                    },
7322                    WebUIFragment::raw(">"),
7323                    WebUIFragment::component("child-component"),
7324                    WebUIFragment::raw("</child-component>"),
7325                ],
7326
7327                contains_boundary: false,
7328            },
7329        );
7330        fragments.insert(
7331            "child-component".to_string(),
7332            FragmentList {
7333                fragments: vec![
7334                    WebUIFragment::raw("<grandchild-component"),
7335                    WebUIFragment {
7336                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7337                            WebUIFragmentAttribute {
7338                                name: "title".into(),
7339                                value: "title".into(),
7340                                attr_start: true,
7341                                ..Default::default()
7342                            },
7343                        )),
7344                    },
7345                    WebUIFragment::raw(">"),
7346                    WebUIFragment::component("grandchild-component"),
7347                    WebUIFragment::raw("</grandchild-component>"),
7348                ],
7349
7350                contains_boundary: false,
7351            },
7352        );
7353        fragments.insert(
7354            "grandchild-component".to_string(),
7355            FragmentList {
7356                fragments: vec![
7357                    WebUIFragment::raw("<h3>"),
7358                    WebUIFragment::signal("title", false),
7359                    WebUIFragment::raw("</h3>"),
7360                ],
7361                contains_boundary: false,
7362            },
7363        );
7364        let protocol = WebUIProtocol::new(fragments);
7365        let state = test_json!({});
7366        let mut writer = TestWriter::new();
7367        handle(
7368            &protocol,
7369            &state,
7370            &RenderOptions::new("index.html", "/"),
7371            &mut writer,
7372        )
7373        .unwrap();
7374        assert_eq!(
7375            writer.get_content(),
7376            "<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>"
7377        );
7378    }
7379
7380    #[test]
7381    fn test_complex_attr_access() {
7382        let mut fragments = HashMap::new();
7383        fragments.insert(
7384            "index.html".to_string(),
7385            FragmentList {
7386                fragments: vec![
7387                    WebUIFragment::raw("<my-component"),
7388                    WebUIFragment {
7389                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7390                            WebUIFragmentAttribute {
7391                                name: ":item".into(),
7392                                value: "complexItem".into(),
7393                                attr_start: true,
7394                                complex: true,
7395                                ..Default::default()
7396                            },
7397                        )),
7398                    },
7399                    WebUIFragment::raw(">"),
7400                    WebUIFragment::component("my-component"),
7401                    WebUIFragment::raw("</my-component>"),
7402                ],
7403
7404                contains_boundary: false,
7405            },
7406        );
7407        fragments.insert(
7408            "my-component".to_string(),
7409            FragmentList {
7410                fragments: vec![
7411                    WebUIFragment::raw("<span>"),
7412                    WebUIFragment::signal("item.foo", false),
7413                    WebUIFragment::raw("</span><p>"),
7414                    WebUIFragment::signal("item.bar", false),
7415                    WebUIFragment::raw("</p>"),
7416                ],
7417                contains_boundary: false,
7418            },
7419        );
7420        let protocol = WebUIProtocol::new(fragments);
7421        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7422        let mut writer = TestWriter::new();
7423        handle(
7424            &protocol,
7425            &state,
7426            &RenderOptions::new("index.html", "/"),
7427            &mut writer,
7428        )
7429        .unwrap();
7430        assert_eq!(
7431            writer.get_content(),
7432            "<my-component><span>1</span><p>true</p></my-component>"
7433        );
7434    }
7435
7436    #[test]
7437    fn test_complex_attr_for_loop() {
7438        let mut fragments = HashMap::new();
7439        fragments.insert(
7440            "index.html".to_string(),
7441            FragmentList {
7442                fragments: vec![WebUIFragment::for_loop(
7443                    "item",
7444                    "list.items",
7445                    "listTemplate",
7446                )],
7447                contains_boundary: false,
7448            },
7449        );
7450        fragments.insert(
7451            "listTemplate".to_string(),
7452            FragmentList {
7453                fragments: vec![
7454                    WebUIFragment {
7455                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7456                            WebUIFragmentAttribute {
7457                                name: ":item".into(),
7458                                value: "item".into(),
7459                                attr_start: true,
7460                                complex: true,
7461                                ..Default::default()
7462                            },
7463                        )),
7464                    },
7465                    WebUIFragment::component("item_component"),
7466                ],
7467
7468                contains_boundary: false,
7469            },
7470        );
7471        fragments.insert(
7472            "item_component".to_string(),
7473            FragmentList {
7474                fragments: vec![
7475                    WebUIFragment::raw("<span>"),
7476                    WebUIFragment::signal("item.name", false),
7477                    WebUIFragment::raw("</span>"),
7478                ],
7479                contains_boundary: false,
7480            },
7481        );
7482        let protocol = WebUIProtocol::new(fragments);
7483        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7484        let mut writer = TestWriter::new();
7485        handle(
7486            &protocol,
7487            &state,
7488            &RenderOptions::new("index.html", "/"),
7489            &mut writer,
7490        )
7491        .unwrap();
7492        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7493    }
7494
7495    #[test]
7496    fn test_complex_attr_nested_for() {
7497        let mut fragments = HashMap::new();
7498        fragments.insert(
7499            "index.html".to_string(),
7500            FragmentList {
7501                fragments: vec![WebUIFragment::for_loop(
7502                    "outer",
7503                    "data.outer",
7504                    "outerTemplate",
7505                )],
7506                contains_boundary: false,
7507            },
7508        );
7509        fragments.insert(
7510            "outerTemplate".to_string(),
7511            FragmentList {
7512                fragments: vec![WebUIFragment::for_loop(
7513                    "middle",
7514                    "outer.middle",
7515                    "middleTemplate",
7516                )],
7517                contains_boundary: false,
7518            },
7519        );
7520        fragments.insert(
7521            "middleTemplate".to_string(),
7522            FragmentList {
7523                fragments: vec![WebUIFragment::for_loop(
7524                    "inner",
7525                    "middle.inner",
7526                    "innerTemplate",
7527                )],
7528                contains_boundary: false,
7529            },
7530        );
7531        fragments.insert(
7532            "innerTemplate".to_string(),
7533            FragmentList {
7534                fragments: vec![
7535                    WebUIFragment::raw("<card"),
7536                    WebUIFragment {
7537                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7538                            WebUIFragmentAttribute {
7539                                name: ":outer".into(),
7540                                value: "outer".into(),
7541                                attr_start: true,
7542                                complex: true,
7543                                ..Default::default()
7544                            },
7545                        )),
7546                    },
7547                    WebUIFragment {
7548                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7549                            WebUIFragmentAttribute {
7550                                name: ":middle".into(),
7551                                value: "middle".into(),
7552                                attr_start: false,
7553                                complex: true,
7554                                ..Default::default()
7555                            },
7556                        )),
7557                    },
7558                    WebUIFragment {
7559                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7560                            WebUIFragmentAttribute {
7561                                name: ":inner".into(),
7562                                value: "inner".into(),
7563                                attr_start: false,
7564                                complex: true,
7565                                ..Default::default()
7566                            },
7567                        )),
7568                    },
7569                    WebUIFragment::raw(">"),
7570                    WebUIFragment::component("card_component"),
7571                    WebUIFragment::raw("</card>"),
7572                ],
7573
7574                contains_boundary: false,
7575            },
7576        );
7577        fragments.insert(
7578            "card_component".to_string(),
7579            FragmentList {
7580                fragments: vec![
7581                    WebUIFragment::raw("<p>"),
7582                    WebUIFragment::signal("outer.label", false),
7583                    WebUIFragment::raw(" / "),
7584                    WebUIFragment::signal("middle.label", false),
7585                    WebUIFragment::raw(" / "),
7586                    WebUIFragment::signal("inner.label", false),
7587                    WebUIFragment::raw("</p>"),
7588                ],
7589                contains_boundary: false,
7590            },
7591        );
7592        let protocol = WebUIProtocol::new(fragments);
7593        let state = test_json!({"data": {"outer": [
7594            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7595            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7596        ]}});
7597        let mut writer = TestWriter::new();
7598        handle(
7599            &protocol,
7600            &state,
7601            &RenderOptions::new("index.html", "/"),
7602            &mut writer,
7603        )
7604        .unwrap();
7605        assert_eq!(
7606            writer.get_content(),
7607            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7608        );
7609    }
7610
7611    // ── GROUP 8: Boolean Component State ──────────────────────────────
7612
7613    #[test]
7614    fn test_bool_component_state_true() {
7615        let mut fragments = HashMap::new();
7616        fragments.insert(
7617            "index.html".to_string(),
7618            FragmentList {
7619                fragments: vec![
7620                    WebUIFragment::raw("<my-component"),
7621                    WebUIFragment {
7622                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7623                            WebUIFragmentAttribute {
7624                                name: "disabled".into(),
7625                                attr_start: true,
7626                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7627                                ..Default::default()
7628                            },
7629                        )),
7630                    },
7631                    WebUIFragment::raw(">"),
7632                    WebUIFragment::component("my-component"),
7633                    WebUIFragment::raw("</my-component>"),
7634                ],
7635
7636                contains_boundary: false,
7637            },
7638        );
7639        fragments.insert(
7640            "my-component".to_string(),
7641            FragmentList {
7642                fragments: vec![
7643                    WebUIFragment::if_cond(
7644                        ConditionExpr::identifier("disabled"),
7645                        "disabledTemplate",
7646                    ),
7647                    WebUIFragment::if_cond(
7648                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7649                        "enabledTemplate",
7650                    ),
7651                ],
7652                contains_boundary: false,
7653            },
7654        );
7655        fragments.insert(
7656            "disabledTemplate".to_string(),
7657            FragmentList {
7658                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7659                contains_boundary: false,
7660            },
7661        );
7662        fragments.insert(
7663            "enabledTemplate".to_string(),
7664            FragmentList {
7665                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7666                contains_boundary: false,
7667            },
7668        );
7669        let protocol = WebUIProtocol::new(fragments);
7670        let state = test_json!({"isDisabled": true});
7671        let mut writer = TestWriter::new();
7672        handle(
7673            &protocol,
7674            &state,
7675            &RenderOptions::new("index.html", "/"),
7676            &mut writer,
7677        )
7678        .unwrap();
7679        assert_eq!(
7680            writer.get_content(),
7681            "<my-component disabled><span>Disabled</span></my-component>"
7682        );
7683    }
7684
7685    #[test]
7686    fn test_bool_component_state_false() {
7687        let mut fragments = HashMap::new();
7688        fragments.insert(
7689            "index.html".to_string(),
7690            FragmentList {
7691                fragments: vec![
7692                    WebUIFragment::raw("<my-component"),
7693                    WebUIFragment {
7694                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7695                            WebUIFragmentAttribute {
7696                                name: "disabled".into(),
7697                                attr_start: true,
7698                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7699                                ..Default::default()
7700                            },
7701                        )),
7702                    },
7703                    WebUIFragment::raw(">"),
7704                    WebUIFragment::component("my-component"),
7705                    WebUIFragment::raw("</my-component>"),
7706                ],
7707
7708                contains_boundary: false,
7709            },
7710        );
7711        fragments.insert(
7712            "my-component".to_string(),
7713            FragmentList {
7714                fragments: vec![
7715                    WebUIFragment::if_cond(
7716                        ConditionExpr::identifier("disabled"),
7717                        "disabledTemplate",
7718                    ),
7719                    WebUIFragment::if_cond(
7720                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7721                        "enabledTemplate",
7722                    ),
7723                ],
7724                contains_boundary: false,
7725            },
7726        );
7727        fragments.insert(
7728            "disabledTemplate".to_string(),
7729            FragmentList {
7730                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7731                contains_boundary: false,
7732            },
7733        );
7734        fragments.insert(
7735            "enabledTemplate".to_string(),
7736            FragmentList {
7737                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7738                contains_boundary: false,
7739            },
7740        );
7741        let protocol = WebUIProtocol::new(fragments);
7742        let state = test_json!({"isDisabled": false});
7743        let mut writer = TestWriter::new();
7744        handle(
7745            &protocol,
7746            &state,
7747            &RenderOptions::new("index.html", "/"),
7748            &mut writer,
7749        )
7750        .unwrap();
7751        assert_eq!(
7752            writer.get_content(),
7753            "<my-component><span>Enabled</span></my-component>"
7754        );
7755    }
7756
7757    #[test]
7758    fn test_bool_component_state_forward() {
7759        let mut fragments = HashMap::new();
7760        fragments.insert(
7761            "index.html".to_string(),
7762            FragmentList {
7763                fragments: vec![
7764                    WebUIFragment::raw("<parent-component"),
7765                    WebUIFragment {
7766                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7767                            WebUIFragmentAttribute {
7768                                name: "disabled".into(),
7769                                attr_start: true,
7770                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7771                                ..Default::default()
7772                            },
7773                        )),
7774                    },
7775                    WebUIFragment::raw(">"),
7776                    WebUIFragment::component("parent-component"),
7777                    WebUIFragment::raw("</parent-component>"),
7778                ],
7779
7780                contains_boundary: false,
7781            },
7782        );
7783        fragments.insert(
7784            "parent-component".to_string(),
7785            FragmentList {
7786                fragments: vec![
7787                    WebUIFragment::if_cond(
7788                        ConditionExpr::identifier("disabled"),
7789                        "parentDisabledTemplate",
7790                    ),
7791                    WebUIFragment::raw("<child-component"),
7792                    WebUIFragment {
7793                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7794                            WebUIFragmentAttribute {
7795                                name: "disabled".into(),
7796                                attr_start: true,
7797                                condition_tree: Some(ConditionExpr::identifier("disabled")),
7798                                ..Default::default()
7799                            },
7800                        )),
7801                    },
7802                    WebUIFragment::raw(">"),
7803                    WebUIFragment::component("child-component"),
7804                    WebUIFragment::raw("</child-component>"),
7805                ],
7806
7807                contains_boundary: false,
7808            },
7809        );
7810        fragments.insert(
7811            "parentDisabledTemplate".to_string(),
7812            FragmentList {
7813                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7814                contains_boundary: false,
7815            },
7816        );
7817        fragments.insert(
7818            "child-component".to_string(),
7819            FragmentList {
7820                fragments: vec![
7821                    WebUIFragment::if_cond(
7822                        ConditionExpr::identifier("disabled"),
7823                        "childDisabledTemplate",
7824                    ),
7825                    WebUIFragment::if_cond(
7826                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7827                        "childEnabledTemplate",
7828                    ),
7829                ],
7830                contains_boundary: false,
7831            },
7832        );
7833        fragments.insert(
7834            "childDisabledTemplate".to_string(),
7835            FragmentList {
7836                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7837                contains_boundary: false,
7838            },
7839        );
7840        fragments.insert(
7841            "childEnabledTemplate".to_string(),
7842            FragmentList {
7843                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7844                contains_boundary: false,
7845            },
7846        );
7847
7848        // Test case 1: isDisabled = true
7849        {
7850            let protocol = WebUIProtocol::new(fragments.clone());
7851            let state = test_json!({"isDisabled": true});
7852            let mut writer = TestWriter::new();
7853            handle(
7854                &protocol,
7855                &state,
7856                &RenderOptions::new("index.html", "/"),
7857                &mut writer,
7858            )
7859            .unwrap();
7860            assert_eq!(
7861                writer.get_content(),
7862                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7863            );
7864        }
7865
7866        // Test case 2: isDisabled = false
7867        {
7868            let protocol = WebUIProtocol::new(fragments.clone());
7869            let state = test_json!({"isDisabled": false});
7870            let mut writer = TestWriter::new();
7871            handle(
7872                &protocol,
7873                &state,
7874                &RenderOptions::new("index.html", "/"),
7875                &mut writer,
7876            )
7877            .unwrap();
7878            assert_eq!(
7879                writer.get_content(),
7880                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7881            );
7882        }
7883    }
7884
7885    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
7886
7887    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
7888
7889    // ── Component tests ──────────────────────────────────────────────
7890
7891    #[test]
7892    fn test_component_with_template() {
7893        let mut fragments = HashMap::new();
7894        fragments.insert(
7895            "index.html".to_string(),
7896            FragmentList {
7897                fragments: vec![
7898                    WebUIFragment::raw("<custom-element>"),
7899                    WebUIFragment::component("custom-element"),
7900                    WebUIFragment::raw("</custom-element>"),
7901                ],
7902                contains_boundary: false,
7903            },
7904        );
7905        fragments.insert(
7906            "custom-element".to_string(),
7907            FragmentList {
7908                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7909                contains_boundary: false,
7910            },
7911        );
7912        let protocol = WebUIProtocol::new(fragments);
7913        let state = test_json!({});
7914        let mut writer = TestWriter::new();
7915        handle(
7916            &protocol,
7917            &state,
7918            &RenderOptions::new("index.html", "/"),
7919            &mut writer,
7920        )
7921        .unwrap();
7922        assert_eq!(
7923            writer.get_content(),
7924            "<custom-element><div>Custom Element</div></custom-element>"
7925        );
7926        assert!(writer.is_ended());
7927    }
7928
7929    #[test]
7930    fn test_component_with_slots() {
7931        let mut fragments = HashMap::new();
7932        fragments.insert(
7933            "index.html".to_string(),
7934            FragmentList {
7935                fragments: vec![
7936                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7937                    WebUIFragment::component("custom-element"),
7938                    WebUIFragment::raw("Hello World</custom-element>"),
7939                ],
7940                contains_boundary: false,
7941            },
7942        );
7943        fragments.insert(
7944            "custom-element".to_string(),
7945            FragmentList {
7946                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7947                contains_boundary: false,
7948            },
7949        );
7950        let protocol = WebUIProtocol::new(fragments);
7951        let state = test_json!({});
7952        let mut writer = TestWriter::new();
7953        handle(
7954            &protocol,
7955            &state,
7956            &RenderOptions::new("index.html", "/"),
7957            &mut writer,
7958        )
7959        .unwrap();
7960        assert_eq!(
7961            writer.get_content(),
7962            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7963        );
7964        assert!(writer.is_ended());
7965    }
7966
7967    #[test]
7968    fn test_multiple_nested_components() {
7969        let mut fragments = HashMap::new();
7970        fragments.insert(
7971            "index.html".to_string(),
7972            FragmentList {
7973                fragments: vec![
7974                    WebUIFragment::raw("<div>"),
7975                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
7976                    WebUIFragment::raw("</div>"),
7977                ],
7978                contains_boundary: false,
7979            },
7980        );
7981        fragments.insert(
7982            "custom-button".to_string(),
7983            FragmentList {
7984                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7985                contains_boundary: false,
7986            },
7987        );
7988        fragments.insert(
7989            "custom-element".to_string(),
7990            FragmentList {
7991                fragments: vec![
7992                    WebUIFragment::raw("<custom-child>"),
7993                    WebUIFragment::component("custom-child"),
7994                    WebUIFragment::raw("</custom-child><slot></slot>"),
7995                ],
7996                contains_boundary: false,
7997            },
7998        );
7999        fragments.insert(
8000            "custom-child".to_string(),
8001            FragmentList {
8002                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8003                contains_boundary: false,
8004            },
8005        );
8006        fragments.insert(
8007            "templateRepeat".to_string(),
8008            FragmentList {
8009                fragments: vec![
8010                    WebUIFragment::raw("<custom-element>"),
8011                    WebUIFragment::component("custom-element"),
8012                    WebUIFragment::raw("<custom-button>"),
8013                    WebUIFragment::component("custom-button"),
8014                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
8015                ],
8016                contains_boundary: false,
8017            },
8018        );
8019        let protocol = WebUIProtocol::new(fragments);
8020        let state = test_json!({"items": [{"name": "Item1"}]});
8021        let mut writer = TestWriter::new();
8022        handle(
8023            &protocol,
8024            &state,
8025            &RenderOptions::new("index.html", "/"),
8026            &mut writer,
8027        )
8028        .unwrap();
8029        assert_eq!(
8030            writer.get_content(),
8031            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8032        );
8033        assert!(writer.is_ended());
8034    }
8035
8036    // ── Conditional tests ────────────────────────────────────────────
8037
8038    #[test]
8039    fn test_if_with_binary_expression() {
8040        let mut fragments = HashMap::new();
8041        fragments.insert(
8042            "index.html".to_string(),
8043            FragmentList {
8044                fragments: vec![
8045                    WebUIFragment::raw("<div>"),
8046                    WebUIFragment::if_cond(
8047                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8048                        "if-1",
8049                    ),
8050                    WebUIFragment::raw("</div>"),
8051                ],
8052                contains_boundary: false,
8053            },
8054        );
8055        fragments.insert(
8056            "if-1".to_string(),
8057            FragmentList {
8058                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8059                contains_boundary: false,
8060            },
8061        );
8062        let protocol = WebUIProtocol::new(fragments);
8063
8064        // True case: x = 10 > 5
8065        let state_true = test_json!({"x": 10});
8066        let mut writer_true = TestWriter::new();
8067        handle(
8068            &protocol,
8069            &state_true,
8070            &RenderOptions::new("index.html", "/"),
8071            &mut writer_true,
8072        )
8073        .unwrap();
8074        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8075
8076        // False case: x = 1 <= 5
8077        let state_false = test_json!({"x": 1});
8078        let mut writer_false = TestWriter::new();
8079        handle(
8080            &protocol,
8081            &state_false,
8082            &RenderOptions::new("index.html", "/"),
8083            &mut writer_false,
8084        )
8085        .unwrap();
8086        assert_eq!(writer_false.get_content(), "<div></div>");
8087    }
8088
8089    #[test]
8090    fn test_for_if_overlapping_local_state() {
8091        let mut fragments = HashMap::new();
8092        fragments.insert(
8093            "index.html".to_string(),
8094            FragmentList {
8095                fragments: vec![
8096                    WebUIFragment::raw("<div>"),
8097                    WebUIFragment::for_loop("item", "items", "template1"),
8098                    WebUIFragment::raw("</div>"),
8099                ],
8100                contains_boundary: false,
8101            },
8102        );
8103        fragments.insert(
8104            "template1".to_string(),
8105            FragmentList {
8106                fragments: vec![
8107                    WebUIFragment::raw("<div>"),
8108                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8109                    WebUIFragment::raw("</div>"),
8110                ],
8111                contains_boundary: false,
8112            },
8113        );
8114        fragments.insert(
8115            "ifBlock".to_string(),
8116            FragmentList {
8117                fragments: vec![
8118                    WebUIFragment::raw("<span>"),
8119                    WebUIFragment::signal("item.label", false),
8120                    WebUIFragment::raw("</span>"),
8121                ],
8122                contains_boundary: false,
8123            },
8124        );
8125        let protocol = WebUIProtocol::new(fragments);
8126        let state = test_json!({
8127            "flag": false,
8128            "items": [
8129                {"label": "A", "flag": true},
8130                {"label": "B", "flag": false},
8131                {"label": "C", "flag": true}
8132            ]
8133        });
8134        let mut writer = TestWriter::new();
8135        handle(
8136            &protocol,
8137            &state,
8138            &RenderOptions::new("index.html", "/"),
8139            &mut writer,
8140        )
8141        .unwrap();
8142        assert_eq!(
8143            writer.get_content(),
8144            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8145        );
8146    }
8147
8148    #[test]
8149    fn test_for_if_global_flag_no_effect() {
8150        let mut fragments = HashMap::new();
8151        fragments.insert(
8152            "index.html".to_string(),
8153            FragmentList {
8154                fragments: vec![
8155                    WebUIFragment::raw("<div>"),
8156                    WebUIFragment::for_loop("item", "items", "template1"),
8157                    WebUIFragment::raw("</div>"),
8158                ],
8159                contains_boundary: false,
8160            },
8161        );
8162        fragments.insert(
8163            "template1".to_string(),
8164            FragmentList {
8165                fragments: vec![
8166                    WebUIFragment::raw("<div>"),
8167                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8168                    WebUIFragment::raw("</div>"),
8169                ],
8170                contains_boundary: false,
8171            },
8172        );
8173        fragments.insert(
8174            "ifBlock".to_string(),
8175            FragmentList {
8176                fragments: vec![
8177                    WebUIFragment::raw("<span>"),
8178                    WebUIFragment::signal("item.label", false),
8179                    WebUIFragment::raw("</span>"),
8180                ],
8181                contains_boundary: false,
8182            },
8183        );
8184        let protocol = WebUIProtocol::new(fragments);
8185        let state = test_json!({
8186            "item": {"flag": true},
8187            "items": [
8188                {"label": "A", "flag": false},
8189                {"label": "B", "flag": true},
8190                {"label": "C", "flag": false}
8191            ]
8192        });
8193        let mut writer = TestWriter::new();
8194        handle(
8195            &protocol,
8196            &state,
8197            &RenderOptions::new("index.html", "/"),
8198            &mut writer,
8199        )
8200        .unwrap();
8201        assert_eq!(
8202            writer.get_content(),
8203            "<div><div></div><div><span>B</span></div><div></div></div>"
8204        );
8205    }
8206
8207    // ── Recursive template test ──────────────────────────────────────
8208
8209    #[test]
8210    fn test_recursive_template_refs() {
8211        let mut fragments = HashMap::new();
8212        fragments.insert(
8213            "index.html".to_string(),
8214            FragmentList {
8215                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8216                contains_boundary: false,
8217            },
8218        );
8219        fragments.insert(
8220            "static".to_string(),
8221            FragmentList {
8222                fragments: vec![
8223                    WebUIFragment::raw("<div expanded=\""),
8224                    WebUIFragment::signal("item.expanded", false),
8225                    WebUIFragment::raw("\" class=\""),
8226                    WebUIFragment::signal("testScenario", false),
8227                    WebUIFragment::raw("\"><span>"),
8228                    WebUIFragment::signal("item.name", false),
8229                    WebUIFragment::raw("</span>"),
8230                    WebUIFragment::for_loop("item", "item.children", "static"),
8231                    WebUIFragment::raw("</div>"),
8232                ],
8233                contains_boundary: false,
8234            },
8235        );
8236        let protocol = WebUIProtocol::new(fragments);
8237        let state = test_json!({
8238            "testScenario": "RecursiveTemplatesWithGlobalState",
8239            "items": [
8240                {"name": "A", "expanded": "false", "children": []},
8241                {"name": "B", "expanded": "true", "children": [
8242                    {"name": "C", "expanded": "false"},
8243                    {"name": "D", "expanded": "false"}
8244                ]},
8245                {"name": "E", "expanded": "false"}
8246            ]
8247        });
8248        let mut writer = TestWriter::new();
8249        handle(
8250            &protocol,
8251            &state,
8252            &RenderOptions::new("index.html", "/"),
8253            &mut writer,
8254        )
8255        .unwrap();
8256        assert_eq!(
8257            writer.get_content(),
8258            "<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>"
8259        );
8260    }
8261
8262    // ── Advanced state management tests ──────────────────────────────
8263
8264    #[test]
8265    fn test_component_in_for_no_local_access() {
8266        let mut fragments = HashMap::new();
8267        fragments.insert(
8268            "index.html".to_string(),
8269            FragmentList {
8270                fragments: vec![
8271                    WebUIFragment::raw("<div>"),
8272                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8273                    WebUIFragment::raw("</div>"),
8274                ],
8275                contains_boundary: false,
8276            },
8277        );
8278        fragments.insert(
8279            "templateComponent".to_string(),
8280            FragmentList {
8281                fragments: vec![
8282                    WebUIFragment::raw("<component-tag>"),
8283                    WebUIFragment::component("my-component"),
8284                    WebUIFragment::raw("</component-tag>"),
8285                ],
8286                contains_boundary: false,
8287            },
8288        );
8289        fragments.insert(
8290            "my-component".to_string(),
8291            FragmentList {
8292                fragments: vec![
8293                    WebUIFragment::raw("<span>"),
8294                    WebUIFragment::signal("name", false),
8295                    WebUIFragment::raw("</span>"),
8296                ],
8297                contains_boundary: false,
8298            },
8299        );
8300        let protocol = WebUIProtocol::new(fragments);
8301        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8302        let mut writer = TestWriter::new();
8303        handle(
8304            &protocol,
8305            &state,
8306            &RenderOptions::new("index.html", "/"),
8307            &mut writer,
8308        )
8309        .unwrap();
8310        assert_eq!(
8311            writer.get_content(),
8312            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8313        );
8314    }
8315
8316    #[test]
8317    fn test_nested_for_hierarchical_state() {
8318        let mut fragments = HashMap::new();
8319        fragments.insert(
8320            "index.html".to_string(),
8321            FragmentList {
8322                fragments: vec![
8323                    WebUIFragment::raw("<div>"),
8324                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8325                    WebUIFragment::raw("</div>"),
8326                ],
8327                contains_boundary: false,
8328            },
8329        );
8330        fragments.insert(
8331            "outerTemplate".to_string(),
8332            FragmentList {
8333                fragments: vec![
8334                    WebUIFragment::raw("<section>"),
8335                    WebUIFragment::signal("globalPrefix", false),
8336                    WebUIFragment::signal("outerItem.outerLabel", false),
8337                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8338                    WebUIFragment::raw("</section>"),
8339                ],
8340                contains_boundary: false,
8341            },
8342        );
8343        fragments.insert(
8344            "innerTemplate".to_string(),
8345            FragmentList {
8346                fragments: vec![
8347                    WebUIFragment::raw("<p>"),
8348                    WebUIFragment::signal("globalPrefix", false),
8349                    WebUIFragment::signal("outerItem.outerLabel", false),
8350                    WebUIFragment::raw(": "),
8351                    WebUIFragment::signal("innerItem.innerLabel", false),
8352                    WebUIFragment::raw("</p>"),
8353                ],
8354                contains_boundary: false,
8355            },
8356        );
8357        let protocol = WebUIProtocol::new(fragments);
8358        let state = test_json!({
8359            "globalPrefix": "Prefix: ",
8360            "outerItems": [
8361                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8362                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8363            ]
8364        });
8365        let mut writer = TestWriter::new();
8366        handle(
8367            &protocol,
8368            &state,
8369            &RenderOptions::new("index.html", "/"),
8370            &mut writer,
8371        )
8372        .unwrap();
8373        assert_eq!(
8374            writer.get_content(),
8375            "<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>"
8376        );
8377    }
8378
8379    #[test]
8380    fn test_component_in_for_global_only() {
8381        let mut fragments = HashMap::new();
8382        fragments.insert(
8383            "index.html".to_string(),
8384            FragmentList {
8385                fragments: vec![
8386                    WebUIFragment::raw("<div>"),
8387                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8388                    WebUIFragment::raw("</div>"),
8389                ],
8390                contains_boundary: false,
8391            },
8392        );
8393        fragments.insert(
8394            "templateComponent".to_string(),
8395            FragmentList {
8396                fragments: vec![
8397                    WebUIFragment::raw("<component-tag>"),
8398                    WebUIFragment::component("my-component"),
8399                    WebUIFragment::raw("</component-tag>"),
8400                ],
8401                contains_boundary: false,
8402            },
8403        );
8404        fragments.insert(
8405            "my-component".to_string(),
8406            FragmentList {
8407                fragments: vec![
8408                    WebUIFragment::raw("<span>"),
8409                    WebUIFragment::signal("name", false),
8410                    WebUIFragment::raw("-"),
8411                    WebUIFragment::signal("globalSuffix", false),
8412                    WebUIFragment::raw("</span>"),
8413                ],
8414                contains_boundary: false,
8415            },
8416        );
8417        let protocol = WebUIProtocol::new(fragments);
8418        let state =
8419            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8420        let mut writer = TestWriter::new();
8421        handle(
8422            &protocol,
8423            &state,
8424            &RenderOptions::new("index.html", "/"),
8425            &mut writer,
8426        )
8427        .unwrap();
8428        assert_eq!(
8429            writer.get_content(),
8430            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8431        );
8432    }
8433
8434    #[test]
8435    fn test_component_no_item_moniker() {
8436        let mut fragments = HashMap::new();
8437        fragments.insert(
8438            "index.html".to_string(),
8439            FragmentList {
8440                fragments: vec![
8441                    WebUIFragment::raw("<div>"),
8442                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8443                    WebUIFragment::raw("</div>"),
8444                ],
8445                contains_boundary: false,
8446            },
8447        );
8448        fragments.insert(
8449            "templateComponent".to_string(),
8450            FragmentList {
8451                fragments: vec![
8452                    WebUIFragment::raw("<component-tag>"),
8453                    WebUIFragment::component("my-component"),
8454                    WebUIFragment::raw("</component-tag>"),
8455                ],
8456                contains_boundary: false,
8457            },
8458        );
8459        fragments.insert(
8460            "my-component".to_string(),
8461            FragmentList {
8462                fragments: vec![
8463                    WebUIFragment::raw("<span>"),
8464                    WebUIFragment::signal("item.name", false),
8465                    WebUIFragment::raw("-"),
8466                    WebUIFragment::signal("globalSuffix", false),
8467                    WebUIFragment::raw("</span>"),
8468                ],
8469                contains_boundary: false,
8470            },
8471        );
8472        let protocol = WebUIProtocol::new(fragments);
8473        let state =
8474            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8475        let mut writer = TestWriter::new();
8476        handle(
8477            &protocol,
8478            &state,
8479            &RenderOptions::new("index.html", "/"),
8480            &mut writer,
8481        )
8482        .unwrap();
8483        assert_eq!(
8484            writer.get_content(),
8485            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8486        );
8487    }
8488
8489    #[test]
8490    fn test_for_nonqualified_uses_global() {
8491        let mut fragments = HashMap::new();
8492        fragments.insert(
8493            "index.html".to_string(),
8494            FragmentList {
8495                fragments: vec![
8496                    WebUIFragment::raw("<div>"),
8497                    WebUIFragment::for_loop("item", "items", "template1"),
8498                    WebUIFragment::raw("</div>"),
8499                ],
8500                contains_boundary: false,
8501            },
8502        );
8503        fragments.insert(
8504            "template1".to_string(),
8505            FragmentList {
8506                fragments: vec![
8507                    WebUIFragment::raw("<span>"),
8508                    WebUIFragment::signal("name", false),
8509                    WebUIFragment::raw("</span>"),
8510                ],
8511                contains_boundary: false,
8512            },
8513        );
8514        let protocol = WebUIProtocol::new(fragments);
8515        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8516        let mut writer = TestWriter::new();
8517        handle(
8518            &protocol,
8519            &state,
8520            &RenderOptions::new("index.html", "/"),
8521            &mut writer,
8522        )
8523        .unwrap();
8524        assert_eq!(
8525            writer.get_content(),
8526            "<div><span>GlobalName</span><span>GlobalName</span></div>"
8527        );
8528    }
8529
8530    #[test]
8531    fn test_nested_for_if_interleaved() {
8532        let mut fragments = HashMap::new();
8533        fragments.insert(
8534            "index.html".to_string(),
8535            FragmentList {
8536                fragments: vec![
8537                    WebUIFragment::raw("<div>"),
8538                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8539                    WebUIFragment::raw("</div>"),
8540                ],
8541                contains_boundary: false,
8542            },
8543        );
8544        fragments.insert(
8545            "outerTemplate".to_string(),
8546            FragmentList {
8547                fragments: vec![
8548                    WebUIFragment::raw("<section>"),
8549                    WebUIFragment::signal("globalPrefix", false),
8550                    WebUIFragment::signal("outerItem.outerLabel", false),
8551                    WebUIFragment::if_cond(
8552                        ConditionExpr::identifier("outerItem.include"),
8553                        "ifTemplate",
8554                    ),
8555                    WebUIFragment::raw("</section>"),
8556                ],
8557                contains_boundary: false,
8558            },
8559        );
8560        fragments.insert(
8561            "ifTemplate".to_string(),
8562            FragmentList {
8563                fragments: vec![
8564                    WebUIFragment::raw("<div>"),
8565                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8566                    WebUIFragment::raw("</div>"),
8567                ],
8568                contains_boundary: false,
8569            },
8570        );
8571        fragments.insert(
8572            "innerTemplate".to_string(),
8573            FragmentList {
8574                fragments: vec![
8575                    WebUIFragment::raw("<p>"),
8576                    WebUIFragment::signal("globalSuffix", false),
8577                    WebUIFragment::raw(": "),
8578                    WebUIFragment::signal("innerItem.innerLabel", false),
8579                    WebUIFragment::raw("</p>"),
8580                ],
8581                contains_boundary: false,
8582            },
8583        );
8584        let protocol = WebUIProtocol::new(fragments);
8585        let state = test_json!({
8586            "globalPrefix": "Prefix: ",
8587            "globalSuffix": "Suffix",
8588            "outerItems": [
8589                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8590                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8591                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8592            ]
8593        });
8594        let mut writer = TestWriter::new();
8595        handle(
8596            &protocol,
8597            &state,
8598            &RenderOptions::new("index.html", "/"),
8599            &mut writer,
8600        )
8601        .unwrap();
8602        assert_eq!(
8603            writer.get_content(),
8604            "<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>"
8605        );
8606    }
8607
8608    #[test]
8609    fn test_nested_for_if_outer_state() {
8610        let mut fragments = HashMap::new();
8611        fragments.insert(
8612            "index.html".to_string(),
8613            FragmentList {
8614                fragments: vec![
8615                    WebUIFragment::raw("<div>"),
8616                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8617                    WebUIFragment::raw("</div>"),
8618                ],
8619                contains_boundary: false,
8620            },
8621        );
8622        fragments.insert(
8623            "outerTemplate".to_string(),
8624            FragmentList {
8625                fragments: vec![
8626                    WebUIFragment::raw("<section>"),
8627                    WebUIFragment::signal("globalPrefix", false),
8628                    WebUIFragment::signal("outerItem.label", false),
8629                    WebUIFragment::for_loop(
8630                        "middleItem",
8631                        "outerItem.middleItems",
8632                        "middleTemplate",
8633                    ),
8634                    WebUIFragment::raw("</section>"),
8635                ],
8636                contains_boundary: false,
8637            },
8638        );
8639        fragments.insert(
8640            "middleTemplate".to_string(),
8641            FragmentList {
8642                fragments: vec![
8643                    WebUIFragment::raw("<div>"),
8644                    WebUIFragment::if_cond(
8645                        ConditionExpr::identifier("outerItem.active"),
8646                        "ifTemplate",
8647                    ),
8648                    WebUIFragment::raw("</div>"),
8649                ],
8650                contains_boundary: false,
8651            },
8652        );
8653        fragments.insert(
8654            "ifTemplate".to_string(),
8655            FragmentList {
8656                fragments: vec![
8657                    WebUIFragment::raw("<p>"),
8658                    WebUIFragment::signal("middleItem.value", false),
8659                    WebUIFragment::raw("</p>"),
8660                ],
8661                contains_boundary: false,
8662            },
8663        );
8664        let protocol = WebUIProtocol::new(fragments);
8665        let state = test_json!({
8666            "globalPrefix": "GP-",
8667            "outerItems": [
8668                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8669                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8670                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8671            ]
8672        });
8673        let mut writer = TestWriter::new();
8674        handle(
8675            &protocol,
8676            &state,
8677            &RenderOptions::new("index.html", "/"),
8678            &mut writer,
8679        )
8680        .unwrap();
8681        assert_eq!(
8682            writer.get_content(),
8683            "<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>"
8684        );
8685    }
8686
8687    #[test]
8688    fn test_nested_for_if_inner_state() {
8689        let mut fragments = HashMap::new();
8690        fragments.insert(
8691            "index.html".to_string(),
8692            FragmentList {
8693                fragments: vec![
8694                    WebUIFragment::raw("<div>"),
8695                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8696                    WebUIFragment::raw("</div>"),
8697                ],
8698                contains_boundary: false,
8699            },
8700        );
8701        fragments.insert(
8702            "outerTemplate".to_string(),
8703            FragmentList {
8704                fragments: vec![
8705                    WebUIFragment::raw("<section>"),
8706                    WebUIFragment::signal("outerItem.label", false),
8707                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8708                    WebUIFragment::raw("</section>"),
8709                ],
8710                contains_boundary: false,
8711            },
8712        );
8713        fragments.insert(
8714            "innerTemplate".to_string(),
8715            FragmentList {
8716                fragments: vec![
8717                    WebUIFragment::raw("<article>"),
8718                    WebUIFragment::if_cond(
8719                        ConditionExpr::identifier("innerItem.show"),
8720                        "ifTemplate",
8721                    ),
8722                    WebUIFragment::raw("</article>"),
8723                ],
8724                contains_boundary: false,
8725            },
8726        );
8727        fragments.insert(
8728            "ifTemplate".to_string(),
8729            FragmentList {
8730                fragments: vec![
8731                    WebUIFragment::raw("<p>"),
8732                    WebUIFragment::signal("innerItem.detail", false),
8733                    WebUIFragment::raw("</p>"),
8734                ],
8735                contains_boundary: false,
8736            },
8737        );
8738        let protocol = WebUIProtocol::new(fragments);
8739        let state = test_json!({
8740            "outerItems": [
8741                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8742                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
8743            ]
8744        });
8745        let mut writer = TestWriter::new();
8746        handle(
8747            &protocol,
8748            &state,
8749            &RenderOptions::new("index.html", "/"),
8750            &mut writer,
8751        )
8752        .unwrap();
8753        assert_eq!(
8754            writer.get_content(),
8755            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8756        );
8757    }
8758
8759    #[test]
8760    fn test_for_merge_local_global_monikers() {
8761        let mut fragments = HashMap::new();
8762        fragments.insert(
8763            "index.html".to_string(),
8764            FragmentList {
8765                fragments: vec![
8766                    WebUIFragment::raw("<div>"),
8767                    WebUIFragment::for_loop("item", "items", "template1"),
8768                    WebUIFragment::raw("</div>"),
8769                ],
8770                contains_boundary: false,
8771            },
8772        );
8773        fragments.insert(
8774            "template1".to_string(),
8775            FragmentList {
8776                fragments: vec![
8777                    WebUIFragment::raw("<span>"),
8778                    WebUIFragment::signal("item.name", false),
8779                    WebUIFragment::raw("-"),
8780                    WebUIFragment::signal("item.globalValue", false),
8781                    WebUIFragment::raw("-"),
8782                    WebUIFragment::signal("item.localOnly", false),
8783                    WebUIFragment::raw("-"),
8784                    WebUIFragment::signal("item.otherVal", false),
8785                    WebUIFragment::raw("</span>"),
8786                ],
8787                contains_boundary: false,
8788            },
8789        );
8790        let protocol = WebUIProtocol::new(fragments);
8791        let state = test_json!({
8792            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8793            "items": [
8794                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8795                {"name": "Local2", "localOnly": "Only2"}
8796            ]
8797        });
8798        let mut writer = TestWriter::new();
8799        handle(
8800            &protocol,
8801            &state,
8802            &RenderOptions::new("index.html", "/"),
8803            &mut writer,
8804        )
8805        .unwrap();
8806        assert_eq!(
8807            writer.get_content(),
8808            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8809        );
8810    }
8811
8812    #[test]
8813    fn test_component_in_for_global_moniker_shadow() {
8814        let mut fragments = HashMap::new();
8815        fragments.insert(
8816            "index.html".to_string(),
8817            FragmentList {
8818                fragments: vec![
8819                    WebUIFragment::raw("<div>"),
8820                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8821                    WebUIFragment::raw("</div>"),
8822                ],
8823                contains_boundary: false,
8824            },
8825        );
8826        fragments.insert(
8827            "templateComponent".to_string(),
8828            FragmentList {
8829                fragments: vec![
8830                    WebUIFragment::raw("<component-tag>"),
8831                    WebUIFragment::component("my-component"),
8832                    WebUIFragment::raw("</component-tag>"),
8833                ],
8834                contains_boundary: false,
8835            },
8836        );
8837        fragments.insert(
8838            "my-component".to_string(),
8839            FragmentList {
8840                fragments: vec![
8841                    WebUIFragment::raw("<span>"),
8842                    WebUIFragment::signal("name", false),
8843                    WebUIFragment::raw("-"),
8844                    WebUIFragment::signal("item.globalValue", false),
8845                    WebUIFragment::raw("-"),
8846                    WebUIFragment::signal("localOnly", false),
8847                    WebUIFragment::raw("-"),
8848                    WebUIFragment::signal("item.otherVal", false),
8849                    WebUIFragment::raw("</span>"),
8850                ],
8851                contains_boundary: false,
8852            },
8853        );
8854        let protocol = WebUIProtocol::new(fragments);
8855        let state = test_json!({
8856            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8857            "items": [
8858                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8859                {"name": "Local2", "localOnly": "Only2"}
8860            ]
8861        });
8862        let mut writer = TestWriter::new();
8863        handle(
8864            &protocol,
8865            &state,
8866            &RenderOptions::new("index.html", "/"),
8867            &mut writer,
8868        )
8869        .unwrap();
8870        assert_eq!(
8871            writer.get_content(),
8872            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8873        );
8874    }
8875
8876    #[test]
8877    fn test_if_in_nested_for_local_flag() {
8878        let mut fragments = HashMap::new();
8879        fragments.insert(
8880            "index.html".to_string(),
8881            FragmentList {
8882                fragments: vec![
8883                    WebUIFragment::raw("<div>"),
8884                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8885                    WebUIFragment::raw("</div>"),
8886                ],
8887                contains_boundary: false,
8888            },
8889        );
8890        fragments.insert(
8891            "outerTemplate".to_string(),
8892            FragmentList {
8893                fragments: vec![
8894                    WebUIFragment::raw("<section>"),
8895                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8896                    WebUIFragment::raw("</section>"),
8897                ],
8898                contains_boundary: false,
8899            },
8900        );
8901        fragments.insert(
8902            "innerTemplate".to_string(),
8903            FragmentList {
8904                fragments: vec![WebUIFragment::if_cond(
8905                    ConditionExpr::identifier("inner_item.flag"),
8906                    "ifInner",
8907                )],
8908                contains_boundary: false,
8909            },
8910        );
8911        fragments.insert(
8912            "ifInner".to_string(),
8913            FragmentList {
8914                fragments: vec![
8915                    WebUIFragment::raw("<p>"),
8916                    WebUIFragment::signal("inner_item.value", false),
8917                    WebUIFragment::raw("</p>"),
8918                ],
8919                contains_boundary: false,
8920            },
8921        );
8922        let protocol = WebUIProtocol::new(fragments);
8923        let state = test_json!({
8924            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8925            "inner_item": {"flag": false}
8926        });
8927        let mut writer = TestWriter::new();
8928        handle(
8929            &protocol,
8930            &state,
8931            &RenderOptions::new("index.html", "/"),
8932            &mut writer,
8933        )
8934        .unwrap();
8935        assert_eq!(
8936            writer.get_content(),
8937            "<div><section><p>X</p></section></div>"
8938        );
8939    }
8940
8941    #[test]
8942    fn test_if_in_nested_for_global_fallback() {
8943        let mut fragments = HashMap::new();
8944        fragments.insert(
8945            "index.html".to_string(),
8946            FragmentList {
8947                fragments: vec![
8948                    WebUIFragment::raw("<div>"),
8949                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8950                    WebUIFragment::raw("</div>"),
8951                ],
8952                contains_boundary: false,
8953            },
8954        );
8955        fragments.insert(
8956            "outerTemplate".to_string(),
8957            FragmentList {
8958                fragments: vec![
8959                    WebUIFragment::raw("<section>"),
8960                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8961                    WebUIFragment::raw("</section>"),
8962                ],
8963                contains_boundary: false,
8964            },
8965        );
8966        fragments.insert(
8967            "innerTemplate".to_string(),
8968            FragmentList {
8969                fragments: vec![WebUIFragment::if_cond(
8970                    ConditionExpr::identifier("inner_item.flag"),
8971                    "ifInner",
8972                )],
8973                contains_boundary: false,
8974            },
8975        );
8976        fragments.insert(
8977            "ifInner".to_string(),
8978            FragmentList {
8979                fragments: vec![
8980                    WebUIFragment::raw("<p>"),
8981                    WebUIFragment::signal("inner_item.value", false),
8982                    WebUIFragment::raw("</p>"),
8983                ],
8984                contains_boundary: false,
8985            },
8986        );
8987        let protocol = WebUIProtocol::new(fragments);
8988        let state = test_json!({
8989            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8990            "inner_item": {"flag": true}
8991        });
8992        let mut writer = TestWriter::new();
8993        handle(
8994            &protocol,
8995            &state,
8996            &RenderOptions::new("index.html", "/"),
8997            &mut writer,
8998        )
8999        .unwrap();
9000        assert_eq!(
9001            writer.get_content(),
9002            "<div><section><p>X</p><p>Y</p></section></div>"
9003        );
9004    }
9005
9006    #[test]
9007    fn test_if_mixed_for_monikers() {
9008        let mut fragments = HashMap::new();
9009        fragments.insert(
9010            "index.html".to_string(),
9011            FragmentList {
9012                fragments: vec![
9013                    WebUIFragment::raw("<div>"),
9014                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9015                    WebUIFragment::raw("</div>"),
9016                ],
9017                contains_boundary: false,
9018            },
9019        );
9020        fragments.insert(
9021            "outerTemplate".to_string(),
9022            FragmentList {
9023                fragments: vec![
9024                    WebUIFragment::raw("<section>"),
9025                    WebUIFragment::signal("outer.outerLabel", false),
9026                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9027                    WebUIFragment::raw("</section>"),
9028                ],
9029                contains_boundary: false,
9030            },
9031        );
9032        fragments.insert(
9033            "innerTemplate".to_string(),
9034            FragmentList {
9035                fragments: vec![WebUIFragment::if_cond(
9036                    ConditionExpr::compound(
9037                        ConditionExpr::identifier("outer.active"),
9038                        LogicalOperator::And,
9039                        ConditionExpr::predicate(
9040                            "inner.value",
9041                            ComparisonOperator::GreaterThan,
9042                            "globalLimit",
9043                        ),
9044                    ),
9045                    "ifInner",
9046                )],
9047                contains_boundary: false,
9048            },
9049        );
9050        fragments.insert(
9051            "ifInner".to_string(),
9052            FragmentList {
9053                fragments: vec![
9054                    WebUIFragment::raw("<p>"),
9055                    WebUIFragment::signal("inner.value", false),
9056                    WebUIFragment::raw("</p>"),
9057                ],
9058                contains_boundary: false,
9059            },
9060        );
9061        let protocol = WebUIProtocol::new(fragments);
9062        let state = test_json!({
9063            "globalLimit": 10,
9064            "list": {"outerItems": [
9065                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9066                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9067                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9068            ]}
9069        });
9070        let mut writer = TestWriter::new();
9071        handle(
9072            &protocol,
9073            &state,
9074            &RenderOptions::new("index.html", "/"),
9075            &mut writer,
9076        )
9077        .unwrap();
9078        assert_eq!(
9079            writer.get_content(),
9080            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9081        );
9082    }
9083
9084    // ── Route-aware rendering tests ─────────────────────────────────────
9085
9086    fn make_route_protocol() -> WebUIProtocol {
9087        use webui_protocol::WebUiFragmentRoute;
9088
9089        let mut fragments = HashMap::new();
9090
9091        // Entry page with two routes
9092        fragments.insert(
9093            "index.html".to_string(),
9094            FragmentList {
9095                fragments: vec![
9096                    WebUIFragment::raw("<h1>Shell</h1>"),
9097                    WebUIFragment::route_from(WebUiFragmentRoute {
9098                        path: "/".into(),
9099                        fragment_id: "dash-page".into(),
9100                        exact: true,
9101                        keep_alive: false,
9102                        ..Default::default()
9103                    }),
9104                    WebUIFragment::route_from(WebUiFragmentRoute {
9105                        path: "/contacts/:id".into(),
9106                        fragment_id: "detail-page".into(),
9107                        exact: true,
9108                        keep_alive: true,
9109                        ..Default::default()
9110                    }),
9111                ],
9112
9113                contains_boundary: false,
9114            },
9115        );
9116
9117        // Dashboard page component
9118        fragments.insert(
9119            "dash-page".to_string(),
9120            FragmentList {
9121                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9122                contains_boundary: false,
9123            },
9124        );
9125
9126        // Detail page component
9127        fragments.insert(
9128            "detail-page".to_string(),
9129            FragmentList {
9130                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9131                contains_boundary: false,
9132            },
9133        );
9134
9135        WebUIProtocol::new(fragments)
9136    }
9137
9138    fn make_nested_route_protocol() -> WebUIProtocol {
9139        use webui_protocol::WebUiFragmentRoute;
9140
9141        let mut fragments = HashMap::new();
9142
9143        fragments.insert(
9144            "index.html".to_string(),
9145            FragmentList {
9146                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9147                    path: "/".into(),
9148                    fragment_id: "app-shell".into(),
9149                    exact: false,
9150                    children: vec![WebUiFragmentRoute {
9151                        path: "sections/:id".into(),
9152                        fragment_id: "section-comp".into(),
9153                        exact: false,
9154                        children: vec![WebUiFragmentRoute {
9155                            path: "topics/:topicId".into(),
9156                            fragment_id: "topic-comp".into(),
9157                            exact: true,
9158                            children: vec![],
9159                            keep_alive: false,
9160                            ..Default::default()
9161                        }],
9162                        keep_alive: false,
9163                        ..Default::default()
9164                    }],
9165                    keep_alive: false,
9166                    ..Default::default()
9167                })],
9168
9169                contains_boundary: false,
9170            },
9171        );
9172
9173        fragments.insert(
9174            "app-shell".to_string(),
9175            FragmentList {
9176                fragments: vec![
9177                    WebUIFragment::raw("<h1>Shell</h1>"),
9178                    WebUIFragment::outlet(),
9179                ],
9180                contains_boundary: false,
9181            },
9182        );
9183
9184        fragments.insert(
9185            "section-comp".to_string(),
9186            FragmentList {
9187                fragments: vec![
9188                    WebUIFragment::raw("<h2>Section</h2>"),
9189                    WebUIFragment::outlet(),
9190                ],
9191                contains_boundary: false,
9192            },
9193        );
9194
9195        fragments.insert(
9196            "topic-comp".to_string(),
9197            FragmentList {
9198                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9199                contains_boundary: false,
9200            },
9201        );
9202
9203        WebUIProtocol::new(fragments)
9204    }
9205
9206    #[test]
9207    fn test_route_renders_shell_always() {
9208        let protocol = make_route_protocol();
9209        let state = test_json!({});
9210        let mut writer = TestWriter::new();
9211        handle(
9212            &protocol,
9213            &state,
9214            &RenderOptions::new("index.html", "/"),
9215            &mut writer,
9216        )
9217        .unwrap();
9218        let html = writer.get_content();
9219
9220        // Shell content always renders regardless of route matching
9221        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9222        // Dashboard matches "/" so it should be active
9223        assert!(html.contains(" active>"), "matched route should be active");
9224        // Detail should be hidden and empty
9225        assert!(
9226            html.contains("style=\"display:none\""),
9227            "non-matched routes should be hidden"
9228        );
9229    }
9230
9231    #[test]
9232    fn test_route_matched_renders_visible() {
9233        let protocol = make_route_protocol();
9234        let state = test_json!({});
9235        let mut writer = TestWriter::new();
9236        handle(
9237            &protocol,
9238            &state,
9239            &RenderOptions::new("index.html", "/"),
9240            &mut writer,
9241        )
9242        .unwrap();
9243        let html = writer.get_content();
9244
9245        // Dashboard route should be visible (active, no display:none)
9246        assert!(
9247            html.contains("<webui-route path=\"/\""),
9248            "dashboard route should exist"
9249        );
9250        assert!(
9251            html.contains("active>") && html.contains("<dash-page"),
9252            "matched route should be active with component tag: {html}"
9253        );
9254        assert!(
9255            html.contains("<p>Dashboard</p>"),
9256            "matched route should have content"
9257        );
9258    }
9259
9260    #[test]
9261    fn test_route_non_matched_renders_hidden_empty() {
9262        let protocol = make_route_protocol();
9263        let state = test_json!({});
9264        let mut writer = TestWriter::new();
9265        handle(
9266            &protocol,
9267            &state,
9268            &RenderOptions::new("index.html", "/"),
9269            &mut writer,
9270        )
9271        .unwrap();
9272        let html = writer.get_content();
9273
9274        // Detail route should be hidden and empty (no content rendered)
9275        assert!(
9276            html.contains("<webui-route path=\"/contacts/:id\""),
9277            "detail route element should exist"
9278        );
9279        // The non-matched route should have display:none and no inner content
9280        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9281        let after_detail = &html[detail_start..];
9282        assert!(
9283            after_detail.contains("style=\"display:none\">")
9284                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9285            "non-matched route should be hidden: {after_detail}"
9286        );
9287        // Should NOT contain the component's rendered content
9288        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9289        let detail_body = &after_detail[..detail_end];
9290        assert!(
9291            !detail_body.contains("<detail-page"),
9292            "non-matched route should not render component content: {detail_body}"
9293        );
9294    }
9295
9296    #[test]
9297    fn test_route_parameterized_match() {
9298        let protocol = make_route_protocol();
9299        let state = test_json!({});
9300        let mut writer = TestWriter::new();
9301        handle(
9302            &protocol,
9303            &state,
9304            &RenderOptions::new("index.html", "/contacts/42"),
9305            &mut writer,
9306        )
9307        .unwrap();
9308        let html = writer.get_content();
9309
9310        // Detail route matches /contacts/42
9311        assert!(
9312            html.contains("active>") && html.contains("<detail-page"),
9313            "detail route should be active: {html}"
9314        );
9315        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9316        // Dashboard should be hidden + empty
9317        let dash_start = html
9318            .find("component=\"dash-page\"")
9319            .expect("dashboard route");
9320        let after_dash = &html[dash_start..];
9321        assert!(
9322            after_dash.contains("style=\"display:none\">"),
9323            "dashboard should be hidden when detail matches: {after_dash}"
9324        );
9325        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9326        let dash_body = &after_dash[..dash_end];
9327        assert!(
9328            !dash_body.contains("<dash-page"),
9329            "dashboard should not render component content: {dash_body}"
9330        );
9331    }
9332
9333    #[test]
9334    fn test_route_no_match_all_hidden_empty() {
9335        let protocol = make_route_protocol();
9336        let state = test_json!({});
9337        let mut writer = TestWriter::new();
9338        handle(
9339            &protocol,
9340            &state,
9341            &RenderOptions::new("index.html", "/nonexistent"),
9342            &mut writer,
9343        )
9344        .unwrap();
9345        let html = writer.get_content();
9346
9347        // Shell content should still render
9348        assert!(html.contains("<h1>Shell</h1>"));
9349        // All routes should be hidden + empty (nothing matched)
9350        assert!(
9351            !html.contains("<p>Dashboard</p>"),
9352            "no route content when nothing matches"
9353        );
9354        assert!(
9355            !html.contains("<p>Detail</p>"),
9356            "no route content when nothing matches"
9357        );
9358    }
9359
9360    #[test]
9361    fn test_route_component_attr_emitted() {
9362        let protocol = make_route_protocol();
9363        let state = test_json!({});
9364        let mut writer = TestWriter::new();
9365        handle(
9366            &protocol,
9367            &state,
9368            &RenderOptions::new("index.html", "/"),
9369            &mut writer,
9370        )
9371        .unwrap();
9372        let html = writer.get_content();
9373        // component attribute should be emitted on webui-route
9374        assert!(
9375            html.contains("component=\"dash-page\""),
9376            "component attr should be on webui-route: {html}"
9377        );
9378        assert!(
9379            html.contains("component=\"detail-page\""),
9380            "component attr should be on webui-route: {html}"
9381        );
9382        assert!(
9383            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9384            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9385        );
9386    }
9387
9388    #[test]
9389    fn test_no_plugin_no_state_attributes() {
9390        let protocol = make_route_protocol();
9391        let state = test_json!({
9392            "title": "Fish & Chips",
9393            "cartOpen": true,
9394            "items": [{"name": "A&B"}]
9395        });
9396        let mut writer = TestWriter::new();
9397
9398        handle(
9399            &protocol,
9400            &state,
9401            &RenderOptions::new("index.html", "/"),
9402            &mut writer,
9403        )
9404        .unwrap();
9405
9406        let html = writer.get_content();
9407        // Without a plugin, no state attributes at all
9408        assert!(
9409            !html.contains("data-state"),
9410            "no data-state without plugin: {html}"
9411        );
9412        assert!(
9413            !html.contains(r#"title="Fish"#),
9414            "no scalar attrs without plugin: {html}"
9415        );
9416    }
9417
9418    #[test]
9419    fn test_nested_routes_render_webui_route_as_light_dom() {
9420        let protocol = make_nested_route_protocol();
9421        let state = test_json!({"title": "Test"});
9422        let handler = WebUIHandler::new();
9423        let mut writer = TestWriter::new();
9424
9425        handler
9426            .handle(
9427                &protocol,
9428                &state,
9429                &RenderOptions::new("index.html", "/sections/frontend"),
9430                &mut writer,
9431            )
9432            .expect("render failed");
9433
9434        let html = writer.get_content();
9435
9436        assert!(
9437            html.contains("component=\"app-shell\"") && html.contains("active>"),
9438            "root route should be active: {html}"
9439        );
9440        // webui-route should NOT have shadow DOM — it's a light DOM structural element
9441        assert!(
9442            !html.contains("<template shadowrootmode"),
9443            "webui-route should be light DOM (no shadow template): {html}"
9444        );
9445    }
9446
9447    #[test]
9448    fn test_nested_routes_render_outlet_as_light_dom() {
9449        let protocol = make_nested_route_protocol();
9450        let state = test_json!({"title": "Test"});
9451        let handler = WebUIHandler::new();
9452        let mut writer = TestWriter::new();
9453
9454        handler
9455            .handle(
9456                &protocol,
9457                &state,
9458                &RenderOptions::new("index.html", "/sections/frontend"),
9459                &mut writer,
9460            )
9461            .expect("render failed");
9462
9463        let html = writer.get_content();
9464
9465        // No <webui-outlet> wrapper — routes render directly at outlet position
9466        assert!(
9467            !html.contains("<webui-outlet>"),
9468            "should not contain webui-outlet wrapper: {html}"
9469        );
9470        // Route elements should be in the output directly
9471        assert!(
9472            html.contains("<webui-route"),
9473            "should contain webui-route elements: {html}"
9474        );
9475    }
9476
9477    #[test]
9478    fn test_nested_routes_match_child_at_outlet() {
9479        let protocol = make_nested_route_protocol();
9480        let state = test_json!({"title": "Test"});
9481        let handler = WebUIHandler::new();
9482        let mut writer = TestWriter::new();
9483
9484        handler
9485            .handle(
9486                &protocol,
9487                &state,
9488                &RenderOptions::new("index.html", "/sections/frontend"),
9489                &mut writer,
9490            )
9491            .expect("render failed");
9492
9493        let html = writer.get_content();
9494
9495        assert!(
9496            html.contains("component=\"section-comp\"") && html.contains("active>"),
9497            "section route should be active: {html}"
9498        );
9499        assert!(
9500            html.contains("<h2>Section</h2>"),
9501            "section content should be present: {html}"
9502        );
9503    }
9504
9505    #[test]
9506    fn test_nested_routes_three_levels_deep() {
9507        let protocol = make_nested_route_protocol();
9508        let state = test_json!({"title": "Test"});
9509        let handler = WebUIHandler::new();
9510        let mut writer = TestWriter::new();
9511
9512        handler
9513            .handle(
9514                &protocol,
9515                &state,
9516                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9517                &mut writer,
9518            )
9519            .expect("render failed");
9520
9521        let html = writer.get_content();
9522
9523        assert!(
9524            html.contains("component=\"app-shell\"") && html.contains("active>"),
9525            "root active: {html}"
9526        );
9527        assert!(
9528            html.contains("component=\"section-comp\"") && html.contains("active>"),
9529            "section active: {html}"
9530        );
9531        assert!(
9532            html.contains("component=\"topic-comp\"")
9533                && html.contains("exact")
9534                && html.contains("active>"),
9535            "topic active: {html}"
9536        );
9537        assert!(
9538            html.contains("<p>Topic content</p>"),
9539            "leaf content present: {html}"
9540        );
9541    }
9542
9543    #[test]
9544    fn test_nested_routes_nonmatched_siblings_hidden() {
9545        let protocol = make_nested_route_protocol();
9546        let state = test_json!({"title": "Test"});
9547        let handler = WebUIHandler::new();
9548        let mut writer = TestWriter::new();
9549
9550        handler
9551            .handle(
9552                &protocol,
9553                &state,
9554                &RenderOptions::new("index.html", "/sections/frontend"),
9555                &mut writer,
9556            )
9557            .expect("render failed");
9558
9559        let html = writer.get_content();
9560
9561        assert!(
9562            html.contains(r#"component="topic-comp" exact style="display:none">"#),
9563            "topic should be hidden: {html}"
9564        );
9565    }
9566
9567    #[test]
9568    fn test_nested_routes_root_only() {
9569        let protocol = make_nested_route_protocol();
9570        let state = test_json!({"title": "Test"});
9571        let handler = WebUIHandler::new();
9572        let mut writer = TestWriter::new();
9573
9574        handler
9575            .handle(
9576                &protocol,
9577                &state,
9578                &RenderOptions::new("index.html", "/"),
9579                &mut writer,
9580            )
9581            .expect("render failed");
9582
9583        let html = writer.get_content();
9584
9585        assert!(
9586            html.contains("component=\"app-shell\"") && html.contains("active>"),
9587            "root active at /: {html}"
9588        );
9589        assert!(
9590            html.contains("<h1>Shell</h1>"),
9591            "shell renders at /: {html}"
9592        );
9593        assert!(
9594            html.contains(r#"component="section-comp" style="display:none">"#),
9595            "section hidden at /: {html}"
9596        );
9597    }
9598
9599    // ── CSS Module dedup tests ───────────────────────────────────────
9600
9601    #[test]
9602    fn test_css_module_emitted_once_inline_in_component() {
9603        // CSS module definition emitted once in the component's light DOM
9604        // on first render, not in <head> and not on second instance.
9605        let template = r#"<p><slot></slot></p>"#;
9606
9607        let mut fragments = HashMap::new();
9608        fragments.insert(
9609            "index.html".to_string(),
9610            FragmentList {
9611                fragments: vec![
9612                    WebUIFragment::raw("<html><head>".to_string()),
9613                    structural_fragment("head_end"),
9614                    WebUIFragment::raw("</head><body><div>".to_string()),
9615                    WebUIFragment::component("my-card"),
9616                    WebUIFragment::raw("A".to_string()),
9617                    WebUIFragment::component("my-card"),
9618                    WebUIFragment::raw("B</div>".to_string()),
9619                    structural_fragment("body_end"),
9620                    WebUIFragment::raw("</body></html>".to_string()),
9621                ],
9622                contains_boundary: false,
9623            },
9624        );
9625        fragments.insert(
9626            "my-card".to_string(),
9627            FragmentList {
9628                fragments: vec![WebUIFragment::raw(template.to_string())],
9629                contains_boundary: false,
9630            },
9631        );
9632
9633        let mut protocol = WebUIProtocol::new(fragments);
9634        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9635        protocol
9636            .components
9637            .entry("my-card".to_string())
9638            .or_default()
9639            .css = "p{color:red}".to_string();
9640        protocol.populate_style_closures(&["index.html"]);
9641        let state = test_json!({});
9642        let mut writer = TestWriter::new();
9643
9644        handle(
9645            &protocol,
9646            &state,
9647            &RenderOptions::new("index.html", "/"),
9648            &mut writer,
9649        )
9650        .unwrap();
9651
9652        let html = writer.get_content();
9653
9654        // CSS module importmap should appear exactly once
9655        let count = html.matches(r#"<script type="importmap""#).count();
9656        assert_eq!(
9657            count, 1,
9658            "CSS module importmap should be emitted once, got {count} in: {html}"
9659        );
9660        assert!(
9661            html.contains(r#""my-card":"data:text/css,"#),
9662            "Importmap must register my-card under a data: URI: {html}"
9663        );
9664
9665        // Template content should appear twice (once per component instance)
9666        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9667        assert_eq!(
9668            tmpl_count, 2,
9669            "Template should render twice, got {tmpl_count} in: {html}"
9670        );
9671
9672        // CSS module should be in <body> (inline), not in <head>
9673        let css_pos = html
9674            .find(r#"<script type="importmap""#)
9675            .expect("CSS module importmap missing");
9676        let body_pos = html.find("<body>").expect("<body> missing");
9677        assert!(
9678            css_pos > body_pos,
9679            "CSS module should be inline in component, not in <head>: {html}"
9680        );
9681    }
9682
9683    #[test]
9684    fn test_component_without_css_renders_normally() {
9685        // Components without CSS module prefix pass through unchanged
9686        let mut fragments = HashMap::new();
9687        fragments.insert(
9688            "index.html".to_string(),
9689            FragmentList {
9690                fragments: vec![WebUIFragment::component("my-card")],
9691                contains_boundary: false,
9692            },
9693        );
9694        fragments.insert(
9695            "my-card".to_string(),
9696            FragmentList {
9697                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9698                contains_boundary: false,
9699            },
9700        );
9701
9702        let protocol = WebUIProtocol::new(fragments);
9703        let state = test_json!({});
9704        let mut writer = TestWriter::new();
9705
9706        handle(
9707            &protocol,
9708            &state,
9709            &RenderOptions::new("index.html", "/"),
9710            &mut writer,
9711        )
9712        .unwrap();
9713
9714        let html = writer.get_content();
9715        assert!(
9716            html.contains("<p>hello</p>"),
9717            "Non-module component should render normally: {html}"
9718        );
9719    }
9720
9721    #[test]
9722    fn test_non_module_strategy_no_css_in_head() {
9723        // When component_css is empty (Link/Style strategies), no
9724        // CSS module importmap tags should appear in <head>.
9725        let template = r#"<p>hello</p>"#;
9726
9727        let mut fragments = HashMap::new();
9728        fragments.insert(
9729            "index.html".to_string(),
9730            FragmentList {
9731                fragments: vec![
9732                    WebUIFragment::raw("<html><head>".to_string()),
9733                    structural_fragment("head_end"),
9734                    WebUIFragment::raw("</head><body>".to_string()),
9735                    WebUIFragment::component("my-card"),
9736                    WebUIFragment::raw("</body></html>".to_string()),
9737                ],
9738                contains_boundary: false,
9739            },
9740        );
9741        fragments.insert(
9742            "my-card".to_string(),
9743            FragmentList {
9744                fragments: vec![WebUIFragment::raw(template.to_string())],
9745                contains_boundary: false,
9746            },
9747        );
9748
9749        // No component css populated — simulates Link/Style strategy
9750        let protocol = WebUIProtocol::new(fragments);
9751        let state = test_json!({});
9752        let mut writer = TestWriter::new();
9753
9754        handle(
9755            &protocol,
9756            &state,
9757            &RenderOptions::new("index.html", "/"),
9758            &mut writer,
9759        )
9760        .unwrap();
9761
9762        let html = writer.get_content();
9763
9764        assert!(
9765            !html.contains(r#"<style type="module""#),
9766            "Non-module strategy should not emit CSS module tags in <head>: {html}"
9767        );
9768        assert!(
9769            !html.contains(r#"<script type="importmap""#),
9770            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9771        );
9772        assert!(
9773            html.contains("<p>hello</p>"),
9774            "Component should still render: {html}"
9775        );
9776    }
9777
9778    #[test]
9779    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9780        let mut fragments = HashMap::new();
9781        fragments.insert(
9782            "index.html".to_string(),
9783            FragmentList {
9784                fragments: vec![
9785                    WebUIFragment::raw("<html><head>".to_string()),
9786                    structural_fragment("head_end"),
9787                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9788                    WebUIFragment::component("my-card"),
9789                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9790                ],
9791                contains_boundary: false,
9792            },
9793        );
9794        fragments.insert(
9795            "my-card".to_string(),
9796            FragmentList {
9797                fragments: vec![WebUIFragment::raw(
9798                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9799                        .to_string(),
9800                )],
9801                        contains_boundary: false,
9802},
9803        );
9804
9805        let protocol = WebUIProtocol::new(fragments);
9806        let state = test_json!({});
9807        let mut writer = TestWriter::new();
9808
9809        handle(
9810            &protocol,
9811            &state,
9812            &RenderOptions::new("index.html", "/"),
9813            &mut writer,
9814        )
9815        .unwrap();
9816
9817        let html = writer.get_content();
9818
9819        assert!(
9820            html.contains("<style>.card{color:red}</style>"),
9821            "Style strategy should embed inline CSS in shadow template: {html}"
9822        );
9823        assert!(
9824            !html.contains(r#"<style type="module""#),
9825            "Style strategy should not emit legacy module CSS in <head>: {html}"
9826        );
9827        assert!(
9828            !html.contains(r#"<script type="importmap""#),
9829            "Style strategy should not emit CSS module importmaps in <head>: {html}"
9830        );
9831    }
9832
9833    #[test]
9834    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9835        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
9836        // in <head>. No preload tag — the stylesheet itself fetches.
9837        let mut fragments = HashMap::new();
9838        fragments.insert(
9839            "index.html".to_string(),
9840            FragmentList {
9841                fragments: vec![
9842                    WebUIFragment::raw("<html><head>".to_string()),
9843                    structural_fragment("head_end"),
9844                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9845                    WebUIFragment::component("my-card"),
9846                    WebUIFragment::raw("</my-card>".to_string()),
9847                    structural_fragment("body_end"),
9848                    WebUIFragment::raw("</body></html>".to_string()),
9849                ],
9850                contains_boundary: false,
9851            },
9852        );
9853        fragments.insert(
9854            "my-card".to_string(),
9855            FragmentList {
9856                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9857                contains_boundary: false,
9858            },
9859        );
9860
9861        let mut protocol = WebUIProtocol::new(fragments);
9862        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9863
9864        let comp = protocol
9865            .components
9866            .entry("my-card".to_string())
9867            .or_default();
9868        comp.css_href = "my-card.css".to_string();
9869        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9870        protocol.populate_style_closures(&["index.html"]);
9871
9872        let state = test_json!({});
9873        let mut writer = TestWriter::new();
9874
9875        handle(
9876            &protocol,
9877            &state,
9878            &RenderOptions::new("index.html", "/"),
9879            &mut writer,
9880        )
9881        .unwrap();
9882
9883        let html = writer.get_content();
9884
9885        let head_end = html.find("</head>").expect("</head> missing");
9886        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9887        assert!(
9888            link_pos.is_some_and(|p| p < head_end),
9889            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9890        );
9891        assert!(
9892            !html.contains(r#"<link rel="preload""#),
9893            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9894        );
9895        assert!(
9896            !html.contains(r#"<style type="module""#),
9897            "Link strategy should not emit legacy module CSS: {html}"
9898        );
9899        assert!(
9900            !html.contains(r#"<script type="importmap""#),
9901            "Link strategy should not emit CSS module importmaps: {html}"
9902        );
9903    }
9904
9905    #[test]
9906    fn link_strategy_preloads_static_shadow_css_in_head() {
9907        let mut fragments = HashMap::new();
9908        fragments.insert(
9909            "index.html".to_string(),
9910            FragmentList {
9911                fragments: vec![
9912                    WebUIFragment::raw("<html><head>".to_string()),
9913                    structural_fragment("head_end"),
9914                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9915                    WebUIFragment::component("my-card"),
9916                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9917                ],
9918                contains_boundary: false,
9919            },
9920        );
9921        fragments.insert(
9922            "o-loading-state".to_string(),
9923            FragmentList {
9924                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9925                contains_boundary: false,
9926            },
9927        );
9928        fragments.insert(
9929            "my-card".to_string(),
9930            FragmentList {
9931                fragments: vec![
9932                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9933                    structural_fragment("shadow_styles:my-card"),
9934                    WebUIFragment::raw("<div>card</div></template>".to_string()),
9935                ],
9936                contains_boundary: false,
9937            },
9938        );
9939
9940        let mut protocol = WebUIProtocol::new(fragments);
9941        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9942        let component = protocol
9943            .components
9944            .entry("my-card".to_string())
9945            .or_default();
9946        component.css_href = "my-card.css".to_string();
9947        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9948        component.uses_shadow_dom = true;
9949        protocol.populate_style_closures(&["index.html"]);
9950
9951        let mut writer = TestWriter::new();
9952        handle(
9953            &protocol,
9954            &test_json!({}),
9955            &RenderOptions::new("index.html", "/"),
9956            &mut writer,
9957        )
9958        .unwrap();
9959
9960        let html = writer.get_content();
9961        let head_end = html.find("</head>").expect("</head> missing");
9962        let preload = html
9963            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9964            .expect("Shadow preload missing");
9965        let stylesheet = html
9966            .find(r#"<link rel="stylesheet" href="my-card.css""#)
9967            .expect("Shadow stylesheet missing");
9968        assert!(preload < head_end);
9969        assert!(stylesheet > head_end);
9970        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9971    }
9972
9973    #[test]
9974    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9975        let fragments = HashMap::from([
9976            (
9977                "index.html".to_string(),
9978                FragmentList {
9979                    fragments: vec![
9980                        WebUIFragment::raw("<html><head>".to_string()),
9981                        structural_fragment("head_end"),
9982                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
9983                        WebUIFragment::component("outer-box"),
9984                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
9985                    ],
9986                    contains_boundary: false,
9987                },
9988            ),
9989            (
9990                "outer-box".to_string(),
9991                FragmentList {
9992                    fragments: vec![
9993                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9994                        structural_fragment("shadow_styles:outer-box"),
9995                        WebUIFragment::raw("<light-card>".to_string()),
9996                        WebUIFragment::component("light-card"),
9997                        WebUIFragment::raw("</light-card><light-card>".to_string()),
9998                        WebUIFragment::component("light-card"),
9999                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
10000                        WebUIFragment::component("inner-box"),
10001                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
10002                        WebUIFragment::component("light-card"),
10003                        WebUIFragment::raw("</light-card></template>".to_string()),
10004                    ],
10005                    contains_boundary: false,
10006                },
10007            ),
10008            (
10009                "inner-box".to_string(),
10010                FragmentList {
10011                    fragments: vec![
10012                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10013                        structural_fragment("shadow_styles:inner-box"),
10014                        WebUIFragment::raw("<light-card>".to_string()),
10015                        WebUIFragment::component("light-card"),
10016                        WebUIFragment::raw("</light-card></template>".to_string()),
10017                    ],
10018                    contains_boundary: false,
10019                },
10020            ),
10021            (
10022                "light-card".to_string(),
10023                FragmentList {
10024                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10025                    contains_boundary: false,
10026                },
10027            ),
10028        ]);
10029        let mut protocol = WebUIProtocol::new(fragments);
10030        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10031        for (tag, css, uses_shadow_dom) in [
10032            ("outer-box", ".outer{}", true),
10033            ("inner-box", ".inner{}", true),
10034            ("light-card", ".card{}", false),
10035        ] {
10036            let component = protocol.components.entry(tag.to_string()).or_default();
10037            component.css = css.to_string();
10038            component.uses_shadow_dom = uses_shadow_dom;
10039        }
10040        protocol.populate_style_closures(&["index.html"]);
10041
10042        let mut writer = TestWriter::new();
10043        handle(
10044            &protocol,
10045            &test_json!({}),
10046            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10047            &mut writer,
10048        )
10049        .unwrap();
10050        let html = writer.get_content();
10051
10052        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10053        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10054        assert_eq!(
10055            html.matches("data-webui-resource=\"light-card\"").count(),
10056            2,
10057            "the Light resource installs once in each Shadow tree: {html}"
10058        );
10059        assert_eq!(
10060            html.matches("data-webui-resource=").count(),
10061            4,
10062            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10063        );
10064        assert_eq!(
10065            html.matches("nonce=\"css-nonce\"").count(),
10066            4,
10067            "each inline style resource carries the nonce"
10068        );
10069        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10070        let inner_end = html.find("</inner-box>").expect("inner host close");
10071        let caller_continuation = &html[inner_end..];
10072        let trailing_card = caller_continuation
10073            .find("<p>card</p>")
10074            .expect("caller continues after nested Shadow root");
10075        assert!(trailing_card > 0);
10076        assert!(
10077            !caller_continuation.contains("data-webui-resource=\"light-card\""),
10078            "returning from the nested Shadow root must restore the outer frame's delivered set"
10079        );
10080    }
10081
10082    #[test]
10083    fn routed_document_styles_are_hoisted_for_the_active_chain() {
10084        let route = WebUiFragmentRoute {
10085            path: "/".to_string(),
10086            fragment_id: "app-shell".to_string(),
10087            children: vec![
10088                WebUiFragmentRoute {
10089                    path: String::new(),
10090                    fragment_id: "dashboard-page".to_string(),
10091                    exact: true,
10092                    ..Default::default()
10093                },
10094                WebUiFragmentRoute {
10095                    path: "details".to_string(),
10096                    fragment_id: "detail-page".to_string(),
10097                    exact: true,
10098                    ..Default::default()
10099                },
10100            ],
10101            ..Default::default()
10102        };
10103        let fragments = HashMap::from([
10104            (
10105                "index.html".to_string(),
10106                FragmentList {
10107                    fragments: vec![
10108                        WebUIFragment::raw("<html><head>"),
10109                        structural_fragment("head_start"),
10110                        structural_fragment("head_end"),
10111                        WebUIFragment::raw("</head><body>"),
10112                        structural_fragment("body_start"),
10113                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10114                        WebUIFragment::route_from(route),
10115                        WebUIFragment::boundary_end(0),
10116                        structural_fragment("body_end"),
10117                        WebUIFragment::raw("</body></html>"),
10118                    ],
10119                    contains_boundary: true,
10120                },
10121            ),
10122            (
10123                "app-shell".to_string(),
10124                FragmentList {
10125                    fragments: vec![
10126                        WebUIFragment::raw("<header>App</header>"),
10127                        WebUIFragment::outlet(),
10128                    ],
10129                    contains_boundary: false,
10130                },
10131            ),
10132            (
10133                "dashboard-page".to_string(),
10134                FragmentList {
10135                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10136                    contains_boundary: false,
10137                },
10138            ),
10139            (
10140                "detail-page".to_string(),
10141                FragmentList {
10142                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10143                    contains_boundary: false,
10144                },
10145            ),
10146        ]);
10147        let mut protocol = WebUIProtocol::new(fragments);
10148        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10149        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10150            let component = protocol.components.entry(tag.to_string()).or_default();
10151            component.css_href = format!("/{tag}.css");
10152            component.template_json = r#"{"h":""}"#.to_string();
10153        }
10154        protocol.populate_style_closures(&["index.html"]);
10155
10156        let mut writer = TestWriter::new();
10157        handle(
10158            &protocol,
10159            &test_json!({}),
10160            &RenderOptions::new("index.html", "/"),
10161            &mut writer,
10162        )
10163        .unwrap();
10164        let html = writer.get_content();
10165
10166        assert_eq!(
10167            html.matches(r#"data-webui-resource="app-shell""#).count(),
10168            1,
10169            "the matched route shell must install once: {html}"
10170        );
10171        assert_eq!(
10172            html.matches(r#"data-webui-resource="dashboard-page""#)
10173                .count(),
10174            1,
10175            "the matched child route must install once: {html}"
10176        );
10177        assert!(
10178            !html.contains(r#"data-webui-resource="detail-page""#),
10179            "inactive route CSS must not be delivered: {html}"
10180        );
10181        let head_end = html.find("</head>").expect("head close");
10182        let head = &html[..head_end];
10183        for (resource, href) in [
10184            ("app-shell", "/app-shell.css"),
10185            ("dashboard-page", "/dashboard-page.css"),
10186        ] {
10187            let encoded_href = crate::html_encode::encode_safe(href);
10188            assert!(
10189                head.contains(&format!(
10190                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10191                )),
10192                "Document-targeted route CSS must be render-blocking in head: {html}"
10193            );
10194            assert!(
10195                !head.contains(&format!(
10196                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10197                )),
10198                "a hoisted stylesheet must not also be preloaded: {html}"
10199            );
10200        }
10201        assert!(
10202            !head.contains("detail-page.css"),
10203            "inactive route CSS must not be preloaded: {html}"
10204        );
10205        let app_style = html
10206            .find(r#"data-webui-resource="app-shell""#)
10207            .expect("app style");
10208        let dashboard_style = html
10209            .find(r#"data-webui-resource="dashboard-page""#)
10210            .expect("dashboard style");
10211        let app_host = html.find("<app-shell").expect("app route host");
10212        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10213        assert!(
10214            app_style < dashboard_style
10215                && dashboard_style < head_end
10216                && head_end < app_host
10217                && app_host < dashboard_host,
10218            "matched Document route closures must be hoisted in chain order: {html}"
10219        );
10220
10221        let protocol = Protocol::new(protocol);
10222        let mut streamed = FlushTestWriter::default();
10223        WebUIHandler::new()
10224            .render_streaming(
10225                &protocol,
10226                &test_json!({}),
10227                &RenderOptions::new("index.html", "/"),
10228                &mut streamed,
10229            )
10230            .unwrap();
10231        let streamed_head_end = streamed
10232            .output
10233            .find("</head>")
10234            .expect("streamed head close");
10235        for resource in ["app-shell", "dashboard-page"] {
10236            let marker = format!(r#"data-webui-resource="{resource}""#);
10237            let position = streamed.output.find(&marker).expect("streamed route style");
10238            assert!(
10239                position < streamed_head_end,
10240                "streaming must hoist {resource} into head: {}",
10241                streamed.output
10242            );
10243            assert_eq!(
10244                streamed.output.matches(&marker).count(),
10245                1,
10246                "streaming must not emit {resource} twice: {}",
10247                streamed.output
10248            );
10249        }
10250    }
10251
10252    #[test]
10253    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10254        let route = WebUiFragmentRoute {
10255            path: "/".to_string(),
10256            fragment_id: "dashboard-page".to_string(),
10257            exact: true,
10258            ..Default::default()
10259        };
10260        let mut protocol = WebUIProtocol::new(HashMap::from([
10261            (
10262                "index.html".to_string(),
10263                FragmentList {
10264                    fragments: vec![
10265                        WebUIFragment::raw("<html><head>"),
10266                        structural_fragment("head_start"),
10267                        structural_fragment("head_end"),
10268                        WebUIFragment::raw("</head><body>"),
10269                        WebUIFragment::route_from(route),
10270                        structural_fragment("body_end"),
10271                        WebUIFragment::raw("</body></html>"),
10272                    ],
10273                    contains_boundary: false,
10274                },
10275            ),
10276            (
10277                "dashboard-page".to_string(),
10278                FragmentList {
10279                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10280                    contains_boundary: false,
10281                },
10282            ),
10283        ]));
10284        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10285        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10286            protocol
10287                .components
10288                .entry(tag.to_string())
10289                .or_default()
10290                .css_href = format!("/{tag}.css");
10291        }
10292        protocol.style_closures.insert(
10293            "index.html".to_string(),
10294            webui_protocol::ComponentStyleClosure::default(),
10295        );
10296        protocol.style_closures.insert(
10297            "dashboard-page".to_string(),
10298            webui_protocol::ComponentStyleClosure {
10299                component_tags: vec![
10300                    "dashboard-page".to_string(),
10301                    "summary-card".to_string(),
10302                    "activity-list".to_string(),
10303                ],
10304                style_chunks: vec![0],
10305            },
10306        );
10307        protocol.style_chunks.push(webui_protocol::StyleChunk {
10308            name: "_chunk-dashboard-page-3".to_string(),
10309            css: String::new(),
10310            css_href: "/_chunk-dashboard-page-3.css".to_string(),
10311            component_tags: vec![
10312                "dashboard-page".to_string(),
10313                "summary-card".to_string(),
10314                "activity-list".to_string(),
10315            ],
10316        });
10317
10318        let mut writer = TestWriter::new();
10319        handle(
10320            &protocol,
10321            &test_json!({}),
10322            &RenderOptions::new("index.html", "/"),
10323            &mut writer,
10324        )
10325        .unwrap();
10326        let html = writer.get_content();
10327        let head_end = html.find("</head>").expect("head close");
10328        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10329        let style = html.find(marker).expect("bundled route stylesheet");
10330
10331        assert!(
10332            style < head_end,
10333            "bundled route CSS must be in head: {html}"
10334        );
10335        assert_eq!(
10336            html.matches(marker).count(),
10337            1,
10338            "the route host must not emit the hoisted chunk again: {html}"
10339        );
10340        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10341        assert!(
10342            !html.contains(&format!(
10343                r#"<link rel="preload" as="style" href="{bundled_href}">"#
10344            )),
10345            "the applied bundled stylesheet must not also be preloaded: {html}"
10346        );
10347    }
10348
10349    #[test]
10350    fn routed_document_inline_styles_are_hoisted() {
10351        for strategy in [
10352            webui_protocol::CssStrategy::Style,
10353            webui_protocol::CssStrategy::Module,
10354        ] {
10355            let route = WebUiFragmentRoute {
10356                path: "/".to_string(),
10357                fragment_id: "dashboard-page".to_string(),
10358                exact: true,
10359                ..Default::default()
10360            };
10361            let mut protocol = WebUIProtocol::new(HashMap::from([
10362                (
10363                    "index.html".to_string(),
10364                    FragmentList {
10365                        fragments: vec![
10366                            WebUIFragment::raw("<html><head>"),
10367                            structural_fragment("head_start"),
10368                            structural_fragment("head_end"),
10369                            WebUIFragment::raw("</head><body>"),
10370                            WebUIFragment::route_from(route),
10371                            structural_fragment("body_end"),
10372                            WebUIFragment::raw("</body></html>"),
10373                        ],
10374                        contains_boundary: false,
10375                    },
10376                ),
10377                (
10378                    "dashboard-page".to_string(),
10379                    FragmentList {
10380                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10381                        contains_boundary: false,
10382                    },
10383                ),
10384            ]));
10385            protocol.set_css_strategy(strategy);
10386            protocol
10387                .components
10388                .entry("dashboard-page".to_string())
10389                .or_default()
10390                .css = ".dashboard{display:grid}".to_string();
10391            protocol.populate_style_closures(&["index.html"]);
10392
10393            let mut writer = TestWriter::new();
10394            handle(
10395                &protocol,
10396                &test_json!({}),
10397                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10398                &mut writer,
10399            )
10400            .unwrap();
10401            let html = writer.get_content();
10402            let head_end = html.find("</head>").expect("head close");
10403            let marker = r#"data-webui-resource="dashboard-page""#;
10404            let style = html.find(marker).expect("route style fallback");
10405            let host = html.find("<dashboard-page").expect("dashboard route host");
10406            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10407                "module"
10408            } else {
10409                "style"
10410            };
10411            let style_marker = format!(
10412                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10413            );
10414
10415            assert!(
10416                style < head_end && head_end < host,
10417                "{strategy:?} route CSS must be applied in head before its host: {html}"
10418            );
10419            assert_eq!(
10420                html.matches(&style_marker).count(),
10421                1,
10422                "{strategy:?} route CSS must not be emitted twice: {html}"
10423            );
10424        }
10425    }
10426
10427    #[test]
10428    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10429        let route = WebUiFragmentRoute {
10430            path: "/".to_string(),
10431            fragment_id: "dashboard-page".to_string(),
10432            exact: true,
10433            ..Default::default()
10434        };
10435        let mut protocol = WebUIProtocol::new(HashMap::from([
10436            (
10437                "index.html".to_string(),
10438                FragmentList {
10439                    fragments: vec![
10440                        WebUIFragment::raw("<html><head>"),
10441                        structural_fragment("head_start"),
10442                        structural_fragment("head_end"),
10443                        WebUIFragment::raw("</head><body><outer-box>"),
10444                        WebUIFragment::component("outer-box"),
10445                        WebUIFragment::raw("</outer-box></body></html>"),
10446                    ],
10447                    contains_boundary: false,
10448                },
10449            ),
10450            (
10451                "outer-box".to_string(),
10452                FragmentList {
10453                    fragments: vec![
10454                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10455                        structural_fragment("shadow_styles:outer-box"),
10456                        WebUIFragment::route_from(route),
10457                        WebUIFragment::raw("</template>"),
10458                    ],
10459                    contains_boundary: false,
10460                },
10461            ),
10462            (
10463                "dashboard-page".to_string(),
10464                FragmentList {
10465                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10466                    contains_boundary: false,
10467                },
10468            ),
10469        ]));
10470        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10471        for tag in ["outer-box", "dashboard-page"] {
10472            protocol
10473                .components
10474                .entry(tag.to_string())
10475                .or_default()
10476                .css_href = format!("/{tag}.css");
10477        }
10478        protocol
10479            .components
10480            .get_mut("outer-box")
10481            .expect("outer-box component")
10482            .uses_shadow_dom = true;
10483        protocol.populate_style_closures(&["index.html"]);
10484
10485        let mut writer = TestWriter::new();
10486        handle(
10487            &protocol,
10488            &test_json!({}),
10489            &RenderOptions::new("index.html", "/"),
10490            &mut writer,
10491        )
10492        .unwrap();
10493        let html = writer.get_content();
10494        let head_end = html.find("</head>").expect("head close");
10495        let head = &html[..head_end];
10496        let dashboard_style = html
10497            .find(r#"data-webui-resource="dashboard-page""#)
10498            .expect("tree-local dashboard stylesheet");
10499        let shadow_start = html
10500            .find("<template shadowrootmode=\"open\">")
10501            .expect("Shadow template");
10502        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10503        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10504
10505        assert!(
10506            head.contains(&format!(
10507                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10508            )),
10509            "Shadow-targeted route CSS must start loading from head: {html}"
10510        );
10511        assert!(
10512            !head.contains(r#"data-webui-resource="dashboard-page""#),
10513            "Shadow-targeted route CSS must not be applied to Document: {html}"
10514        );
10515        assert!(
10516            shadow_start < dashboard_style && dashboard_style < dashboard_host,
10517            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10518        );
10519    }
10520
10521    #[test]
10522    fn isolated_shadow_entry_does_not_emit_document_preloads() {
10523        let route = WebUiFragmentRoute {
10524            path: "/".to_string(),
10525            fragment_id: "dashboard-page".to_string(),
10526            exact: true,
10527            ..Default::default()
10528        };
10529        let mut protocol = WebUIProtocol::new(HashMap::from([
10530            (
10531                "outer-box".to_string(),
10532                FragmentList {
10533                    fragments: vec![
10534                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10535                        structural_fragment("shadow_styles:outer-box"),
10536                        WebUIFragment::route_from(route),
10537                        WebUIFragment::raw("</template>"),
10538                    ],
10539                    contains_boundary: false,
10540                },
10541            ),
10542            (
10543                "dashboard-page".to_string(),
10544                FragmentList {
10545                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10546                    contains_boundary: false,
10547                },
10548            ),
10549        ]));
10550        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10551        for tag in ["outer-box", "dashboard-page"] {
10552            protocol
10553                .components
10554                .entry(tag.to_string())
10555                .or_default()
10556                .css_href = format!("/{tag}.css");
10557        }
10558        protocol
10559            .components
10560            .get_mut("outer-box")
10561            .expect("outer-box component")
10562            .uses_shadow_dom = true;
10563        protocol.populate_style_closures(&["outer-box"]);
10564
10565        let mut writer = TestWriter::new();
10566        handle(
10567            &protocol,
10568            &test_json!({}),
10569            &RenderOptions::new("outer-box", "/"),
10570            &mut writer,
10571        )
10572        .unwrap();
10573        let html = writer.get_content();
10574
10575        assert!(
10576            html.starts_with("<template shadowrootmode=\"open\">"),
10577            "isolated component output must not be prefixed with document metadata: {html}"
10578        );
10579        assert!(
10580            !html.contains(r#"<link rel="preload""#),
10581            "an isolated Shadow entry has no Document head for preloads: {html}"
10582        );
10583        assert!(
10584            html.contains(r#"data-webui-resource="dashboard-page""#),
10585            "the route stylesheet must still install inside the ShadowRoot: {html}"
10586        );
10587    }
10588
10589    #[test]
10590    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10591        let route = WebUiFragmentRoute {
10592            path: "/".to_string(),
10593            fragment_id: "app-shell".to_string(),
10594            children: vec![
10595                WebUiFragmentRoute {
10596                    path: String::new(),
10597                    fragment_id: "dashboard-page".to_string(),
10598                    exact: true,
10599                    ..Default::default()
10600                },
10601                WebUiFragmentRoute {
10602                    path: "details".to_string(),
10603                    fragment_id: "detail-page".to_string(),
10604                    exact: true,
10605                    ..Default::default()
10606                },
10607            ],
10608            ..Default::default()
10609        };
10610        let fragments = HashMap::from([
10611            (
10612                "index.html".to_string(),
10613                FragmentList {
10614                    fragments: vec![
10615                        WebUIFragment::raw("<html><head>"),
10616                        structural_fragment("head_start"),
10617                        structural_fragment("head_end"),
10618                        WebUIFragment::raw("</head><body>"),
10619                        structural_fragment("body_start"),
10620                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10621                        WebUIFragment::route_from(route),
10622                        WebUIFragment::boundary_end(0),
10623                        structural_fragment("body_end"),
10624                        WebUIFragment::raw("</body></html>"),
10625                    ],
10626                    contains_boundary: true,
10627                },
10628            ),
10629            (
10630                "app-shell".to_string(),
10631                FragmentList {
10632                    fragments: vec![
10633                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10634                        structural_fragment("shadow_styles:app-shell"),
10635                        WebUIFragment::raw("<shared-card"),
10636                        structural_fragment("streaming_root:shared-card"),
10637                        WebUIFragment::raw(">"),
10638                        WebUIFragment::component("shared-card"),
10639                        WebUIFragment::raw("</shared-card>"),
10640                        WebUIFragment::outlet(),
10641                        WebUIFragment::raw("</template>"),
10642                    ],
10643                    contains_boundary: false,
10644                },
10645            ),
10646            (
10647                "dashboard-page".to_string(),
10648                FragmentList {
10649                    fragments: vec![
10650                        WebUIFragment::raw("<shared-card"),
10651                        structural_fragment("streaming_root:shared-card"),
10652                        WebUIFragment::raw(">"),
10653                        WebUIFragment::component("shared-card"),
10654                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10655                    ],
10656                    contains_boundary: false,
10657                },
10658            ),
10659            (
10660                "detail-page".to_string(),
10661                FragmentList {
10662                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10663                    contains_boundary: false,
10664                },
10665            ),
10666            (
10667                "shared-card".to_string(),
10668                FragmentList {
10669                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10670                    contains_boundary: false,
10671                },
10672            ),
10673        ]);
10674        let mut protocol = WebUIProtocol::new(fragments);
10675        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10676        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10677            let component = protocol.components.entry(tag.to_string()).or_default();
10678            component.css = format!(".{tag}{{display:block}}");
10679            component.template_json = r#"{"h":""}"#.to_string();
10680        }
10681        protocol
10682            .components
10683            .get_mut("app-shell")
10684            .expect("app shell component")
10685            .uses_shadow_dom = true;
10686        protocol.populate_style_closures(&["index.html"]);
10687
10688        let mut writer = TestWriter::new();
10689        handle(
10690            &protocol,
10691            &test_json!({}),
10692            &RenderOptions::new("index.html", "/"),
10693            &mut writer,
10694        )
10695        .unwrap();
10696        let html = writer.get_content();
10697
10698        for resource in ["app-shell", "shared-card", "dashboard-page"] {
10699            assert_eq!(
10700                html.matches(&format!(r#"data-webui-resource="{resource}""#))
10701                    .count(),
10702                1,
10703                "{resource} must install once in the active ShadowRoot: {html}"
10704            );
10705        }
10706        assert!(
10707            !html.contains(r#"data-webui-resource="detail-page""#),
10708            "inactive route CSS must not enter the ShadowRoot: {html}"
10709        );
10710        let shadow_start = html
10711            .find("<template shadowrootmode=\"open\">")
10712            .expect("Shadow template");
10713        let dashboard_style = html
10714            .find(r#"data-webui-resource="dashboard-page""#)
10715            .expect("dashboard style");
10716        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10717        let shadow_end = html.find("</template>").expect("Shadow template close");
10718        assert!(
10719            shadow_start < dashboard_style
10720                && dashboard_style < dashboard_host
10721                && dashboard_host < shadow_end,
10722            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10723        );
10724
10725        let protocol = Protocol::new(protocol);
10726        let mut streamed = FlushTestWriter::default();
10727        WebUIHandler::new()
10728            .render_streaming(
10729                &protocol,
10730                &test_json!({}),
10731                &RenderOptions::new("index.html", "/"),
10732                &mut streamed,
10733            )
10734            .unwrap();
10735        for resource in ["app-shell", "shared-card"] {
10736            assert_eq!(
10737                streamed
10738                    .output
10739                    .matches(&format!(r#"data-webui-resource="{resource}""#))
10740                    .count(),
10741                1,
10742                "streaming must preserve tree-local delivery for {resource}: {}",
10743                streamed.output
10744            );
10745        }
10746        assert!(
10747            !streamed
10748                .output
10749                .contains(r#"data-webui-resource="dashboard-page""#),
10750            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10751            streamed.output
10752        );
10753        assert!(
10754            streamed
10755                .output
10756                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10757                && streamed
10758                    .output
10759                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10760            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10761            streamed.output
10762        );
10763        assert!(
10764            !streamed
10765                .output
10766                .contains(r#"data-webui-resource="detail-page""#),
10767            "streaming must omit inactive route CSS: {}",
10768            streamed.output
10769        );
10770    }
10771
10772    #[test]
10773    fn document_styles_follow_doctype_before_headless_document_content() {
10774        for entry_fragments in [
10775            vec![
10776                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10777                structural_fragment("body_start"),
10778                WebUIFragment::raw("<my-card>"),
10779                WebUIFragment::component("my-card"),
10780                WebUIFragment::raw("</my-card></body></html>"),
10781            ],
10782            vec![
10783                WebUIFragment::raw("<!doctype html><html><my-card>"),
10784                WebUIFragment::component("my-card"),
10785                WebUIFragment::raw("</my-card></html>"),
10786            ],
10787        ] {
10788            let mut protocol = WebUIProtocol::new(HashMap::from([
10789                (
10790                    "index.html".to_string(),
10791                    FragmentList {
10792                        fragments: entry_fragments,
10793                        contains_boundary: false,
10794                    },
10795                ),
10796                (
10797                    "my-card".to_string(),
10798                    FragmentList {
10799                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
10800                        contains_boundary: false,
10801                    },
10802                ),
10803            ]));
10804            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10805            let component = protocol
10806                .components
10807                .entry("my-card".to_string())
10808                .or_default();
10809            component.css = ".card{color:red}".to_string();
10810            component.uses_shadow_dom = false;
10811            protocol.populate_style_closures(&["index.html"]);
10812
10813            let mut writer = TestWriter::new();
10814            handle(
10815                &protocol,
10816                &test_json!({}),
10817                &RenderOptions::new("index.html", "/"),
10818                &mut writer,
10819            )
10820            .expect("render");
10821            let html = writer.get_content();
10822
10823            assert!(
10824                html.to_ascii_lowercase().starts_with("<!doctype html>"),
10825                "the doctype must remain the first document token: {html}"
10826            );
10827            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10828            let document = html.find("<html").expect("document root");
10829            let component = html.find("<my-card>").expect("component host");
10830            assert!(
10831                style < document && style < component,
10832                "headless document styles must precede document content: {html}"
10833            );
10834        }
10835    }
10836
10837    #[test]
10838    fn authored_shadow_component_can_render_as_entry_fragment() {
10839        let mut protocol = WebUIProtocol::new(HashMap::from([(
10840            "my-card".to_string(),
10841            FragmentList {
10842                fragments: vec![
10843                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10844                    structural_fragment("shadow_styles:my-card"),
10845                    WebUIFragment::raw("<p>card</p></template>"),
10846                ],
10847                contains_boundary: false,
10848            },
10849        )]));
10850        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10851        let component = protocol
10852            .components
10853            .entry("my-card".to_string())
10854            .or_default();
10855        component.css = ".card{color:red}".to_string();
10856        component.uses_shadow_dom = true;
10857        protocol.populate_style_closures(&["my-card"]);
10858
10859        let mut writer = TestWriter::new();
10860        handle(
10861            &protocol,
10862            &test_json!({}),
10863            &RenderOptions::new("my-card", "/"),
10864            &mut writer,
10865        )
10866        .expect("render");
10867        let html = writer.get_content();
10868
10869        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10870        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10871        assert!(html.ends_with("<p>card</p></template>"));
10872    }
10873
10874    #[test]
10875    fn component_style_closures_escape_mixed_case_style_end_tags() {
10876        for strategy in [
10877            webui_protocol::CssStrategy::Style,
10878            webui_protocol::CssStrategy::Module,
10879        ] {
10880            let mut protocol = WebUIProtocol::new(HashMap::from([
10881                (
10882                    "index.html".to_string(),
10883                    FragmentList {
10884                        fragments: vec![
10885                            WebUIFragment::raw("<html><head>"),
10886                            structural_fragment("head_end"),
10887                            WebUIFragment::raw("</head><body><safe-card>"),
10888                            WebUIFragment::component("safe-card"),
10889                            WebUIFragment::raw("</safe-card></body></html>"),
10890                        ],
10891                        contains_boundary: false,
10892                    },
10893                ),
10894                (
10895                    "safe-card".to_string(),
10896                    FragmentList {
10897                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10898                        contains_boundary: false,
10899                    },
10900                ),
10901            ]));
10902            protocol.set_css_strategy(strategy);
10903            protocol.components.insert(
10904                "safe-card".to_string(),
10905                webui_protocol::ComponentData {
10906                    css: ".safe{content:'</StYlE>'}".to_string(),
10907                    ..Default::default()
10908                },
10909            );
10910            protocol.populate_style_closures(&["index.html"]);
10911            let mut writer = TestWriter::new();
10912
10913            handle(
10914                &protocol,
10915                &test_json!({}),
10916                &RenderOptions::new("index.html", "/"),
10917                &mut writer,
10918            )
10919            .unwrap();
10920
10921            let html = writer.get_content();
10922            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10923            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10924        }
10925    }
10926
10927    #[test]
10928    fn shadow_closure_supports_link_and_module_ssr_paths() {
10929        for strategy in [
10930            webui_protocol::CssStrategy::Link,
10931            webui_protocol::CssStrategy::Module,
10932        ] {
10933            let mut protocol = WebUIProtocol::new(HashMap::from([
10934                (
10935                    "index.html".to_string(),
10936                    FragmentList {
10937                        fragments: vec![
10938                            WebUIFragment::raw("<html><head>".to_string()),
10939                            structural_fragment("head_end"),
10940                            WebUIFragment::raw("</head><body><my-card>".to_string()),
10941                            WebUIFragment::component("my-card"),
10942                            WebUIFragment::raw("</my-card></body></html>".to_string()),
10943                        ],
10944                        contains_boundary: false,
10945                    },
10946                ),
10947                (
10948                    "my-card".to_string(),
10949                    FragmentList {
10950                        fragments: vec![
10951                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10952                            structural_fragment("shadow_styles:my-card"),
10953                            WebUIFragment::raw("<p>card</p></template>".to_string()),
10954                        ],
10955                        contains_boundary: false,
10956                    },
10957                ),
10958            ]));
10959            protocol.set_css_strategy(strategy);
10960            let component = protocol
10961                .components
10962                .entry("my-card".to_string())
10963                .or_default();
10964            component.css = ".card{color:red}".to_string();
10965            component.css_href = "/my-card.css".to_string();
10966            component.uses_shadow_dom = true;
10967            protocol.populate_style_closures(&["index.html"]);
10968
10969            let mut writer = TestWriter::new();
10970            handle(
10971                &protocol,
10972                &test_json!({}),
10973                &RenderOptions::new("index.html", "/"),
10974                &mut writer,
10975            )
10976            .unwrap();
10977            let html = writer.get_content();
10978            match strategy {
10979                webui_protocol::CssStrategy::Link => {
10980                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10981                    assert!(html.contains(&format!(
10982                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10983                    )));
10984                }
10985                webui_protocol::CssStrategy::Module => {
10986                    assert!(html.contains(
10987                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10988                    ));
10989                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10990                }
10991                webui_protocol::CssStrategy::Style => unreachable!(),
10992            }
10993        }
10994    }
10995
10996    #[test]
10997    fn test_module_preloads_emit_in_head_in_compiler_order() {
10998        // The whole value of these hints is ordering: preloads are issued in
10999        // document order over one connection, so the largest chunk must go
11000        // first. The compiler sorts; the handler must not reorder or dedupe.
11001        let mut fragments = HashMap::new();
11002        fragments.insert(
11003            "index.html".to_string(),
11004            FragmentList {
11005                fragments: vec![
11006                    WebUIFragment::raw(
11007                        r#"<html><head><script type="module" async src="/index.js"></script>"#
11008                            .to_string(),
11009                    ),
11010                    structural_fragment("head_end"),
11011                    WebUIFragment::raw("</head><body>".to_string()),
11012                    structural_fragment("body_end"),
11013                    WebUIFragment::raw("</body></html>".to_string()),
11014                ],
11015                contains_boundary: false,
11016            },
11017        );
11018
11019        let mut protocol = WebUIProtocol::new(fragments);
11020        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11021
11022        let state = test_json!({});
11023        let mut writer = TestWriter::new();
11024        handle(
11025            &protocol,
11026            &state,
11027            &RenderOptions::new("index.html", "/"),
11028            &mut writer,
11029        )
11030        .unwrap();
11031
11032        let html = writer.get_content();
11033        let head_end = html.find("</head>").expect("</head> missing");
11034        let head = &html[..head_end];
11035        assert!(
11036            head.contains(
11037                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11038            ),
11039            "hints must appear in <head> in the compiler's order: {html}"
11040        );
11041    }
11042
11043    #[test]
11044    fn test_no_module_preloads_emits_nothing() {
11045        let mut fragments = HashMap::new();
11046        fragments.insert(
11047            "index.html".to_string(),
11048            FragmentList {
11049                fragments: vec![
11050                    WebUIFragment::raw("<html><head>".to_string()),
11051                    structural_fragment("head_end"),
11052                    WebUIFragment::raw("</head><body>".to_string()),
11053                    structural_fragment("body_end"),
11054                    WebUIFragment::raw("</body></html>".to_string()),
11055                ],
11056                contains_boundary: false,
11057            },
11058        );
11059        let protocol = WebUIProtocol::new(fragments);
11060
11061        let state = test_json!({});
11062        let mut writer = TestWriter::new();
11063        handle(
11064            &protocol,
11065            &state,
11066            &RenderOptions::new("index.html", "/"),
11067            &mut writer,
11068        )
11069        .unwrap();
11070
11071        assert!(
11072            !writer.get_content().contains("modulepreload"),
11073            "a build without hints must be byte-identical to before"
11074        );
11075    }
11076
11077    #[test]
11078    fn test_link_strategy_head_links_follow_document_order() {
11079        // Regression for #381: Link-strategy <head> CSS <link> tags must be
11080        // emitted in document/traversal order, not alphabetical tag order.
11081        // Document order here is <z-widget> then <a-widget>; an alphabetical
11082        // sort would (incorrectly) place a-widget first.
11083        let mut fragments = HashMap::new();
11084        fragments.insert(
11085            "index.html".to_string(),
11086            FragmentList {
11087                fragments: vec![
11088                    WebUIFragment::raw("<html><head>".to_string()),
11089                    structural_fragment("head_end"),
11090                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
11091                    WebUIFragment::component("z-widget"),
11092                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11093                    WebUIFragment::component("a-widget"),
11094                    WebUIFragment::raw("</a-widget>".to_string()),
11095                    structural_fragment("body_end"),
11096                    WebUIFragment::raw("</body></html>".to_string()),
11097                ],
11098                contains_boundary: false,
11099            },
11100        );
11101        fragments.insert(
11102            "z-widget".to_string(),
11103            FragmentList {
11104                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11105                contains_boundary: false,
11106            },
11107        );
11108        fragments.insert(
11109            "a-widget".to_string(),
11110            FragmentList {
11111                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11112                contains_boundary: false,
11113            },
11114        );
11115
11116        let mut protocol = WebUIProtocol::new(fragments);
11117        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11118
11119        let z = protocol
11120            .components
11121            .entry("z-widget".to_string())
11122            .or_default();
11123        z.css_href = "z-widget.css".to_string();
11124        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11125
11126        let a = protocol
11127            .components
11128            .entry("a-widget".to_string())
11129            .or_default();
11130        a.css_href = "a-widget.css".to_string();
11131        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11132        protocol.populate_style_closures(&["index.html"]);
11133
11134        let state = test_json!({});
11135        let mut writer = TestWriter::new();
11136
11137        handle(
11138            &protocol,
11139            &state,
11140            &RenderOptions::new("index.html", "/"),
11141            &mut writer,
11142        )
11143        .unwrap();
11144
11145        let html = writer.get_content();
11146        let head_end = html.find("</head>").expect("</head> missing");
11147        let head_section = &html[..head_end];
11148
11149        let z_pos = head_section
11150            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11151            .expect("z-widget stylesheet link missing from <head>");
11152        let a_pos = head_section
11153            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11154            .expect("a-widget stylesheet link missing from <head>");
11155
11156        assert!(
11157            z_pos < a_pos,
11158            "CSS <link> tags must follow document order (z-widget before \
11159             a-widget), not alphabetical order: {html}"
11160        );
11161    }
11162
11163    #[test]
11164    fn test_css_module_emitted_in_component_light_dom() {
11165        // CSS module <style> tags are emitted inline in the component's light DOM,
11166        // not in <head>. This keeps SSR output lean — only rendered components
11167        // get their style definitions.
11168        let mut fragments = HashMap::new();
11169        fragments.insert(
11170            "index.html".to_string(),
11171            FragmentList {
11172                fragments: vec![
11173                    WebUIFragment::raw("<html><head>".to_string()),
11174                    structural_fragment("head_end"),
11175                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11176                    WebUIFragment::component("my-card"),
11177                    WebUIFragment::raw("</my-card>".to_string()),
11178                    WebUIFragment::raw("</body></html>".to_string()),
11179                ],
11180                contains_boundary: false,
11181            },
11182        );
11183        fragments.insert(
11184            "my-card".to_string(),
11185            FragmentList {
11186                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11187                contains_boundary: false,
11188            },
11189        );
11190
11191        let mut protocol = WebUIProtocol::new(fragments);
11192        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11193        protocol
11194            .components
11195            .entry("my-card".to_string())
11196            .or_default()
11197            .css = "p{color:red}".to_string();
11198        protocol.populate_style_closures(&["index.html"]);
11199        let state = test_json!({});
11200        let mut writer = TestWriter::new();
11201
11202        handle(
11203            &protocol,
11204            &state,
11205            &RenderOptions::new("index.html", "/"),
11206            &mut writer,
11207        )
11208        .unwrap();
11209
11210        let html = writer.get_content();
11211
11212        // CSS module importmap must be INSIDE the component tag (light DOM)
11213        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11214        let css_pos = html
11215            .find(r#"<script type="importmap""#)
11216            .expect("CSS module importmap missing");
11217        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11218        assert!(
11219            css_pos > tag_open && css_pos < tag_close,
11220            "CSS module should be inside component light DOM: {html}"
11221        );
11222
11223        // <head> should NOT contain module styles
11224        let head_end = html.find("</head>").expect("</head> missing");
11225        assert!(
11226            css_pos > head_end,
11227            "CSS module should not be in <head>: {html}"
11228        );
11229    }
11230
11231    #[test]
11232    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11233        let mut protocol = WebUIProtocol::default();
11234        protocol.fragments = HashMap::from([
11235            (
11236                "index.html".to_string(),
11237                FragmentList {
11238                    fragments: vec![
11239                        WebUIFragment::raw("<legacy-card>"),
11240                        WebUIFragment::component("legacy-card"),
11241                        WebUIFragment::raw("</legacy-card>"),
11242                    ],
11243                    contains_boundary: false,
11244                },
11245            ),
11246            (
11247                "legacy-card".to_string(),
11248                FragmentList {
11249                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11250                    contains_boundary: false,
11251                },
11252            ),
11253        ]);
11254        protocol.components.insert(
11255            "legacy-card".to_string(),
11256            webui_protocol::ComponentData {
11257                css: ".legacy{color:red}".to_string(),
11258                ..Default::default()
11259            },
11260        );
11261        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11262        let mut writer = TestWriter::new();
11263
11264        let error = handle(
11265            &protocol,
11266            &test_json!({}),
11267            &RenderOptions::new("index.html", "/"),
11268            &mut writer,
11269        )
11270        .expect_err("styled protocols require closure metadata");
11271        assert!(error
11272            .to_string()
11273            .contains("component style closure metadata is required"));
11274    }
11275
11276    #[test]
11277    fn test_css_module_emitted_for_route_components() {
11278        // Route components get CSS modules emitted inline in their light DOM.
11279        let template = r#"<h1>Dashboard</h1>"#;
11280
11281        let mut fragments = HashMap::new();
11282        fragments.insert(
11283            "index.html".to_string(),
11284            FragmentList {
11285                fragments: vec![
11286                    WebUIFragment::raw("<html><head>".to_string()),
11287                    structural_fragment("head_end"),
11288                    WebUIFragment::raw("</head><body>".to_string()),
11289                    WebUIFragment::route("/", "dash-page"),
11290                    structural_fragment("body_end"),
11291                    WebUIFragment::raw("</body></html>".to_string()),
11292                ],
11293                contains_boundary: false,
11294            },
11295        );
11296        fragments.insert(
11297            "dash-page".to_string(),
11298            FragmentList {
11299                fragments: vec![WebUIFragment::raw(template.to_string())],
11300                contains_boundary: false,
11301            },
11302        );
11303
11304        let mut protocol = WebUIProtocol::new(fragments);
11305        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11306        let comp = protocol
11307            .components
11308            .entry("dash-page".to_string())
11309            .or_default();
11310        comp.css = "h1{font-size:2rem}".to_string();
11311        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11312        protocol.populate_style_closures(&["index.html"]);
11313        let state = test_json!({});
11314        let mut writer = TestWriter::new();
11315
11316        handle(
11317            &protocol,
11318            &state,
11319            &RenderOptions::new("index.html", "/"),
11320            &mut writer,
11321        )
11322        .unwrap();
11323
11324        let html = writer.get_content();
11325
11326        assert!(
11327            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11328            "Route component should have CSS module importmap with data: URI: {html}"
11329        );
11330        assert!(
11331            html.contains("<h1>Dashboard</h1>"),
11332            "Route component should render content: {html}"
11333        );
11334        let fallback = html
11335            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11336            .expect("route Module fallback");
11337        let host_open = html.find("<dash-page>").expect("route host");
11338        let content = html.find("<h1>Dashboard</h1>").expect("route content");
11339        assert!(
11340            fallback < host_open && host_open < content,
11341            "the Module fallback must precede the generated Light route host: {html}"
11342        );
11343        // The importmap belongs to the component's own Light DOM, never to the
11344        // route element. The router only treats `<link>`/`<style>` markers as
11345        // route-owned styles, so an importmap emitted as a direct route child
11346        // would be cleared on navigation and mistaken for the mounted
11347        // component. Keep it inside the host so that can never happen.
11348        let importmap = html
11349            .find(r#"<script type="importmap""#)
11350            .expect("route CSS module importmap");
11351        assert!(
11352            host_open < importmap,
11353            "the CSS module importmap must live inside the route host, not as a route child: {html}"
11354        );
11355    }
11356
11357    #[test]
11358    fn test_head_css_link_skipped_for_components_without_css() {
11359        // Regression: components without CSS files must not get <link> tags
11360        // in <head>, otherwise the browser requests a 404.
11361        let mut fragments = HashMap::new();
11362        fragments.insert(
11363            "index.html".to_string(),
11364            FragmentList {
11365                fragments: vec![
11366                    WebUIFragment::raw("<html><head>".to_string()),
11367                    structural_fragment("head_end"),
11368                    WebUIFragment::raw("</head><body>".to_string()),
11369                    WebUIFragment::component("has-css"),
11370                    WebUIFragment::component("no-css"),
11371                    structural_fragment("body_end"),
11372                    WebUIFragment::raw("</body></html>".to_string()),
11373                ],
11374                contains_boundary: false,
11375            },
11376        );
11377        fragments.insert(
11378            "has-css".to_string(),
11379            FragmentList {
11380                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11381                contains_boundary: false,
11382            },
11383        );
11384        fragments.insert(
11385            "no-css".to_string(),
11386            FragmentList {
11387                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11388                contains_boundary: false,
11389            },
11390        );
11391
11392        let mut protocol = WebUIProtocol::new(fragments);
11393        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11394
11395        // Only has-css has an external stylesheet (Link strategy)
11396        protocol
11397            .components
11398            .entry("has-css".to_string())
11399            .or_default()
11400            .css_href = "has-css.css".to_string();
11401        protocol
11402            .components
11403            .entry("no-css".to_string())
11404            .or_default()
11405            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11406        protocol.populate_style_closures(&["index.html"]);
11407
11408        let state = test_json!({});
11409        let mut writer = TestWriter::new();
11410
11411        let handler = WebUIHandler::with_plugin(|| {
11412            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11413        });
11414        handler
11415            .handle(
11416                &protocol,
11417                &state,
11418                &RenderOptions::new("index.html", "/"),
11419                &mut writer,
11420            )
11421            .unwrap();
11422
11423        let html = writer.get_content();
11424        assert!(
11425            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11426            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11427        );
11428        assert!(
11429            !html.contains("no-css.css"),
11430            "Component without CSS must NOT get a <link> in <head>: {html}"
11431        );
11432    }
11433
11434    #[test]
11435    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11436        // Simulates a page where app-shell renders cart-panel, but cart-panel
11437        // contains an <if> block with product-card inside. When the condition
11438        // is false (empty cart), product-card is NOT rendered — but it IS
11439        // reachable from the fragment graph. Its template metadata and CSS module
11440        // definition must be in the output so the client can mount it when
11441        // the <if> flips true. However, its bit must NOT be set in the
11442        // inventory — the inventory tracks what was actually rendered.
11443        let mut fragments = HashMap::new();
11444        fragments.insert(
11445            "index.html".to_string(),
11446            FragmentList {
11447                fragments: vec![
11448                    WebUIFragment::raw("<html><head>".to_string()),
11449                    structural_fragment("head_end"),
11450                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11451                    WebUIFragment::component("app-shell"),
11452                    WebUIFragment::raw("</app-shell>".to_string()),
11453                    structural_fragment("body_end"),
11454                    WebUIFragment::raw("</body></html>".to_string()),
11455                ],
11456                contains_boundary: false,
11457            },
11458        );
11459        // app-shell contains a cart panel
11460        fragments.insert(
11461            "app-shell".to_string(),
11462            FragmentList {
11463                fragments: vec![
11464                    WebUIFragment::raw("<div>Shell</div>".to_string()),
11465                    WebUIFragment::component("cart-panel"),
11466                ],
11467                contains_boundary: false,
11468            },
11469        );
11470        // cart-panel has an <if> block containing product-card
11471        fragments.insert(
11472            "cart-panel".to_string(),
11473            FragmentList {
11474                fragments: vec![
11475                    WebUIFragment::raw("<aside>".to_string()),
11476                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11477                    WebUIFragment::raw("</aside>".to_string()),
11478                ],
11479                contains_boundary: false,
11480            },
11481        );
11482        // cart-items (if block body) contains product-card
11483        fragments.insert(
11484            "cart-items".to_string(),
11485            FragmentList {
11486                fragments: vec![WebUIFragment::component("product-card")],
11487                contains_boundary: false,
11488            },
11489        );
11490        fragments.insert(
11491            "product-card".to_string(),
11492            FragmentList {
11493                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11494                contains_boundary: false,
11495            },
11496        );
11497
11498        let mut protocol = WebUIProtocol::new(fragments);
11499        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11500        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11501        for name in ["app-shell", "cart-panel", "product-card"] {
11502            let comp = protocol.components.entry(name.to_string()).or_default();
11503            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11504            comp.css = format!(".{name}{{display:block}}");
11505            if name == "cart-panel" {
11506                comp.hydration_mode = StateProjectionMode::Keys as i32;
11507                comp.hydration_keys = vec!["hasItems".to_string()];
11508            }
11509            if name == "product-card" {
11510                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11511            }
11512        }
11513        protocol.populate_style_closures(&["index.html"]);
11514
11515        // Render with hasItems=false — product-card should NOT be rendered
11516        let state = test_json!({ "hasItems": false });
11517        let mut writer = TestWriter::new();
11518
11519        let handler = WebUIHandler::with_plugin(|| {
11520            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11521        });
11522        handler
11523            .handle(
11524                &protocol,
11525                &state,
11526                &RenderOptions::new("index.html", "/"),
11527                &mut writer,
11528            )
11529            .unwrap();
11530
11531        let html = writer.get_content();
11532
11533        assert!(
11534            html.contains(r#"<script type="application/json" id="webui-data">"#),
11535            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11536        );
11537        assert!(
11538            html.contains(r#""state":{"hasItems":false}"#),
11539            "SSR state should live in the JSON data block: {html}"
11540        );
11541        assert!(
11542            html.contains(r#""inventory":"#),
11543            "SSR inventory should live in the JSON data block: {html}"
11544        );
11545        assert!(
11546            !html.contains("window.__webui={\""),
11547            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11548        );
11549        assert!(
11550            !html.contains(r#"document.getElementById("webui-data")"#),
11551            "SSR must not parse webui-data; client packages own that lazy load: {html}"
11552        );
11553        assert!(
11554            !html.contains("window.__webui=w;"),
11555            "executable bootstrap must not replace existing window.__webui registrations: {html}"
11556        );
11557        assert!(
11558            !html.contains("w.templateFns={\""),
11559            "template function emission must not replace existing templateFns registrations: {html}"
11560        );
11561        assert!(
11562            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11563            "template functions should merge into the flat templateFns registry: {html}"
11564        );
11565
11566        // product-card template IS in the output — it's a known component
11567        // whose template must be available for client-side <if> activation.
11568        assert!(
11569            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11570            "product-card template should be emitted even when unrendered: {html}"
11571        );
11572
11573        // product-card CSS module IS in the output — reachable components need
11574        // their stylesheet definitions for client-side <if> activation.
11575        assert!(
11576            html.contains(r#""product-card":"data:text/css,"#),
11577            "reachable product-card CSS module importmap should be emitted: {html}"
11578        );
11579
11580        // app-shell and cart-panel SHOULD be in the output (they were rendered)
11581        assert!(
11582            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11583            "rendered app-shell template should be emitted: {html}"
11584        );
11585        assert!(
11586            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11587            "rendered cart-panel template should be emitted: {html}"
11588        );
11589    }
11590
11591    #[test]
11592    fn component_asset_manifest_falls_back_to_body_start() {
11593        let fragments = HashMap::from([(
11594            "index.html".to_string(),
11595            FragmentList {
11596                fragments: vec![
11597                    WebUIFragment::raw("<body>".to_string()),
11598                    structural_fragment("body_start"),
11599                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11600                ],
11601                contains_boundary: false,
11602            },
11603        )]);
11604        let mut protocol = WebUIProtocol::new(fragments);
11605        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11606            root: "lazy-panel".to_string(),
11607            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11608        }];
11609        let mut writer = TestWriter::new();
11610
11611        handle(
11612            &protocol,
11613            &Value::Null,
11614            &RenderOptions::new("index.html", "/"),
11615            &mut writer,
11616        )
11617        .unwrap();
11618
11619        assert_eq!(
11620            writer.get_content(),
11621            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11622        );
11623    }
11624
11625    #[test]
11626    fn component_asset_styles_emit_once_when_body_precedes_head() {
11627        let fragments = HashMap::from([(
11628            "index.html".to_string(),
11629            FragmentList {
11630                fragments: vec![
11631                    WebUIFragment::raw("<body>".to_string()),
11632                    structural_fragment("body_start"),
11633                    WebUIFragment::raw("</body><head>".to_string()),
11634                    structural_fragment("head_end"),
11635                    WebUIFragment::raw("</head>".to_string()),
11636                ],
11637                contains_boundary: false,
11638            },
11639        )]);
11640        let mut protocol = WebUIProtocol::new(fragments);
11641        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11642            root: "lazy-panel".to_string(),
11643            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11644        }];
11645        let mut writer = TestWriter::new();
11646
11647        handle(
11648            &protocol,
11649            &Value::Null,
11650            &RenderOptions::new("index.html", "/"),
11651            &mut writer,
11652        )
11653        .unwrap();
11654
11655        assert_eq!(
11656            writer
11657                .get_content()
11658                .matches(r#"id="webui-component-assets""#)
11659                .count(),
11660            1
11661        );
11662    }
11663
11664    #[test]
11665    fn light_component_asset_styles_apply_at_head_end() {
11666        let fragments = HashMap::from([(
11667            "index.html".to_string(),
11668            FragmentList {
11669                fragments: vec![
11670                    WebUIFragment::raw("<html><head>".to_string()),
11671                    structural_fragment("head_end"),
11672                    WebUIFragment::raw("</head><body></body></html>".to_string()),
11673                ],
11674                contains_boundary: false,
11675            },
11676        )]);
11677        let mut protocol = WebUIProtocol::new(fragments);
11678        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11679        for root in ["lazy-panel", "secondary-panel"] {
11680            protocol.components.insert(
11681                root.to_string(),
11682                webui_protocol::ComponentData {
11683                    uses_shadow_dom: false,
11684                    ..Default::default()
11685                },
11686            );
11687        }
11688        protocol.component_asset_style_preloads = vec![
11689            ComponentAssetStylePreload {
11690                root: "lazy-panel".to_string(),
11691                style_hrefs: vec![
11692                    "/assets/lazy-panel.css".to_string(),
11693                    "/assets/shared.css".to_string(),
11694                ],
11695            },
11696            ComponentAssetStylePreload {
11697                root: "secondary-panel".to_string(),
11698                style_hrefs: vec![
11699                    "/assets/secondary-panel.css".to_string(),
11700                    "/assets/shared.css".to_string(),
11701                ],
11702            },
11703        ];
11704        let mut writer = TestWriter::new();
11705
11706        handle(
11707            &protocol,
11708            &Value::Null,
11709            &RenderOptions::new("index.html", "/"),
11710            &mut writer,
11711        )
11712        .unwrap();
11713
11714        let html = writer.get_content();
11715        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11716        assert_eq!(html.matches("/assets/shared.css").count(), 1);
11717        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11718        let links_end = html
11719            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11720            .expect("asset stylesheet must be emitted");
11721        let head_end = html.find("</head>").expect("head must close");
11722        assert!(links_end < head_end);
11723    }
11724
11725    // ── CSP nonce on CSS module importmap ───────────────────────────
11726    //
11727    // When `RenderOptions::with_nonce(...)` is set, every inline
11728    // `<script type="importmap">` definition emitted during SSR for a
11729    // component CSS module must include `nonce="VALUE"` so strict CSP
11730    // `script-src 'nonce-...'` policies allow it. The without-nonce case
11731    // is already covered by other CSS module tests (e.g.
11732    // `test_css_module_emitted_for_route_components`).
11733
11734    #[test]
11735    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11736        // Per-component first-render path (`emit_css_module`).
11737        let template = r#"<h1>Dashboard</h1>"#;
11738
11739        let mut fragments = HashMap::new();
11740        fragments.insert(
11741            "index.html".to_string(),
11742            FragmentList {
11743                fragments: vec![
11744                    WebUIFragment::raw("<html><head>".to_string()),
11745                    structural_fragment("head_end"),
11746                    WebUIFragment::raw("</head><body>".to_string()),
11747                    WebUIFragment::route("/", "dash-page"),
11748                    structural_fragment("body_end"),
11749                    WebUIFragment::raw("</body></html>".to_string()),
11750                ],
11751                contains_boundary: false,
11752            },
11753        );
11754        fragments.insert(
11755            "dash-page".to_string(),
11756            FragmentList {
11757                fragments: vec![WebUIFragment::raw(template.to_string())],
11758                contains_boundary: false,
11759            },
11760        );
11761
11762        let mut protocol = WebUIProtocol::new(fragments);
11763        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11764        let comp = protocol
11765            .components
11766            .entry("dash-page".to_string())
11767            .or_default();
11768        comp.css = "h1{font-size:2rem}".to_string();
11769        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11770        protocol.populate_style_closures(&["index.html"]);
11771        let state = test_json!({});
11772        let mut writer = TestWriter::new();
11773
11774        handle(
11775            &protocol,
11776            &state,
11777            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11778            &mut writer,
11779        )
11780        .unwrap();
11781
11782        let html = writer.get_content();
11783
11784        assert!(
11785            html.contains(
11786                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11787            ),
11788            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11789        );
11790    }
11791
11792    #[test]
11793    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11794        // Body-end emission path for reachable-but-unrendered components
11795        // (the second site touched by the patch). Triggered via a false
11796        // `<if>` block under hydration; requires the WebUI plugin so the
11797        // body_end hook executes.
11798        let mut fragments = HashMap::new();
11799        fragments.insert(
11800            "index.html".to_string(),
11801            FragmentList {
11802                fragments: vec![
11803                    WebUIFragment::raw("<html><head>".to_string()),
11804                    structural_fragment("head_end"),
11805                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11806                    WebUIFragment::component("app-shell"),
11807                    WebUIFragment::raw("</app-shell>".to_string()),
11808                    structural_fragment("body_end"),
11809                    WebUIFragment::raw("</body></html>".to_string()),
11810                ],
11811                contains_boundary: false,
11812            },
11813        );
11814        fragments.insert(
11815            "app-shell".to_string(),
11816            FragmentList {
11817                fragments: vec![WebUIFragment::if_cond(
11818                    ConditionExpr::identifier("hasItems"),
11819                    "cart-items",
11820                )],
11821                contains_boundary: false,
11822            },
11823        );
11824        fragments.insert(
11825            "cart-items".to_string(),
11826            FragmentList {
11827                fragments: vec![WebUIFragment::component("product-card")],
11828                contains_boundary: false,
11829            },
11830        );
11831        fragments.insert(
11832            "product-card".to_string(),
11833            FragmentList {
11834                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11835                contains_boundary: false,
11836            },
11837        );
11838
11839        let mut protocol = WebUIProtocol::new(fragments);
11840        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11841        for name in ["app-shell", "product-card"] {
11842            let comp = protocol.components.entry(name.to_string()).or_default();
11843            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11844            comp.css = format!(".{name}{{display:block}}");
11845        }
11846        protocol.populate_style_closures(&["index.html"]);
11847
11848        // Render with hasItems=false so product-card is reachable but not
11849        // rendered, forcing its CSS module emission through the body_end path.
11850        let state = test_json!({ "hasItems": false });
11851        let mut writer = TestWriter::new();
11852
11853        let handler = WebUIHandler::with_plugin(|| {
11854            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11855        });
11856        handler
11857            .handle(
11858                &protocol,
11859                &state,
11860                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11861                &mut writer,
11862            )
11863            .unwrap();
11864
11865        let html = writer.get_content();
11866
11867        assert!(
11868            html.contains(
11869                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11870            ),
11871            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11872        );
11873    }
11874
11875    #[test]
11876    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11877        let mut fragments = HashMap::new();
11878        fragments.insert(
11879            "index.html".to_string(),
11880            FragmentList {
11881                fragments: vec![
11882                    WebUIFragment::raw("<html><body><style>".to_string()),
11883                    WebUIFragment::signal("tokens.light", true),
11884                    WebUIFragment::raw("</style>".to_string()),
11885                    structural_fragment("body_end"),
11886                    WebUIFragment::raw("</body></html>".to_string()),
11887                ],
11888                contains_boundary: false,
11889            },
11890        );
11891        fragments.insert(
11892            "app-shell".to_string(),
11893            FragmentList {
11894                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
11895                contains_boundary: false,
11896            },
11897        );
11898        let index_fragments = fragments
11899            .get_mut("index.html")
11900            .expect("index fixture should exist");
11901        index_fragments
11902            .fragments
11903            .insert(1, WebUIFragment::component("app-shell"));
11904        let mut protocol = WebUIProtocol::new(fragments);
11905        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11906        // Only `name` is a hydration key. `tokens` is a server-only field
11907        // (used above to resolve SSR CSS variables) and is NOT in the component
11908        // hydration keys,
11909        // so projection MUST keep it out of the client state block.
11910        protocol.components.insert(
11911            "app-shell".to_string(),
11912            webui_protocol::ComponentData {
11913                hydration_mode: StateProjectionMode::Keys as i32,
11914                hydration_keys: vec!["name".to_string()],
11915                ..Default::default()
11916            },
11917        );
11918        let state = test_json!({
11919            "name": "Alice",
11920            "tokens": {
11921                "light": "--color-brand: red;"
11922            }
11923        });
11924        let mut writer = TestWriter::new();
11925        let handler = WebUIHandler::with_plugin(|| {
11926            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11927        });
11928        handler.handle(
11929            &protocol,
11930            &state,
11931            &RenderOptions::new("index.html", "/"),
11932            &mut writer,
11933        )?;
11934        let output = writer.get_content();
11935
11936        // SSR still reads `tokens` to resolve the inline <style>...
11937        assert!(output.contains("--color-brand: red;"));
11938        // ...but only the hydration key reaches the client state.
11939        assert!(output.contains(r#""name":"Alice""#));
11940        assert!(!output.contains(r#""tokens""#));
11941        Ok(())
11942    }
11943
11944    #[test]
11945    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11946        let mut fragments = HashMap::new();
11947        fragments.insert(
11948            "index.html".to_string(),
11949            FragmentList {
11950                fragments: vec![
11951                    WebUIFragment::raw("<html><body>"),
11952                    structural_fragment("body_end"),
11953                    WebUIFragment::raw("</body></html>"),
11954                ],
11955                contains_boundary: false,
11956            },
11957        );
11958        let protocol = WebUIProtocol::new(fragments);
11959        let state = test_json!({
11960            "client": "visible",
11961            "serverOnly": "also preserved",
11962        });
11963        let handler = WebUIHandler::with_plugin(|| {
11964            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11965        });
11966        let mut writer = TestWriter::new();
11967        handler.handle(
11968            &protocol,
11969            &state,
11970            &RenderOptions::new("index.html", "/"),
11971            &mut writer,
11972        )?;
11973        let output = writer.get_content();
11974        assert!(output.contains(r#""client":"visible""#));
11975        assert!(output.contains(r#""serverOnly":"also preserved""#));
11976        Ok(())
11977    }
11978
11979    #[test]
11980    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11981        let mut fragments = HashMap::new();
11982        fragments.insert(
11983            "index.html".to_string(),
11984            FragmentList {
11985                fragments: vec![
11986                    WebUIFragment::raw("<html><body>"),
11987                    WebUIFragment::component("app-shell"),
11988                    structural_fragment("body_end"),
11989                    WebUIFragment::raw("</body></html>"),
11990                ],
11991                contains_boundary: false,
11992            },
11993        );
11994        fragments.insert(
11995            "app-shell".to_string(),
11996            FragmentList {
11997                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11998                contains_boundary: false,
11999            },
12000        );
12001        let mut protocol = WebUIProtocol::new(fragments);
12002        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12003        protocol.components.insert(
12004            "app-shell".to_string(),
12005            webui_protocol::ComponentData {
12006                hydration_mode: StateProjectionMode::All as i32,
12007                ..Default::default()
12008            },
12009        );
12010        let state = test_json!({
12011            "known": "value",
12012            "possiblyInherited": "must not be dropped",
12013        });
12014        let handler = WebUIHandler::with_plugin(|| {
12015            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12016        });
12017        let mut writer = TestWriter::new();
12018        handler.handle(
12019            &protocol,
12020            &state,
12021            &RenderOptions::new("index.html", "/"),
12022            &mut writer,
12023        )?;
12024        let output = writer.get_content();
12025        assert!(output.contains(r#""known":"value""#));
12026        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12027        Ok(())
12028    }
12029
12030    #[test]
12031    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12032        let mut fragments = HashMap::new();
12033        fragments.insert(
12034            "index.html".to_string(),
12035            FragmentList {
12036                fragments: vec![
12037                    WebUIFragment::raw("<html><body>"),
12038                    WebUIFragment::component("app-shell"),
12039                    structural_fragment("body_end"),
12040                    WebUIFragment::raw("</body></html>"),
12041                ],
12042                contains_boundary: false,
12043            },
12044        );
12045        fragments.insert(
12046            "app-shell".to_string(),
12047            FragmentList {
12048                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12049                contains_boundary: false,
12050            },
12051        );
12052        let mut protocol = WebUIProtocol::new(fragments);
12053        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12054        let state = test_json!({
12055            "known": "value",
12056            "serverOnly": "must not be dropped",
12057        });
12058        let handler = WebUIHandler::with_plugin(|| {
12059            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12060        });
12061        let mut writer = TestWriter::new();
12062        handler.handle(
12063            &protocol,
12064            &state,
12065            &RenderOptions::new("index.html", "/"),
12066            &mut writer,
12067        )?;
12068        let output = writer.get_content();
12069        assert!(output.contains(r#""known":"value""#));
12070        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12071        Ok(())
12072    }
12073
12074    #[test]
12075    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12076        let mut fragments = HashMap::new();
12077        fragments.insert(
12078            "index.html".to_string(),
12079            FragmentList {
12080                fragments: vec![
12081                    WebUIFragment::raw("<html><body>"),
12082                    WebUIFragment::component("app-shell"),
12083                    structural_fragment("body_end"),
12084                    WebUIFragment::raw("</body></html>"),
12085                ],
12086                contains_boundary: false,
12087            },
12088        );
12089        fragments.insert(
12090            "app-shell".to_string(),
12091            FragmentList {
12092                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12093                contains_boundary: false,
12094            },
12095        );
12096        let mut protocol = WebUIProtocol::new(fragments);
12097        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12098        protocol.components.insert(
12099            "app-shell".to_string(),
12100            webui_protocol::ComponentData {
12101                hydration_mode: i32::MAX,
12102                ..Default::default()
12103            },
12104        );
12105        let state = test_json!({
12106            "known": "value",
12107            "serverOnly": "must not be dropped",
12108        });
12109        let handler = WebUIHandler::with_plugin(|| {
12110            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12111        });
12112        let mut writer = TestWriter::new();
12113        handler.handle(
12114            &protocol,
12115            &state,
12116            &RenderOptions::new("index.html", "/"),
12117            &mut writer,
12118        )?;
12119        let output = writer.get_content();
12120        assert!(output.contains(r#""known":"value""#));
12121        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12122        Ok(())
12123    }
12124
12125    #[test]
12126    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12127        let mut protocol = WebUIProtocol::new(HashMap::new());
12128        protocol.components.insert(
12129            "app-shell".to_string(),
12130            webui_protocol::ComponentData {
12131                navigation_keys: vec!["selected".to_string()],
12132                ..Default::default()
12133            },
12134        );
12135
12136        match collect_navigation_state(&protocol, ["app-shell"]) {
12137            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12138            StateSelection::Full
12139            | StateSelection::KeyIds(_)
12140            | StateSelection::FullExceptKeyIds(_) => {
12141                panic!("legacy navigation keys should remain owned and projected")
12142            }
12143        }
12144    }
12145
12146    #[test]
12147    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12148        let mut protocol = WebUIProtocol::new(HashMap::new());
12149        protocol.components.insert(
12150            "app-shell".to_string(),
12151            webui_protocol::ComponentData::default(),
12152        );
12153
12154        assert!(matches!(
12155            collect_navigation_state(&protocol, ["app-shell"]),
12156            StateSelection::Full
12157        ));
12158    }
12159
12160    #[test]
12161    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12162        let mut fragments = HashMap::new();
12163        fragments.insert(
12164            "index.html".to_string(),
12165            FragmentList {
12166                fragments: vec![
12167                    WebUIFragment::raw("<html><body>".to_string()),
12168                    structural_fragment("body_end"),
12169                    WebUIFragment::raw("</body></html>".to_string()),
12170                ],
12171                contains_boundary: false,
12172            },
12173        );
12174        let mut protocol = WebUIProtocol::new(fragments);
12175        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12176        let state = test_json!({
12177            "title": "Legacy state",
12178            "serverOnly": "preserved",
12179        });
12180        let handler = WebUIHandler::with_plugin(|| {
12181            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12182        });
12183
12184        let mut writer = TestWriter::new();
12185        handler.handle(
12186            &protocol,
12187            &state,
12188            &RenderOptions::new("index.html", "/"),
12189            &mut writer,
12190        )?;
12191        assert!(writer.get_content().contains(r#""state":{}"#));
12192        assert!(!writer.get_content().contains("Legacy state"));
12193        assert!(!writer.get_content().contains("preserved"));
12194        Ok(())
12195    }
12196
12197    #[test]
12198    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12199        let mut fragments = HashMap::new();
12200        fragments.insert(
12201            "index.html".to_string(),
12202            FragmentList {
12203                fragments: vec![
12204                    WebUIFragment::raw("<html><body>"),
12205                    WebUIFragment::component("items-page"),
12206                    structural_fragment("body_end"),
12207                    WebUIFragment::raw("</body></html>"),
12208                ],
12209                contains_boundary: false,
12210            },
12211        );
12212        fragments.insert(
12213            "items-page".to_string(),
12214            FragmentList {
12215                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12216                contains_boundary: false,
12217            },
12218        );
12219        let mut protocol = WebUIProtocol::new(fragments);
12220        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12221        protocol.components.insert(
12222            "items-page".to_string(),
12223            webui_protocol::ComponentData {
12224                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12225                navigation_mode: Some(StateProjectionMode::Keys as i32),
12226                navigation_keys: vec!["items".into()],
12227                ..Default::default()
12228            },
12229        );
12230        let state = test_json!({
12231            "items": ["STATE_SENTINEL"],
12232            "serverOnly": "SECRET_SENTINEL",
12233        });
12234        let handler = WebUIHandler::with_plugin(|| {
12235            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12236        });
12237        let mut writer = TestWriter::new();
12238
12239        handler.handle(
12240            &protocol,
12241            &state,
12242            &RenderOptions::new("index.html", "/"),
12243            &mut writer,
12244        )?;
12245
12246        let output = writer.get_content();
12247        assert!(output.contains(r#""state":{}"#));
12248        assert!(!output.contains("STATE_SENTINEL"));
12249        assert!(!output.contains("SECRET_SENTINEL"));
12250        Ok(())
12251    }
12252
12253    #[test]
12254    fn write_selected_state_projects_and_escapes() {
12255        // `keep` is in the sorted key set and its value contains a `</` that
12256        // must be escaped; `drop` is absent and must be projected out.
12257        let state = test_json!({
12258            "drop": "secret",
12259            "keep": "</script><b>"
12260        });
12261        let keys = ["keep"];
12262        let selection = StateSelection::Keys(keys.to_vec());
12263        let mut sink = TestWriter::new();
12264        let mut scratch = Vec::new();
12265        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12266        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12267    }
12268
12269    #[test]
12270    fn write_selected_state_non_object_projection_emits_empty_object() {
12271        let state = test_json!("scalar state has nothing hydratable");
12272        let keys: [&str; 0] = [];
12273        let selection = StateSelection::Keys(keys.to_vec());
12274        let mut sink = TestWriter::new();
12275        let mut scratch = Vec::new();
12276        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12277        assert_eq!(sink.get_content(), "{}");
12278    }
12279
12280    #[test]
12281    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12282        let state = test_json!({
12283            "keptA": 1,
12284            "keptB": 2,
12285            "serverOnlyA": 3,
12286            "serverOnlyB": 4,
12287        });
12288        let keys = ["keptA", "keptA", "keptB", "missing"];
12289        let selection = StateSelection::Keys(keys.to_vec());
12290        let mut sink = TestWriter::new();
12291        let mut scratch = Vec::new();
12292        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12293        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12294    }
12295
12296    #[test]
12297    fn write_selected_state_map_first_matches_schema_first_output() {
12298        let state = test_json!({
12299            "keptA": 1,
12300            "keptB": 2,
12301        });
12302        let keys = ["keptA", "keptB", "missingA", "missingB"];
12303        let selection = StateSelection::Keys(keys.to_vec());
12304        let mut sink = TestWriter::new();
12305        let mut scratch = Vec::new();
12306        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12307        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12308    }
12309
12310    #[test]
12311    fn write_selected_state_full_preserves_and_escapes_state() {
12312        let state = test_json!({
12313            "serverOnly": "</script><b>",
12314            "value": 42,
12315        });
12316        let mut sink = TestWriter::new();
12317        let mut scratch = Vec::new();
12318        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12319        assert_eq!(
12320            sink.get_content(),
12321            r#"{"serverOnly":"<\/script><b>","value":42}"#
12322        );
12323    }
12324
12325    #[test]
12326    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12327        let preloads = vec![ComponentAssetStylePreload {
12328            root: "lazy-panel".to_string(),
12329            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12330        }];
12331
12332        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12333
12334        assert_eq!(
12335            manifest,
12336            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12337        );
12338    }
12339
12340    #[test]
12341    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12342        // Simulates several checkpoint bootstraps serialized in one render: a
12343        // single request-local buffer must grow once for the largest payload and
12344        // then be reused for subsequent payloads without reallocating. This is
12345        // the deterministic seam behind the streaming path's allocation gate —
12346        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
12347        let mut sink = TestWriter::new();
12348        let mut scratch: Vec<u8> = Vec::new();
12349        // No allocation until serialization actually needs it.
12350        assert_eq!(scratch.capacity(), 0);
12351
12352        // The largest payload first establishes the high-water capacity.
12353        let mut large = serde_json::Map::new();
12354        for index in 0..64 {
12355            large.insert(
12356                format!("component_{index:04}"),
12357                Value::String("x".repeat(48)),
12358            );
12359        }
12360        let large = Value::Object(large);
12361        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12362        let high_water = scratch.capacity();
12363        assert!(high_water > 0);
12364
12365        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
12366        // never grows (no per-checkpoint allocation) and never shrinks.
12367        for seq in 0..8 {
12368            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12369            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12370            assert_eq!(
12371                scratch.capacity(),
12372                high_water,
12373                "scratch reallocated on checkpoint {seq}"
12374            );
12375        }
12376    }
12377
12378    #[test]
12379    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12380        let mut fragments = HashMap::new();
12381        fragments.insert(
12382            "index.html".to_string(),
12383            FragmentList {
12384                fragments: vec![
12385                    WebUIFragment::raw("<html><body>"),
12386                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12387                        path: "/".to_string(),
12388                        fragment_id: "home-page".to_string(),
12389                        exact: true,
12390                        ..Default::default()
12391                    }),
12392                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12393                        path: "/admin".to_string(),
12394                        fragment_id: "admin-page".to_string(),
12395                        exact: true,
12396                        ..Default::default()
12397                    }),
12398                    structural_fragment("body_end"),
12399                    WebUIFragment::raw("</body></html>"),
12400                ],
12401
12402                contains_boundary: false,
12403            },
12404        );
12405        fragments.insert(
12406            "home-page".to_string(),
12407            FragmentList {
12408                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12409                contains_boundary: false,
12410            },
12411        );
12412        fragments.insert(
12413            "admin-page".to_string(),
12414            FragmentList {
12415                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12416                contains_boundary: false,
12417            },
12418        );
12419
12420        let mut protocol = WebUIProtocol::new(fragments);
12421        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12422        protocol.components.insert(
12423            "home-page".to_string(),
12424            webui_protocol::ComponentData {
12425                template_json: "{}".to_string(),
12426                hydration_mode: StateProjectionMode::Keys as i32,
12427                hydration_keys: vec!["homeTitle".to_string()],
12428                ..Default::default()
12429            },
12430        );
12431        protocol.components.insert(
12432            "admin-page".to_string(),
12433            webui_protocol::ComponentData {
12434                template_json: "{}".to_string(),
12435                hydration_mode: StateProjectionMode::Keys as i32,
12436                hydration_keys: vec!["adminToken".to_string()],
12437                ..Default::default()
12438            },
12439        );
12440        let state = test_json!({
12441            "homeTitle": "Welcome",
12442            "adminToken": "TOP_SECRET_SENTINEL",
12443        });
12444        let handler = WebUIHandler::with_plugin(|| {
12445            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12446        });
12447        let mut writer = TestWriter::new();
12448        handler.handle(
12449            &protocol,
12450            &state,
12451            &RenderOptions::new("index.html", "/"),
12452            &mut writer,
12453        )?;
12454        let output = writer.get_content();
12455        assert!(output.contains(r#""homeTitle":"Welcome""#));
12456        assert!(!output.contains("TOP_SECRET_SENTINEL"));
12457        assert!(!output.contains(r#""adminToken""#));
12458        Ok(())
12459    }
12460
12461    #[test]
12462    fn test_component_attr_name_aria() {
12463        // component_attr_name correctly maps ARIA attributes via the shared table
12464        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12465        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12466        assert_eq!(
12467            component_attr_name("aria-activedescendant"),
12468            "ariaActiveDescendant"
12469        );
12470        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12471        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12472    }
12473
12474    #[test]
12475    fn test_component_attr_name_html_global() {
12476        assert_eq!(component_attr_name("readonly"), "readOnly");
12477        assert_eq!(component_attr_name("tabindex"), "tabIndex");
12478        assert_eq!(component_attr_name("accesskey"), "accessKey");
12479        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12480        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12481        assert_eq!(component_attr_name("inputmode"), "inputMode");
12482        assert_eq!(component_attr_name("maxlength"), "maxLength");
12483        assert_eq!(component_attr_name("minlength"), "minLength");
12484        assert_eq!(component_attr_name("novalidate"), "noValidate");
12485        assert_eq!(component_attr_name("formaction"), "formAction");
12486        assert_eq!(component_attr_name("ismap"), "isMap");
12487        assert_eq!(component_attr_name("usemap"), "useMap");
12488    }
12489
12490    #[test]
12491    fn test_component_attr_name_strips_colon() {
12492        assert_eq!(component_attr_name(":readonly"), "readOnly");
12493        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12494        assert_eq!(component_attr_name(":data-title"), "dataTitle");
12495    }
12496
12497    #[test]
12498    fn test_component_attr_name_regular() {
12499        assert_eq!(component_attr_name("data-title"), "dataTitle");
12500        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12501        assert_eq!(component_attr_name("simple"), "simple");
12502    }
12503
12504    // ── allowed_query SSR emission tests ─────────────────────────────
12505
12506    fn make_query_route_protocol() -> WebUIProtocol {
12507        use webui_protocol::WebUiFragmentRoute;
12508
12509        let mut fragments = HashMap::new();
12510
12511        fragments.insert(
12512            "index.html".to_string(),
12513            FragmentList {
12514                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12515                    path: "/".into(),
12516                    fragment_id: "app-shell".into(),
12517                    exact: false,
12518                    children: vec![
12519                        WebUiFragmentRoute {
12520                            path: "compose".into(),
12521                            fragment_id: "compose-page".into(),
12522                            exact: true,
12523                            allowed_query: "action,to,subject".into(),
12524                            keep_alive: false,
12525                            ..Default::default()
12526                        },
12527                        WebUiFragmentRoute {
12528                            path: "settings".into(),
12529                            fragment_id: "settings-page".into(),
12530                            exact: true,
12531                            keep_alive: false,
12532                            ..Default::default()
12533                        },
12534                    ],
12535                    keep_alive: false,
12536                    ..Default::default()
12537                })],
12538
12539                contains_boundary: false,
12540            },
12541        );
12542
12543        fragments.insert(
12544            "app-shell".to_string(),
12545            FragmentList {
12546                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12547                contains_boundary: false,
12548            },
12549        );
12550        fragments.insert(
12551            "compose-page".to_string(),
12552            FragmentList {
12553                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12554                contains_boundary: false,
12555            },
12556        );
12557        fragments.insert(
12558            "settings-page".to_string(),
12559            FragmentList {
12560                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12561                contains_boundary: false,
12562            },
12563        );
12564
12565        WebUIProtocol::new(fragments)
12566    }
12567
12568    #[test]
12569    fn test_matched_route_omits_query_attr_from_dom() {
12570        let protocol = make_query_route_protocol();
12571        let state = test_json!({});
12572        let handler = WebUIHandler::new();
12573        let mut writer = TestWriter::new();
12574
12575        handler
12576            .handle(
12577                &protocol,
12578                &state,
12579                &RenderOptions::new("index.html", "/compose"),
12580                &mut writer,
12581            )
12582            .expect("render failed");
12583
12584        let html = writer.get_content();
12585        // query attr is no longer in DOM — it's in the SSR chain JSON instead
12586        assert!(
12587            !html.contains(r#"query="action,to,subject""#),
12588            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12589        );
12590    }
12591
12592    #[test]
12593    fn test_nonmatched_route_omits_query_attr_from_dom() {
12594        let protocol = make_query_route_protocol();
12595        let state = test_json!({});
12596        let handler = WebUIHandler::new();
12597        let mut writer = TestWriter::new();
12598
12599        handler
12600            .handle(
12601                &protocol,
12602                &state,
12603                &RenderOptions::new("index.html", "/settings"),
12604                &mut writer,
12605            )
12606            .expect("render failed");
12607
12608        let html = writer.get_content();
12609        // query attr should not be on hidden siblings either
12610        assert!(
12611            !html.contains(r#"query="#),
12612            "hidden route should not have query attr: {html}"
12613        );
12614    }
12615
12616    #[test]
12617    fn test_route_without_query_has_no_query_attr() {
12618        let protocol = make_query_route_protocol();
12619        let state = test_json!({});
12620        let handler = WebUIHandler::new();
12621        let mut writer = TestWriter::new();
12622
12623        handler
12624            .handle(
12625                &protocol,
12626                &state,
12627                &RenderOptions::new("index.html", "/settings"),
12628                &mut writer,
12629            )
12630            .expect("render failed");
12631
12632        let html = writer.get_content();
12633        // Find the settings route element and verify it has no query attr
12634        let settings_idx = html
12635            .find(r#"component="settings-page""#)
12636            .expect("settings route should exist");
12637        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12638        assert!(
12639            !settings_tag.contains("query="),
12640            "route without allowed_query should not emit query attr: {settings_tag}"
12641        );
12642    }
12643
12644    // ── Per-render head_inject / body_inject (replaces the byte-scanner
12645    //    InjectingStreamingWriter approach with structural signal-based
12646    //    injection) ───────────────────────────────────────────────────
12647
12648    fn build_head_body_protocol() -> WebUIProtocol {
12649        let mut fragments = HashMap::new();
12650        fragments.insert(
12651            "index.html".to_string(),
12652            FragmentList {
12653                fragments: vec![
12654                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12655                    structural_fragment("head_end"),
12656                    WebUIFragment::raw("</head><body>hello".to_string()),
12657                    structural_fragment("body_end"),
12658                    WebUIFragment::raw("</body></html>".to_string()),
12659                ],
12660                contains_boundary: false,
12661            },
12662        );
12663        WebUIProtocol::new(fragments)
12664    }
12665
12666    #[test]
12667    fn head_inject_emits_at_head_end_boundary() {
12668        let protocol = build_head_body_protocol();
12669        let state = test_json!({});
12670        let mut writer = TestWriter::new();
12671        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12672        handle(&protocol, &state, &opts, &mut writer).unwrap();
12673        let html = writer.get_content();
12674        // The inject must appear immediately before `</head>`.
12675        let inject_idx = html
12676            .find("<link rel=preload>")
12677            .expect("inject HTML missing");
12678        let head_close_idx = html.find("</head>").expect("</head> missing");
12679        assert!(
12680            inject_idx < head_close_idx,
12681            "head_inject must appear before </head>: {html}"
12682        );
12683        // No duplicate.
12684        assert_eq!(html.matches("<link rel=preload>").count(), 1);
12685    }
12686
12687    #[test]
12688    fn component_render_policy_css_emits_once_in_head_with_nonce() {
12689        let mut protocol = build_head_body_protocol();
12690        protocol.component_render_css = concat!(
12691            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12692            "contain-intrinsic-block-size:auto 18rem;}"
12693        )
12694        .to_string();
12695        let state = test_json!({});
12696        let mut writer = TestWriter::new();
12697        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12698        handle(&protocol, &state, &opts, &mut writer).unwrap();
12699        let html = writer.get_content();
12700
12701        let style = concat!(
12702            r#"<style data-webui-render-policy nonce="test-nonce">"#,
12703            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12704            "contain-intrinsic-block-size:auto 18rem;}</style>"
12705        );
12706        let style_index = html.find(style).expect("render policy style missing");
12707        let head_close = html.find("</head>").expect("</head> missing");
12708        assert!(style_index < head_close);
12709        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12710    }
12711
12712    #[test]
12713    fn body_inject_emits_at_body_end_boundary() {
12714        let protocol = build_head_body_protocol();
12715        let state = test_json!({});
12716        let mut writer = TestWriter::new();
12717        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12718        handle(&protocol, &state, &opts, &mut writer).unwrap();
12719        let html = writer.get_content();
12720        let inject_idx = html
12721            .find("<script>lr</script>")
12722            .expect("inject HTML missing");
12723        let body_close_idx = html.find("</body>").expect("</body> missing");
12724        assert!(
12725            inject_idx < body_close_idx,
12726            "body_inject must appear before </body>: {html}"
12727        );
12728        assert_eq!(html.matches("<script>lr</script>").count(), 1);
12729    }
12730
12731    // ── Reserved `$webui` state inject namespace ──────────────────────
12732
12733    /// Protocol carrying all three structural boundaries, so `body_start`
12734    /// placement can be asserted alongside `head_end` / `body_end`.
12735    fn build_all_boundaries_protocol() -> WebUIProtocol {
12736        let mut fragments = HashMap::new();
12737        fragments.insert(
12738            "index.html".to_string(),
12739            FragmentList {
12740                fragments: vec![
12741                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12742                    structural_fragment("head_end"),
12743                    WebUIFragment::raw("</head><body>".to_string()),
12744                    structural_fragment("body_start"),
12745                    WebUIFragment::raw("hello".to_string()),
12746                    structural_fragment("body_end"),
12747                    WebUIFragment::raw("</body></html>".to_string()),
12748                ],
12749                contains_boundary: false,
12750            },
12751        );
12752        WebUIProtocol::new(fragments)
12753    }
12754
12755    fn state_inject_options<'a>() -> RenderOptions<'a> {
12756        RenderOptions::new("index.html", "/")
12757    }
12758
12759    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12760        let mut writer = TestWriter::new();
12761        handle(protocol, state, options, &mut writer).unwrap();
12762        writer.get_content().to_string()
12763    }
12764
12765    #[test]
12766    fn state_inject_emits_at_every_structural_boundary() {
12767        let protocol = build_all_boundaries_protocol();
12768        let state = test_json!({
12769            "$webui": {
12770                "headEnd": "<meta name=he>",
12771                "bodyStart": "<span id=bs></span>",
12772                "bodyEnd": "<script>be</script>",
12773            }
12774        });
12775        let html = render_with(&protocol, &state, &state_inject_options());
12776
12777        let head_end = html.find("<meta name=he>").expect("headEnd missing");
12778        let head_close = html.find("</head>").expect("</head> missing");
12779        let body_open = html.find("<body>").expect("<body> missing");
12780        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12781        let hello = html.find("hello").expect("body content missing");
12782        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12783        let body_close = html.find("</body>").expect("</body> missing");
12784
12785        assert!(
12786            head_end < head_close,
12787            "headEnd must precede </head>: {html}"
12788        );
12789        assert!(
12790            body_open < body_start && body_start < hello,
12791            "bodyStart must sit immediately after <body>: {html}"
12792        );
12793        assert!(
12794            hello < body_end && body_end < body_close,
12795            "bodyEnd must precede </body>: {html}"
12796        );
12797
12798        for needle in [
12799            "<meta name=he>",
12800            "<span id=bs></span>",
12801            "<script>be</script>",
12802        ] {
12803            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12804        }
12805    }
12806
12807    #[test]
12808    fn state_inject_follows_render_options_inject() {
12809        let protocol = build_all_boundaries_protocol();
12810        let state = test_json!({
12811            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12812        });
12813        let options = RenderOptions::new("index.html", "/")
12814            .with_head_inject("<!--opt-he-->")
12815            .with_body_inject("<!--opt-be-->");
12816        let html = render_with(&protocol, &state, &options);
12817
12818        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12819        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12820        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12821        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12822
12823        assert!(
12824            opt_he < state_he,
12825            "RenderOptions head_inject must precede the state-supplied value: {html}"
12826        );
12827        assert!(
12828            opt_be < state_be,
12829            "RenderOptions body_inject must precede the state-supplied value: {html}"
12830        );
12831    }
12832
12833    #[test]
12834    fn malformed_state_inject_values_are_inert() {
12835        let protocol = build_all_boundaries_protocol();
12836        // Absent key, wrong container type, and per-member null / empty /
12837        // non-string values must all render without output and without error.
12838        for state in [
12839            test_json!({}),
12840            test_json!({ "$webui": "not-an-object" }),
12841            test_json!({ "$webui": [] }),
12842            test_json!({ "$webui": null }),
12843            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12844            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12845        ] {
12846            let html = render_with(&protocol, &state, &state_inject_options());
12847            assert!(
12848                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12849                "malformed reserved state must render the document unchanged: {html}"
12850            );
12851            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12852            assert!(!html.contains("42"), "non-string member leaked: {html}");
12853        }
12854    }
12855
12856    #[test]
12857    fn state_inject_never_reaches_the_hydration_payload() {
12858        let protocol = build_all_boundaries_protocol();
12859        let state = test_json!({
12860            "visible": "keep",
12861            "$webui": { "bodyEnd": "<script>secret</script>" }
12862        });
12863        let handler = WebUIHandler::with_plugin(|| {
12864            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12865        });
12866        let runtime = Protocol::new(protocol.clone());
12867        let mut writer = TestWriter::new();
12868        handler
12869            .render(&runtime, &state, &state_inject_options(), &mut writer)
12870            .unwrap();
12871        let html = writer.get_content();
12872
12873        let data_start = html
12874            .find(r#"<script type="application/json" id="webui-data""#)
12875            .expect("hydration block missing");
12876        let data_end = html[data_start..]
12877            .find("</script>")
12878            .map(|offset| data_start + offset)
12879            .expect("hydration block never closes");
12880        let payload = &html[data_start..data_end];
12881        assert!(
12882            !payload.contains("$webui"),
12883            "reserved key must be stripped from the hydration payload: {payload}"
12884        );
12885        assert!(
12886            payload.contains("visible"),
12887            "ordinary state must survive the filter: {payload}"
12888        );
12889        // Per the documented precedence the injected HTML is emitted after
12890        // the built-in hydration block, and still before `</body>`.
12891        let inject = html
12892            .find("<script>secret</script>")
12893            .expect("inject missing");
12894        let body_close = html.find("</body>").expect("</body> missing");
12895        assert!(
12896            data_end < inject && inject < body_close,
12897            "state bodyEnd must follow the hydration block and precede </body>: {html}"
12898        );
12899    }
12900
12901    #[test]
12902    fn projected_hydration_strips_reserved_state_inject_key() {
12903        let mut fragments = HashMap::new();
12904        fragments.insert(
12905            "index.html".to_string(),
12906            FragmentList {
12907                fragments: vec![
12908                    WebUIFragment::raw("<html><body>"),
12909                    WebUIFragment::component("app-shell"),
12910                    structural_fragment("body_end"),
12911                    WebUIFragment::raw("</body></html>"),
12912                ],
12913                contains_boundary: false,
12914            },
12915        );
12916        fragments.insert(
12917            "app-shell".to_string(),
12918            FragmentList {
12919                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12920                contains_boundary: false,
12921            },
12922        );
12923        let mut document = WebUIProtocol::new(fragments);
12924        document.initial_state_strategy = InitialStateStrategy::Components as i32;
12925        document.components.insert(
12926            "app-shell".to_string(),
12927            webui_protocol::ComponentData {
12928                hydration_mode: StateProjectionMode::Keys as i32,
12929                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12930                ..Default::default()
12931            },
12932        );
12933        let state = test_json!({
12934            "$webui": { "bodyEnd": "<script>secret</script>" },
12935            "serverOnly": "drop",
12936            "visible": "keep",
12937        });
12938        let handler = WebUIHandler::with_plugin(|| {
12939            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12940        });
12941        let mut writer = TestWriter::new();
12942
12943        handler
12944            .render(
12945                &Protocol::new(document),
12946                &state,
12947                &RenderOptions::new("index.html", "/"),
12948                &mut writer,
12949            )
12950            .unwrap();
12951        let html = writer.get_content();
12952        let data_start = html
12953            .find(r#"<script type="application/json" id="webui-data""#)
12954            .expect("hydration block missing");
12955        let data_end = html[data_start..]
12956            .find("</script>")
12957            .map(|offset| data_start + offset)
12958            .expect("hydration block never closes");
12959        let payload = &html[data_start..data_end];
12960
12961        assert!(
12962            !payload.contains(STATE_INJECT_KEY),
12963            "reserved key leaked: {payload}"
12964        );
12965        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12966        assert!(!payload.contains("serverOnly"), "{payload}");
12967        assert!(html.contains("<script>secret</script>"), "{html}");
12968    }
12969
12970    #[test]
12971    fn write_selected_state_strips_reserved_key_from_full_state() {
12972        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12973        let mut sink = TestWriter::new();
12974        let mut scratch = Vec::new();
12975        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12976        let json = sink.get_content();
12977        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12978        assert!(
12979            json.contains("\"a\":1") && json.contains("\"z\":2"),
12980            "{json}"
12981        );
12982    }
12983
12984    #[test]
12985    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12986        // The streaming record projects through interned key IDs, so the
12987        // reserved inject key must be filtered on that path too.
12988        let mut protocol = WebUIProtocol::new(HashMap::new());
12989        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12990        protocol.fragments.insert(
12991            "keep-card".to_string(),
12992            webui_protocol::FragmentList::default(),
12993        );
12994        protocol.components.insert(
12995            "keep-card".to_string(),
12996            webui_protocol::ComponentData {
12997                hydration_mode: StateProjectionMode::Keys as i32,
12998                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12999                ..Default::default()
13000            },
13001        );
13002        let protocol = Protocol::new(protocol);
13003        let index = protocol.component_reachability();
13004        let component = protocol.component_index()["keep-card"];
13005        let mut ids = Vec::new();
13006        assert!(!collect_hydration_key_ids_into(
13007            protocol.protocol(),
13008            index,
13009            [component],
13010            &mut ids
13011        ));
13012
13013        let state = test_json!({
13014            "$webui": { "bodyEnd": "<b>x</b>" },
13015            "keep": 1,
13016        });
13017        let mut sink = TestWriter::new();
13018        let mut scratch = Vec::new();
13019
13020        write_selected_state(
13021            &mut sink,
13022            &mut scratch,
13023            &state,
13024            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13025        )
13026        .unwrap();
13027
13028        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13029    }
13030
13031    #[test]
13032    fn write_selected_state_full_is_unchanged_without_reserved_key() {
13033        let state = test_json!({ "a": 1, "z": 2 });
13034        let mut sink = TestWriter::new();
13035        let mut scratch = Vec::new();
13036        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13037        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13038    }
13039
13040    #[test]
13041    fn body_start_hook_dedupes_on_malformed_protocol() {
13042        let mut fragments = HashMap::new();
13043        fragments.insert(
13044            "index.html".to_string(),
13045            FragmentList {
13046                fragments: vec![
13047                    WebUIFragment::raw("<html><head></head><body>".to_string()),
13048                    structural_fragment("body_start"),
13049                    structural_fragment("body_start"),
13050                    WebUIFragment::raw("</body></html>".to_string()),
13051                ],
13052                contains_boundary: false,
13053            },
13054        );
13055        let protocol = WebUIProtocol::new(fragments);
13056        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13057        let html = render_with(&protocol, &state, &state_inject_options());
13058        assert_eq!(
13059            html.matches("<i>once</i>").count(),
13060            1,
13061            "duplicate body_start signals must not duplicate the inject: {html}"
13062        );
13063    }
13064
13065    #[test]
13066    fn injects_are_no_op_when_unset() {
13067        let protocol = build_head_body_protocol();
13068        let state = test_json!({});
13069        let mut writer = TestWriter::new();
13070        handle(
13071            &protocol,
13072            &state,
13073            &RenderOptions::new("index.html", "/"),
13074            &mut writer,
13075        )
13076        .unwrap();
13077        let html = writer.get_content();
13078        assert!(!html.contains("<link rel=preload>"));
13079        assert!(!html.contains("<script>lr</script>"));
13080    }
13081
13082    #[test]
13083    fn empty_inject_string_treated_as_unset() {
13084        let protocol = build_head_body_protocol();
13085        let state = test_json!({});
13086        let mut writer = TestWriter::new();
13087        let opts = RenderOptions::new("index.html", "/")
13088            .with_head_inject("")
13089            .with_body_inject("");
13090        handle(&protocol, &state, &opts, &mut writer).unwrap();
13091        // No injection happens — empty strings are normalised to None
13092        // by the builder, so the output is identical to the no-options case.
13093        let html = writer.get_content();
13094        assert!(html.contains("</head>"));
13095        assert!(html.contains("</body>"));
13096    }
13097
13098    #[test]
13099    fn inject_html_is_passed_through_verbatim() {
13100        // The handler does NOT escape the inject string — hosts pass
13101        // raw HTML they trust. This test pins that contract: a `<` in
13102        // the inject is emitted as-is, not encoded as `&lt;`.
13103        let protocol = build_head_body_protocol();
13104        let state = test_json!({});
13105        let mut writer = TestWriter::new();
13106        let opts =
13107            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13108        handle(&protocol, &state, &opts, &mut writer).unwrap();
13109        assert!(writer.get_content().contains("<script>var x=1;</script>"));
13110    }
13111
13112    /// Both injects fire and appear at the correct structural
13113    /// positions. Critically, this is robust against `</head>` /
13114    /// `</body>` literals appearing elsewhere in the document — the
13115    /// signal-based emitter cannot mis-fire on byte patterns inside
13116    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
13117    /// previous byte-scanner could).
13118    #[test]
13119    fn injects_robust_against_marker_literals_in_content() {
13120        let mut fragments = HashMap::new();
13121        // The body intentionally contains `</body>` and `</head>`
13122        // literals before the actual structural close — these came
13123        // from a (hypothetical) iframe srcdoc or comment.
13124        fragments.insert(
13125            "index.html".to_string(),
13126            FragmentList {
13127                fragments: vec![
13128                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13129                    structural_fragment("head_end"),
13130                    WebUIFragment::raw(
13131                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13132                    ),
13133                    structural_fragment("body_end"),
13134                    WebUIFragment::raw("</body></html>".to_string()),
13135                ],
13136                contains_boundary: false,
13137            },
13138        );
13139        let protocol = WebUIProtocol::new(fragments);
13140        let state = test_json!({});
13141        let mut writer = TestWriter::new();
13142        let opts = RenderOptions::new("index.html", "/")
13143            .with_head_inject("<HEAD-INJ>")
13144            .with_body_inject("<BODY-INJ>");
13145        handle(&protocol, &state, &opts, &mut writer).unwrap();
13146        let html = writer.get_content();
13147        // The head inject sits between `<title>x</title>` and the
13148        // first `</head>` — the structural one, not the comment one.
13149        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13150        let head_close_idx = html.find("</head>").expect("</head> missing");
13151        assert!(head_inj_idx < head_close_idx);
13152        // The body inject sits before the structural `</body>` — NOT
13153        // before the `</body>` literal in the comment (which would
13154        // require the inject to appear inside `<p>hi</p>` somewhere).
13155        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13156        // Find the LAST `</body>` (the structural one).
13157        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13158        assert!(
13159            body_inj_idx < body_close_idx,
13160            "body_inject must precede the structural </body>: {html}"
13161        );
13162        // And the comment is preserved verbatim.
13163        assert!(html.contains("<!-- </body> </head> -->"));
13164    }
13165
13166    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13167    /// same render. Each fires at the correct structural boundary and
13168    /// neither leaks into the other's region.
13169    #[test]
13170    fn both_injects_fire_at_correct_boundaries() {
13171        let protocol = build_head_body_protocol();
13172        let state = test_json!({});
13173        let mut writer = TestWriter::new();
13174        let opts = RenderOptions::new("index.html", "/")
13175            .with_head_inject("<META-HEAD>")
13176            .with_body_inject("<SCRIPT-BODY>");
13177        handle(&protocol, &state, &opts, &mut writer).unwrap();
13178        let html = writer.get_content();
13179        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13180        let head_close = html.find("</head>").expect("</head> missing");
13181        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13182        let body_close = html.find("</body>").expect("</body> missing");
13183        assert!(head_idx < head_close, "head_inject before </head>");
13184        assert!(head_close < body_idx, "body_inject after </head>");
13185        assert!(body_idx < body_close, "body_inject before </body>");
13186        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13187        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13188    }
13189
13190    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13191    /// emitting `head_end` and `body_end` more than once must NOT
13192    /// duplicate the host inject HTML. Without the dedup guard,
13193    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13194    /// 1000 duplicate signals = 1 GiB output).
13195    #[test]
13196    fn injects_dedupe_against_duplicate_signals() {
13197        let mut fragments = HashMap::new();
13198        fragments.insert(
13199            "index.html".to_string(),
13200            FragmentList {
13201                fragments: vec![
13202                    WebUIFragment::raw("<html><head>".to_string()),
13203                    structural_fragment("head_end"),
13204                    structural_fragment("head_end"), // duplicate
13205                    structural_fragment("head_end"), // triplicate
13206                    WebUIFragment::raw("</head><body>".to_string()),
13207                    structural_fragment("body_end"),
13208                    structural_fragment("body_end"), // duplicate
13209                    WebUIFragment::raw("</body></html>".to_string()),
13210                ],
13211                contains_boundary: false,
13212            },
13213        );
13214        let protocol = WebUIProtocol::new(fragments);
13215        let state = test_json!({});
13216        let mut writer = TestWriter::new();
13217        let opts = RenderOptions::new("index.html", "/")
13218            .with_head_inject("<HINJ>")
13219            .with_body_inject("<BINJ>");
13220        handle(&protocol, &state, &opts, &mut writer).unwrap();
13221        let html = writer.get_content();
13222        assert_eq!(
13223            html.matches("<HINJ>").count(),
13224            1,
13225            "head_inject must emit exactly once even with duplicate head_end signals"
13226        );
13227        assert_eq!(
13228            html.matches("<BINJ>").count(),
13229            1,
13230            "body_inject must emit exactly once even with duplicate body_end signals"
13231        );
13232    }
13233
13234    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13235    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13236    /// never fire). Verifies the injects are no-ops, not panics.
13237    #[test]
13238    fn injects_no_op_when_no_head_or_body_signals() {
13239        let mut fragments = HashMap::new();
13240        fragments.insert(
13241            "index.html".to_string(),
13242            FragmentList {
13243                fragments: vec![WebUIFragment::raw(
13244                    "<my-component>hi</my-component>".to_string(),
13245                )],
13246                contains_boundary: false,
13247            },
13248        );
13249        let protocol = WebUIProtocol::new(fragments);
13250        let state = test_json!({});
13251        let mut writer = TestWriter::new();
13252        let opts = RenderOptions::new("index.html", "/")
13253            .with_head_inject("<HINJ>")
13254            .with_body_inject("<BINJ>");
13255        handle(&protocol, &state, &opts, &mut writer).unwrap();
13256        let html = writer.get_content();
13257        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13258        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13259        assert!(html.contains("<my-component>"));
13260    }
13261
13262    /// Coverage-19: the handler's `&self` is shared across threads.
13263    /// Two concurrent renders with different inject values must NOT
13264    /// cross-contaminate (each thread sees only its own inject).
13265    /// Per-render mutable state lives on the `WebUIProcessContext`,
13266    /// which is stack-allocated per call.
13267    #[test]
13268    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13269        let protocol = std::sync::Arc::new(build_head_body_protocol());
13270        let state = std::sync::Arc::new(test_json!({}));
13271        let handler = std::sync::Arc::new(WebUIHandler::new());
13272
13273        const N_THREADS: usize = 16;
13274        let mut handles = Vec::with_capacity(N_THREADS);
13275        for tid in 0..N_THREADS {
13276            let h = std::sync::Arc::clone(&handler);
13277            let p = std::sync::Arc::clone(&protocol);
13278            let s = std::sync::Arc::clone(&state);
13279            handles.push(std::thread::spawn(move || {
13280                let head = format!("<HEAD-T{tid}>");
13281                let body = format!("<BODY-T{tid}>");
13282                let mut writer = TestWriter::new();
13283                let opts = RenderOptions::new("index.html", "/")
13284                    .with_head_inject(&head)
13285                    .with_body_inject(&body);
13286                h.handle(&p, &s, &opts, &mut writer).unwrap();
13287                let html = writer.get_content();
13288                // Must contain my own injects exactly once.
13289                assert_eq!(html.matches(&head).count(), 1);
13290                assert_eq!(html.matches(&body).count(), 1);
13291                // Must NOT contain any other thread's inject.
13292                for other in 0..N_THREADS {
13293                    if other == tid {
13294                        continue;
13295                    }
13296                    let other_head = format!("<HEAD-T{other}>");
13297                    let other_body = format!("<BODY-T{other}>");
13298                    assert!(
13299                        !html.contains(&other_head),
13300                        "tid {tid} saw {other}'s head_inject"
13301                    );
13302                    assert!(
13303                        !html.contains(&other_body),
13304                        "tid {tid} saw {other}'s body_inject"
13305                    );
13306                }
13307            }));
13308        }
13309        for h in handles {
13310            h.join().expect("worker panicked");
13311        }
13312    }
13313
13314    /// Coverage-17: a large (1 MiB) head_inject must round-trip
13315    /// correctly without panic, truncation, or excessive overhead.
13316    /// (No size cap is enforced by the handler — the host owns the
13317    /// safety contract; see `with_head_inject` doc comment.)
13318    #[test]
13319    fn large_inject_roundtrips_without_truncation() {
13320        let protocol = build_head_body_protocol();
13321        let state = test_json!({});
13322        let mut writer = TestWriter::new();
13323        let big = "x".repeat(1024 * 1024);
13324        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13325        handle(&protocol, &state, &opts, &mut writer).unwrap();
13326        let html = writer.get_content();
13327        assert!(
13328            html.contains(&big),
13329            "large head_inject must be present verbatim ({} bytes)",
13330            big.len()
13331        );
13332        // Sanity: only one copy.
13333        assert_eq!(html.matches(&big).count(), 1);
13334    }
13335
13336    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
13337    /// matching the empty-string semantics of `with_head_inject` /
13338    /// `with_body_inject`. An empty content attribute is browser-
13339    /// ignored noise.
13340    #[test]
13341    fn empty_nonce_treated_as_unset() {
13342        let protocol = build_head_body_protocol();
13343        let state = test_json!({});
13344        let mut writer = TestWriter::new();
13345        let opts = RenderOptions::new("index.html", "/").with_nonce("");
13346        handle(&protocol, &state, &opts, &mut writer).unwrap();
13347        assert!(
13348            !writer.get_content().contains("webui-nonce"),
13349            "empty nonce must not emit <meta name=\"webui-nonce\">"
13350        );
13351    }
13352
13353    /// Regression for the bug Akrosh caught: the `pub` fields on
13354    /// `RenderOptions` let a caller bypass the `with_*` builder
13355    /// normalisation, e.g.:
13356    ///
13357    /// ```ignore
13358    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
13359    /// ```
13360    ///
13361    /// Without defensive normalisation at handler init, this would
13362    /// emit `<script nonce="">` on every inline script. Under a
13363    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
13364    /// empty nonce is a HARD CSP failure that blocks every inline
13365    /// script — a complete inline-script-execution outage.
13366    ///
13367    /// The handler now treats `Some("")` identically to `None` for
13368    /// all three injection points (nonce / head_inject / body_inject)
13369    /// regardless of how the option was populated.
13370    #[test]
13371    fn empty_field_bypass_is_normalised_at_handler_init() {
13372        let protocol = build_head_body_protocol();
13373        let state = test_json!({});
13374
13375        // Bypass the `with_nonce` builder by writing the field directly.
13376        let opts_with_empty_nonce = RenderOptions {
13377            nonce: Some(""),
13378            ..RenderOptions::new("index.html", "/")
13379        };
13380        let mut writer = TestWriter::new();
13381        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13382        let html = writer.get_content();
13383        assert!(
13384            !html.contains("webui-nonce"),
13385            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13386        );
13387        assert!(
13388            !html.contains("nonce=\"\""),
13389            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13390        );
13391
13392        // Same defence for inject fields.
13393        let opts_with_empty_injects = RenderOptions {
13394            head_inject: Some(""),
13395            body_inject: Some(""),
13396            ..RenderOptions::new("index.html", "/")
13397        };
13398        let mut writer = TestWriter::new();
13399        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13400        // No assertion needed beyond "doesn't panic and doesn't emit
13401        // empty inject markers" — the head_end / body_end paths must
13402        // treat the empty inject as no-op the same way the builder does.
13403    }
13404
13405    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
13406    /// path optimisation (insert key once + `get_mut`-swap value
13407    /// in-place) was suspected of corrupting the outer scope when a
13408    /// nested `<for>` loop reuses the same variable name. This test
13409    /// proves the optimisation is correct under that condition by
13410    /// requiring the outer `item` to be visible before, between, and
13411    /// after the inner loop, with its value preserved across inner
13412    /// iterations.
13413    ///
13414    /// Trace through the optimisation on `outer = [A, B]`,
13415    /// `inner = [X, Y]` with both loops using `item` as the variable:
13416    ///
13417    ///   outer pre-insert "item": Null
13418    ///   iter 1: get_mut → write A
13419    ///     emit "outer:A"
13420    ///     enter inner: saved = remove("item") = Some(A)
13421    ///                  pre-insert "item": Null
13422    ///                  iter 1: write X → emit "inner:X"
13423    ///                  iter 2: write Y → emit "inner:Y"
13424    ///                  restore: insert("item", A)   ← outer's A back
13425    ///     emit "outer:A again"               ← reads A correctly
13426    ///   iter 2: get_mut → write B (overwrites the restored A,
13427    ///                              but that's correct — we're now
13428    ///                              in iter 2 of the outer loop)
13429    ///     emit "outer:B"
13430    ///     enter inner: saved = remove("item") = Some(B), …, restore B
13431    ///     emit "outer:B again"
13432    ///
13433    /// If the audit's claim were correct — that the outer's `get_mut`
13434    /// somehow held a reference past the inner loop and clobbered the
13435    /// restoration — we'd see corrupted values in the "outer:X again"
13436    /// emissions. The assertion below pins the correct sequence.
13437    #[test]
13438    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13439        let mut fragments = HashMap::new();
13440        fragments.insert(
13441            "index.html".to_string(),
13442            FragmentList {
13443                fragments: vec![
13444                    WebUIFragment::raw("["),
13445                    WebUIFragment::for_loop("item", "outer", "outer_body"),
13446                    WebUIFragment::raw("]"),
13447                ],
13448                contains_boundary: false,
13449            },
13450        );
13451        fragments.insert(
13452            "outer_body".to_string(),
13453            FragmentList {
13454                fragments: vec![
13455                    WebUIFragment::raw("(O="),
13456                    WebUIFragment::signal("item.tag", false),
13457                    WebUIFragment::for_loop("item", "inner", "inner_body"),
13458                    WebUIFragment::raw(",O="),
13459                    WebUIFragment::signal("item.tag", false),
13460                    WebUIFragment::raw(")"),
13461                ],
13462                contains_boundary: false,
13463            },
13464        );
13465        fragments.insert(
13466            "inner_body".to_string(),
13467            FragmentList {
13468                fragments: vec![
13469                    WebUIFragment::raw("[I="),
13470                    WebUIFragment::signal("item.tag", false),
13471                    WebUIFragment::raw("]"),
13472                ],
13473                contains_boundary: false,
13474            },
13475        );
13476        let protocol = WebUIProtocol::new(fragments);
13477        let state = test_json!({
13478            "outer": [{"tag": "A"}, {"tag": "B"}],
13479            "inner": [{"tag": "X"}, {"tag": "Y"}],
13480        });
13481        let mut writer = TestWriter::new();
13482        handle(
13483            &protocol,
13484            &state,
13485            &RenderOptions::new("index.html", "/"),
13486            &mut writer,
13487        )
13488        .unwrap();
13489        // Expected sequence:
13490        //   outer iter 1 (item=A):
13491        //     emit "(O=A"               ← outer A before inner
13492        //     inner iter 1 (item=X) emit "[I=X]"
13493        //     inner iter 2 (item=Y) emit "[I=Y]"
13494        //     emit ",O=A)"              ← outer A AFTER inner restore
13495        //   outer iter 2 (item=B):
13496        //     emit "(O=B"
13497        //     inner iter 1 (item=X) emit "[I=X]"
13498        //     inner iter 2 (item=Y) emit "[I=Y]"
13499        //     emit ",O=B)"
13500        assert_eq!(
13501            writer.get_content(),
13502            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13503            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13504        );
13505    }
13506    #[derive(Default)]
13507    struct FlushTestWriter {
13508        output: String,
13509        flushes: Vec<usize>,
13510        fail_flush: bool,
13511        fail_flush_at: Option<usize>,
13512        flush_attempts: usize,
13513        ended: bool,
13514    }
13515
13516    impl ResponseWriter for FlushTestWriter {
13517        fn write(&mut self, content: &str) -> Result<()> {
13518            self.output.push_str(content);
13519            Ok(())
13520        }
13521
13522        fn end(&mut self) -> Result<()> {
13523            self.ended = true;
13524            Ok(())
13525        }
13526    }
13527
13528    impl FlushWriter for FlushTestWriter {
13529        fn flush(&mut self) -> Result<()> {
13530            let attempt = self.flush_attempts;
13531            self.flush_attempts += 1;
13532            if self.fail_flush || self.fail_flush_at == Some(attempt) {
13533                return Err(HandlerError::ClientDisconnected);
13534            }
13535            self.flushes.push(self.output.len());
13536            Ok(())
13537        }
13538    }
13539
13540    fn streaming_protocol(with_boundaries: bool) -> Protocol {
13541        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13542    }
13543
13544    fn streaming_protocol_with_state_strategy(
13545        with_boundaries: bool,
13546        state_strategy: InitialStateStrategy,
13547    ) -> Protocol {
13548        let mut fragments = HashMap::new();
13549        let mut entry = vec![
13550            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13551            structural_fragment("head_start"),
13552            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13553            structural_fragment("head_end"),
13554            WebUIFragment::raw("</HEAD><body>"),
13555            structural_fragment("body_start"),
13556        ];
13557        if with_boundaries {
13558            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13559        }
13560        entry.extend([
13561            WebUIFragment::raw("<my-counter"),
13562            structural_fragment("streaming_root:my-counter"),
13563            WebUIFragment::raw(">"),
13564            WebUIFragment::component("my-counter"),
13565            WebUIFragment::raw("</my-counter>"),
13566        ]);
13567        if with_boundaries {
13568            entry.push(WebUIFragment::boundary_end(0));
13569        }
13570        entry.extend([
13571            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13572            structural_fragment("body_end"),
13573            WebUIFragment::raw("</body></html>"),
13574        ]);
13575        fragments.insert(
13576            "index.html".to_string(),
13577            FragmentList {
13578                fragments: entry,
13579                contains_boundary: with_boundaries,
13580            },
13581        );
13582        fragments.insert(
13583            "my-counter".to_string(),
13584            FragmentList {
13585                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13586                contains_boundary: false,
13587            },
13588        );
13589
13590        let mut document = WebUIProtocol::new(fragments);
13591        document.initial_state_strategy = state_strategy as i32;
13592        document.components.insert(
13593            "my-counter".to_string(),
13594            webui_protocol::ComponentData {
13595                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13596                template_functions: "[function(){return true}]".to_string(),
13597                hydration_mode: StateProjectionMode::Keys as i32,
13598                hydration_keys: vec!["count".to_string()],
13599                ..Default::default()
13600            },
13601        );
13602        Protocol::new(document)
13603    }
13604
13605    #[test]
13606    fn handler_error_stays_small() {
13607        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
13608        // therefore `Result<(), HandlerError>` threaded through the hot legacy
13609        // render path — down to a single `String`-sized payload plus a
13610        // discriminant word. If the boundary payload is un-boxed back to
13611        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
13612        // this fails.
13613        assert!(
13614            std::mem::size_of::<HandlerError>()
13615                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13616            "HandlerError grew to {} bytes",
13617            std::mem::size_of::<HandlerError>()
13618        );
13619    }
13620
13621    #[test]
13622    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13623        let protocol = streaming_protocol(true);
13624        let handler = WebUIHandler::with_plugin(|| {
13625            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13626        });
13627        let state = test_json!({ "count": 1, "serverOnly": "secret" });
13628        let mut writer = FlushTestWriter::default();
13629
13630        handler
13631            .render_streaming(
13632                &protocol,
13633                &state,
13634                &RenderOptions::new("index.html", "/"),
13635                &mut writer,
13636            )
13637            .unwrap();
13638
13639        assert!(writer.ended);
13640        assert_eq!(
13641            writer.flushes.len(),
13642            3,
13643            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13644        );
13645        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13646        assert!(boundary_flush.contains("<!--wb:0-->"));
13647        assert!(boundary_flush.contains("<!--/wb:0-->"));
13648        assert!(
13649            boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
13650            "boundary flush: {boundary_flush}"
13651        );
13652        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13653        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13654        assert!(!boundary_flush.contains("slow tail"));
13655        assert!(!writer.output.contains("id=\"webui-data\""));
13656        // The terminal flush commits the scriptless tail without manufacturing
13657        // another state/template projection.
13658        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13659        assert!(terminal_flush.contains("slow tail"));
13660        assert!(writer.output.contains("[1,4,0,{}]"));
13661        assert!(!writer.output.contains("[1,0,1,"));
13662        assert!(!writer.output.contains("[2,4,0,{}]"));
13663
13664        let marker = writer
13665            .output
13666            .find(STREAMING_MARKER)
13667            .expect("streaming marker");
13668        let attributed_head = writer
13669            .output
13670            .find("<HEAD data-shell=\"main\">")
13671            .expect("attributed mixed-case head");
13672        let authored_script = writer
13673            .output
13674            .find("src=\"/index.js\"")
13675            .expect("entry script");
13676        assert!(attributed_head < marker && marker < authored_script);
13677    }
13678
13679    #[test]
13680    fn streaming_terminal_tail_never_resends_full_state() {
13681        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13682        let handler = WebUIHandler::with_plugin(|| {
13683            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13684        });
13685        let mut writer = FlushTestWriter::default();
13686
13687        handler
13688            .render_streaming(
13689                &protocol,
13690                &test_json!({ "count": 1, "serverOnly": "secret" }),
13691                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13692                &mut writer,
13693            )
13694            .unwrap();
13695
13696        assert_eq!(
13697            writer.output.matches("serverOnly").count(),
13698            1,
13699            "full state belongs only to the interactive boundary"
13700        );
13701        assert!(writer.output.contains("[1,4,0,{}]"));
13702    }
13703
13704    /// Streaming must place the reserved-state injects exactly where the
13705    /// ordinary render does, so a host can switch modes without its
13706    /// boundary HTML moving.
13707    #[test]
13708    fn state_inject_placement_matches_between_render_modes() {
13709        let entry = vec![
13710            WebUIFragment::raw("<html><head>"),
13711            structural_fragment("head_start"),
13712            structural_fragment("head_end"),
13713            WebUIFragment::raw("</head><body>"),
13714            structural_fragment("body_start"),
13715            WebUIFragment::raw("<main>static</main>"),
13716            structural_fragment("body_end"),
13717            WebUIFragment::raw("</body></html>"),
13718        ];
13719        let fragments = HashMap::from([(
13720            "index.html".to_string(),
13721            FragmentList {
13722                fragments: entry,
13723                contains_boundary: false,
13724            },
13725        )]);
13726        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13727        let state = test_json!({
13728            "$webui": {
13729                "headEnd": "<meta name=he>",
13730                "bodyStart": "<span id=bs></span>",
13731                "bodyEnd": "<script>be</script>",
13732            }
13733        });
13734        let options = RenderOptions::new("index.html", "/");
13735
13736        let mut ordinary = TestWriter::new();
13737        WebUIHandler::new()
13738            .render(&protocol, &state, &options, &mut ordinary)
13739            .unwrap();
13740        let ordinary_html = ordinary.get_content().to_string();
13741
13742        let mut streamed = FlushTestWriter::default();
13743        WebUIHandler::new()
13744            .render_streaming(&protocol, &state, &options, &mut streamed)
13745            .unwrap();
13746        let streamed_html = &streamed.output;
13747
13748        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13749            let head_end = html.find("<meta name=he>").expect("headEnd missing");
13750            let head_close = html.find("</head>").expect("</head> missing");
13751            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13752            let main = html.find("<main>static</main>").expect("content missing");
13753            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13754            let body_close = html.find("</body>").expect("</body> missing");
13755            assert!(head_end < head_close, "headEnd misplaced: {html}");
13756            assert!(body_start < main, "bodyStart misplaced: {html}");
13757            assert!(
13758                main < body_end && body_end < body_close,
13759                "bodyEnd misplaced: {html}"
13760            );
13761        }
13762
13763        // The streaming response still terminates with its single empty
13764        // terminal record: an inject must not perturb the record stream.
13765        assert!(
13766            streamed_html.contains(",4,0,{}]"),
13767            "streaming must still end in one empty terminal record: {streamed_html}"
13768        );
13769    }
13770
13771    #[test]
13772    fn streaming_state_inject_emits_and_strips_reserved_key() {
13773        let entry = vec![
13774            WebUIFragment::raw("<html><head>"),
13775            structural_fragment("head_start"),
13776            structural_fragment("head_end"),
13777            WebUIFragment::raw("</head><body>"),
13778            structural_fragment("body_start"),
13779            structural_fragment("body_end"),
13780            WebUIFragment::raw("</body></html>"),
13781        ];
13782        let fragments = HashMap::from([(
13783            "index.html".to_string(),
13784            FragmentList {
13785                fragments: entry,
13786                contains_boundary: false,
13787            },
13788        )]);
13789        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13790        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13791
13792        let mut writer = FlushTestWriter::default();
13793        WebUIHandler::new()
13794            .render_streaming(
13795                &protocol,
13796                &state,
13797                &RenderOptions::new("index.html", "/"),
13798                &mut writer,
13799            )
13800            .unwrap();
13801        assert!(writer.output.contains("<script>be</script>"));
13802        assert!(!writer.output.contains("$webui"));
13803    }
13804
13805    #[test]
13806    fn static_streaming_document_uses_one_empty_terminal_record() {
13807        let fragments = HashMap::from([(
13808            "index.html".to_string(),
13809            FragmentList {
13810                fragments: vec![
13811                    WebUIFragment::raw("<html><head>"),
13812                    structural_fragment("head_start"),
13813                    structural_fragment("head_end"),
13814                    WebUIFragment::raw("</head><body>"),
13815                    structural_fragment("body_start"),
13816                    WebUIFragment::raw("<main>static</main>"),
13817                    structural_fragment("body_end"),
13818                    WebUIFragment::raw("</body></html>"),
13819                ],
13820                contains_boundary: false,
13821            },
13822        )]);
13823        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13824        let mut writer = FlushTestWriter::default();
13825
13826        WebUIHandler::new()
13827            .render_streaming(
13828                &protocol,
13829                &test_json!({ "serverOnly": "secret" }),
13830                &RenderOptions::new("index.html", "/"),
13831                &mut writer,
13832            )
13833            .unwrap();
13834
13835        assert_eq!(writer.flushes.len(), 1);
13836        assert!(writer.output.contains(STREAMING_MARKER));
13837        assert!(writer.output.contains("[0,4,0,{}]"));
13838        assert!(!writer.output.contains("serverOnly"));
13839        assert!(!writer.output.contains("id=\"webui-data\""));
13840        assert!(!writer.output.contains("<!--wb:"));
13841    }
13842
13843    #[test]
13844    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13845        let protocol = streaming_protocol(true);
13846        let handler = WebUIHandler::with_plugin(|| {
13847            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13848        });
13849        let mut writer = FlushTestWriter::default();
13850        handler
13851            .render_streaming(
13852                &protocol,
13853                &test_json!({ "count": 1 }),
13854                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13855                &mut writer,
13856            )
13857            .unwrap();
13858
13859        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13860        let envelope = writer.output[boundary_end..]
13861            .find("data-webui-boundary nonce=\"test-nonce-123\"")
13862            .map(|index| index + boundary_end)
13863            .expect("nonce-bearing envelope");
13864        let functions = writer
13865            .output
13866            .find("templateFns")
13867            .expect("function side channel");
13868        let sentinel = writer
13869            .output
13870            .find("<webui-hydrate>")
13871            .expect("hydration sentinel");
13872        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13873        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13874    }
13875
13876    #[test]
13877    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13878        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13879            let mut fragments = vec![structural_fragment("head_start")];
13880            fragments.extend(boundaries);
13881            let fragments = HashMap::from([(
13882                "index.html".to_string(),
13883                FragmentList {
13884                    fragments,
13885                    contains_boundary: true,
13886                },
13887            )]);
13888            Protocol::new(WebUIProtocol::new(fragments))
13889        }
13890
13891        let handler = WebUIHandler::new();
13892        let mut writer = FlushTestWriter::default();
13893        let result = handler.render_streaming(
13894            &protocol(vec![WebUIFragment::boundary_end(0)]),
13895            &test_json!({}),
13896            &RenderOptions::new("index.html", "/"),
13897            &mut writer,
13898        );
13899        match result {
13900            Err(HandlerError::StreamingBoundary(err)) => {
13901                assert_eq!(err.signal, "boundary");
13902                assert!(err.reason.contains("no active occurrence"));
13903            }
13904            other => panic!("expected ordered-boundary error, got {other:?}"),
13905        }
13906
13907        let mut writer = FlushTestWriter::default();
13908        let result = handler.render_streaming(
13909            &protocol(vec![
13910                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13911                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13912            ]),
13913            &test_json!({}),
13914            &RenderOptions::new("index.html", "/"),
13915            &mut writer,
13916        );
13917        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13918    }
13919
13920    #[test]
13921    fn streaming_render_propagates_boundary_flush_disconnect() {
13922        let protocol = streaming_protocol(true);
13923        let handler = WebUIHandler::new();
13924        let mut writer = FlushTestWriter {
13925            fail_flush: true,
13926            ..FlushTestWriter::default()
13927        };
13928        let result = handler.render_streaming(
13929            &protocol,
13930            &test_json!({}),
13931            &RenderOptions::new("index.html", "/"),
13932            &mut writer,
13933        );
13934        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13935        assert!(!writer.ended);
13936    }
13937
13938    #[test]
13939    fn streaming_render_requires_structural_head_start_before_writing() {
13940        let fragments = HashMap::from([(
13941            "index.html".to_string(),
13942            FragmentList {
13943                fragments: vec![
13944                    WebUIFragment::raw(
13945                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13946                    ),
13947                    structural_fragment("head_end"),
13948                    WebUIFragment::raw("</HEAD><body>"),
13949                    structural_fragment("body_start"),
13950                    structural_fragment("body_end"),
13951                ],
13952                contains_boundary: false,
13953            },
13954        )]);
13955        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13956        let mut writer = FlushTestWriter::default();
13957
13958        let result = WebUIHandler::new().render_streaming(
13959            &protocol,
13960            &test_json!({}),
13961            &RenderOptions::new("index.html", "/"),
13962            &mut writer,
13963        );
13964
13965        assert!(matches!(
13966            result,
13967            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13968        ));
13969        assert_eq!(
13970            writer.output,
13971            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13972        );
13973        assert!(writer.flushes.is_empty());
13974        assert!(!writer.ended);
13975    }
13976
13977    #[test]
13978    fn streaming_render_rejects_duplicate_head_start() {
13979        let fragments = HashMap::from([(
13980            "index.html".to_string(),
13981            FragmentList {
13982                fragments: vec![
13983                    WebUIFragment::raw("<html><head>"),
13984                    structural_fragment("head_start"),
13985                    structural_fragment("head_start"),
13986                    structural_fragment("head_end"),
13987                    WebUIFragment::raw("</head><body>"),
13988                    structural_fragment("body_start"),
13989                    structural_fragment("body_end"),
13990                ],
13991                contains_boundary: false,
13992            },
13993        )]);
13994        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13995        let mut writer = FlushTestWriter::default();
13996
13997        let result = WebUIHandler::new().render_streaming(
13998            &protocol,
13999            &test_json!({}),
14000            &RenderOptions::new("index.html", "/"),
14001            &mut writer,
14002        );
14003
14004        assert!(matches!(
14005            result,
14006            Err(HandlerError::DuplicateStreamingHeadStart)
14007        ));
14008        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14009        assert!(writer.flushes.is_empty());
14010    }
14011
14012    #[test]
14013    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14014        let handler = WebUIHandler::with_plugin(|| {
14015            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14016        });
14017        let state = test_json!({
14018            "count": 1,
14019            "head_start": "must not render",
14020            "boundary_start:0": "must not render",
14021            "boundary_end:0": "must not render",
14022        });
14023        let mut with_boundaries = TestWriter::new();
14024        let mut without_boundaries = TestWriter::new();
14025        handler
14026            .render(
14027                &streaming_protocol(true),
14028                &state,
14029                &RenderOptions::new("index.html", "/"),
14030                &mut with_boundaries,
14031            )
14032            .unwrap();
14033        handler
14034            .render(
14035                &streaming_protocol(false),
14036                &state,
14037                &RenderOptions::new("index.html", "/"),
14038                &mut without_boundaries,
14039            )
14040            .unwrap();
14041        assert_eq!(
14042            with_boundaries.get_content(),
14043            without_boundaries.get_content()
14044        );
14045    }
14046
14047    #[test]
14048    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14049        for source in [
14050            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14051            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14052        ] {
14053            let mut parser = HtmlParser::new();
14054            parser
14055                .parse("index.html", source)
14056                .expect("parse head fixture");
14057            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14058            let mut writer = TestWriter::new();
14059            WebUIHandler::new()
14060                .render(
14061                    &protocol,
14062                    &test_json!({ "theme": "light" }),
14063                    &RenderOptions::new("index.html", "/"),
14064                    &mut writer,
14065                )
14066                .unwrap();
14067
14068            assert_eq!(
14069                writer.get_content(),
14070                "<html><head><title>T</title></head><body>x</body></html>"
14071            );
14072        }
14073    }
14074
14075    #[test]
14076    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14077        let source =
14078            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14079        let mut parser = HtmlParser::new();
14080        parser.parse("index.html", source).expect("parse fixture");
14081        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14082
14083        let mut ordinary = TestWriter::new();
14084        WebUIHandler::new()
14085            .render(
14086                &protocol,
14087                &test_json!({}),
14088                &RenderOptions::new("index.html", "/"),
14089                &mut ordinary,
14090            )
14091            .unwrap();
14092        assert_eq!(ordinary.get_content(), source);
14093
14094        let mut streaming = FlushTestWriter::default();
14095        WebUIHandler::new()
14096            .render_streaming(
14097                &protocol,
14098                &test_json!({}),
14099                &RenderOptions::new("index.html", "/"),
14100                &mut streaming,
14101            )
14102            .unwrap();
14103        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14104        assert!(streaming.output.contains("</HEAD><BODY>x"));
14105        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14106        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14107        let title = streaming.output.find("<title>").expect("title");
14108        assert!(opening < marker && marker < title);
14109        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14110    }
14111
14112    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
14113    /// unprefixed `body_end` is ordinary authored content.
14114    #[test]
14115    fn unnamespaced_signal_is_ordinary_content() {
14116        let fragments = HashMap::from([(
14117            "index.html".to_string(),
14118            FragmentList {
14119                fragments: vec![
14120                    WebUIFragment::raw("<html><head>"),
14121                    structural_fragment("head_end"),
14122                    WebUIFragment::raw("</head><body>"),
14123                    WebUIFragment::signal("body_end".to_string(), false),
14124                    structural_fragment("body_end"),
14125                    WebUIFragment::raw("</body></html>"),
14126                ],
14127                contains_boundary: false,
14128            },
14129        )]);
14130        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14131        let mut writer = TestWriter::new();
14132        WebUIHandler::new()
14133            .render(
14134                &protocol,
14135                &test_json!({ "body_end": "content" }),
14136                &RenderOptions::new("index.html", "/"),
14137                &mut writer,
14138            )
14139            .expect("current protocol must render");
14140        assert!(writer.get_content().contains("content"));
14141    }
14142
14143    #[test]
14144    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14145        let source = concat!(
14146            "<html><head></head><body>",
14147            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14148            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14149            "</body></html>",
14150        );
14151        let state = test_json!({
14152            "head_start": "hs",
14153            "head_end": "he",
14154            "body_start": "bs",
14155            "body_end": "be",
14156            "boundary_start:0": "b0s",
14157            "boundary_end:0": "b0e",
14158            "streaming_root:forged": "root",
14159        });
14160        let mut parser = HtmlParser::new();
14161        parser.parse("index.html", source).expect("parse fixture");
14162        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14163        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14164
14165        let mut ordinary = TestWriter::new();
14166        WebUIHandler::new()
14167            .render(
14168                &protocol,
14169                &state,
14170                &RenderOptions::new("index.html", "/"),
14171                &mut ordinary,
14172            )
14173            .unwrap();
14174        assert_eq!(ordinary.get_content(), expected);
14175
14176        let mut streaming = FlushTestWriter::default();
14177        WebUIHandler::new()
14178            .render_streaming(
14179                &protocol,
14180                &state,
14181                &RenderOptions::new("index.html", "/"),
14182                &mut streaming,
14183            )
14184            .unwrap();
14185        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14186        assert!(!streaming.output.contains("<!--wb:"));
14187        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14188        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14189    }
14190
14191    #[test]
14192    fn streaming_rejects_structural_signals_after_terminal() {
14193        let fragments = HashMap::from([(
14194            "index.html".to_string(),
14195            FragmentList {
14196                fragments: vec![
14197                    WebUIFragment::raw("<html><head>"),
14198                    structural_fragment("head_start"),
14199                    structural_fragment("head_end"),
14200                    WebUIFragment::raw("</head><body>"),
14201                    structural_fragment("body_start"),
14202                    structural_fragment("body_end"),
14203                    WebUIFragment::raw("</body>"),
14204                    structural_fragment("boundary_start:0"),
14205                    WebUIFragment::raw("</html>"),
14206                ],
14207                contains_boundary: false,
14208            },
14209        )]);
14210        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14211        let mut streaming = FlushTestWriter::default();
14212        let result = WebUIHandler::new().render_streaming(
14213            &protocol,
14214            &test_json!({}),
14215            &RenderOptions::new("index.html", "/"),
14216            &mut streaming,
14217        );
14218
14219        match result {
14220            Err(HandlerError::StreamingBoundary(error)) => {
14221                assert_eq!(error.signal, "boundary_start:0");
14222                assert!(
14223                    error
14224                        .reason
14225                        .contains("structural signal arrived after body_end"),
14226                    "reason: {}",
14227                    error.reason
14228                );
14229            }
14230            other => panic!("expected post-terminal rejection, got {other:?}"),
14231        }
14232        assert_eq!(
14233            streaming.output,
14234            "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14235        );
14236        assert!(!streaming.output.contains("<!--wb:0-->"));
14237
14238        let mut ordinary = TestWriter::new();
14239        WebUIHandler::new()
14240            .render(
14241                &protocol,
14242                &test_json!({}),
14243                &RenderOptions::new("index.html", "/"),
14244                &mut ordinary,
14245            )
14246            .unwrap();
14247        assert_eq!(
14248            ordinary.get_content(),
14249            "<html><head></head><body></body></html>"
14250        );
14251    }
14252
14253    fn disjoint_streaming_protocol_ext(
14254        hosts: &[&str],
14255        emit_root_signal: bool,
14256        include_styles: bool,
14257    ) -> Protocol {
14258        let mut fragments = HashMap::new();
14259        let mut entry = vec![
14260            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14261            structural_fragment("head_start"),
14262            structural_fragment("head_end"),
14263            WebUIFragment::raw("</head><body>"),
14264            structural_fragment("body_start"),
14265        ];
14266        for (sequence, host) in hosts.iter().enumerate() {
14267            entry.push(WebUIFragment::boundary(
14268                sequence as u32,
14269                "index.html",
14270                format!("boundary-{sequence}"),
14271                None,
14272            ));
14273            entry.push(WebUIFragment::raw(format!("<{host}")));
14274            if emit_root_signal {
14275                entry.push(structural_fragment(format!("streaming_root:{host}")));
14276            }
14277            entry.push(WebUIFragment::raw(">"));
14278            entry.push(WebUIFragment::component(*host));
14279            entry.push(WebUIFragment::raw(format!("</{host}>")));
14280            entry.push(WebUIFragment::boundary_end(sequence as u32));
14281        }
14282        entry.push(structural_fragment("body_end"));
14283        entry.push(WebUIFragment::raw("</body></html>"));
14284        fragments.insert(
14285            "index.html".to_string(),
14286            FragmentList {
14287                fragments: entry,
14288                contains_boundary: true,
14289            },
14290        );
14291        fragments.insert(
14292            "comp-a".to_string(),
14293            FragmentList {
14294                fragments: vec![WebUIFragment::raw("<b>A</b>")],
14295                contains_boundary: false,
14296            },
14297        );
14298        fragments.insert(
14299            "comp-b".to_string(),
14300            FragmentList {
14301                fragments: vec![WebUIFragment::raw("<b>B</b>")],
14302                contains_boundary: false,
14303            },
14304        );
14305
14306        let mut document = WebUIProtocol::new(fragments);
14307        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14308        document.components.insert(
14309            "comp-a".to_string(),
14310            webui_protocol::ComponentData {
14311                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14312                hydration_mode: StateProjectionMode::Keys as i32,
14313                hydration_keys: vec!["a_count".to_string()],
14314                ..Default::default()
14315            },
14316        );
14317        document.components.insert(
14318            "comp-b".to_string(),
14319            webui_protocol::ComponentData {
14320                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14321                hydration_mode: StateProjectionMode::Keys as i32,
14322                hydration_keys: vec!["b_count".to_string()],
14323                ..Default::default()
14324            },
14325        );
14326        if include_styles {
14327            document.set_css_strategy(webui_protocol::CssStrategy::Style);
14328            document
14329                .components
14330                .get_mut("comp-a")
14331                .expect("comp-a component")
14332                .css = ".comp-a{color:red}".to_string();
14333            document
14334                .components
14335                .get_mut("comp-b")
14336                .expect("comp-b component")
14337                .css = ".comp-b{color:blue}".to_string();
14338            document.populate_style_closures(&["index.html"]);
14339        }
14340        Protocol::new(document)
14341    }
14342
14343    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14344        disjoint_streaming_protocol_ext(hosts, true, false)
14345    }
14346
14347    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14348        disjoint_streaming_protocol_ext(hosts, true, true)
14349    }
14350
14351    fn streaming_root_validation_protocol(
14352        mut host_fragments: Vec<WebUIFragment>,
14353        tail: Vec<WebUIFragment>,
14354    ) -> Protocol {
14355        let mut entry = vec![
14356            WebUIFragment::raw("<html><head>"),
14357            structural_fragment("head_start"),
14358            structural_fragment("head_end"),
14359            WebUIFragment::raw("</head><body>"),
14360            structural_fragment("body_start"),
14361            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14362        ];
14363        entry.append(&mut host_fragments);
14364        entry.extend(tail);
14365        let fragments = HashMap::from([
14366            (
14367                "index.html".to_string(),
14368                FragmentList {
14369                    fragments: entry,
14370                    contains_boundary: true,
14371                },
14372            ),
14373            (
14374                "comp-a".to_string(),
14375                FragmentList {
14376                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14377                    contains_boundary: false,
14378                },
14379            ),
14380            (
14381                "comp-b".to_string(),
14382                FragmentList {
14383                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
14384                    contains_boundary: false,
14385                },
14386            ),
14387        ]);
14388        Protocol::new(WebUIProtocol::new(fragments))
14389    }
14390
14391    fn completed_streaming_tail() -> Vec<WebUIFragment> {
14392        vec![
14393            WebUIFragment::raw("</comp-a>"),
14394            WebUIFragment::boundary_end(0),
14395            structural_fragment("body_end"),
14396            WebUIFragment::raw("</body></html>"),
14397        ]
14398    }
14399
14400    fn assert_streaming_root_error(
14401        protocol: &Protocol,
14402        expected_signal: &str,
14403        expected_reason: &str,
14404    ) {
14405        let mut writer = FlushTestWriter::default();
14406        let result = WebUIHandler::new().render_streaming(
14407            protocol,
14408            &test_json!({}),
14409            &RenderOptions::new("index.html", "/"),
14410            &mut writer,
14411        );
14412        match result {
14413            Err(HandlerError::StreamingBoundary(error)) => {
14414                assert_eq!(error.signal, expected_signal);
14415                assert!(
14416                    error.reason.contains(expected_reason),
14417                    "reason: {}",
14418                    error.reason
14419                );
14420            }
14421            other => panic!("expected streaming-root rejection, got {other:?}"),
14422        }
14423    }
14424
14425    #[test]
14426    fn streaming_root_signal_injects_data_ws_inside_boundary() {
14427        let protocol = disjoint_streaming_protocol(&["comp-a"]);
14428        let handler = WebUIHandler::with_plugin(|| {
14429            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14430        });
14431        let mut writer = FlushTestWriter::default();
14432        handler
14433            .render_streaming(
14434                &protocol,
14435                &test_json!({ "a_count": 1 }),
14436                &RenderOptions::new("index.html", "/"),
14437                &mut writer,
14438            )
14439            .unwrap();
14440        // The parser-owned signal is consumed to inject exactly ` data-ws`
14441        // inside the host's opening tag, before the custom element upgrades.
14442        assert!(
14443            writer.output.contains("<comp-a data-ws>"),
14444            "streamed host must carry data-ws: {}",
14445            writer.output
14446        );
14447    }
14448
14449    #[test]
14450    fn streaming_root_signal_preserves_ordinary_output_bytes() {
14451        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
14452        // output with and without the signal, and never a `data-ws` attribute.
14453        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14454        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14455        let state = test_json!({ "a_count": 1, "b_count": 2 });
14456        let plugin = || {
14457            WebUIHandler::with_plugin(
14458                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14459            )
14460        };
14461        let mut with_writer = TestWriter::new();
14462        let mut without_writer = TestWriter::new();
14463        plugin()
14464            .render(
14465                &with_signal,
14466                &state,
14467                &RenderOptions::new("index.html", "/"),
14468                &mut with_writer,
14469            )
14470            .unwrap();
14471        plugin()
14472            .render(
14473                &without_signal,
14474                &state,
14475                &RenderOptions::new("index.html", "/"),
14476                &mut without_writer,
14477            )
14478            .unwrap();
14479        // The rendered DOM (everything up to the inert data block) is
14480        // deterministic and is where a leaked `data-ws` would appear. The
14481        // ordinary template map is HashSet-ordered, so compare the DOM prefix
14482        // for byte identity rather than the whole document.
14483        let dom_prefix = |content: &str| -> String {
14484            content
14485                .split_once(r#"<script type="application/json" id="webui-data""#)
14486                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14487        };
14488        assert_eq!(
14489            dom_prefix(&with_writer.get_content()),
14490            dom_prefix(&without_writer.get_content())
14491        );
14492        assert!(!with_writer.get_content().contains("data-ws"));
14493        assert!(with_writer.get_content().contains("<comp-a>"));
14494    }
14495
14496    #[test]
14497    fn streaming_component_without_root_signal_is_rejected() {
14498        let protocol = streaming_root_validation_protocol(
14499            vec![
14500                WebUIFragment::raw("<comp-a>"),
14501                WebUIFragment::component("comp-a"),
14502            ],
14503            completed_streaming_tail(),
14504        );
14505
14506        assert_streaming_root_error(
14507            &protocol,
14508            "comp-a",
14509            "has no compiler-owned streaming_root signal",
14510        );
14511    }
14512
14513    #[test]
14514    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14515        let duplicate = streaming_root_validation_protocol(
14516            vec![
14517                WebUIFragment::raw("<comp-a"),
14518                structural_fragment("streaming_root:comp-a"),
14519                structural_fragment("streaming_root:comp-a"),
14520                WebUIFragment::raw(">"),
14521                WebUIFragment::component("comp-a"),
14522            ],
14523            completed_streaming_tail(),
14524        );
14525        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14526
14527        let after_tag_close = streaming_root_validation_protocol(
14528            vec![
14529                WebUIFragment::raw("<comp-a>"),
14530                structural_fragment("streaming_root:comp-a"),
14531                WebUIFragment::component("comp-a"),
14532            ],
14533            completed_streaming_tail(),
14534        );
14535        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14536
14537        let detached_from_opening = streaming_root_validation_protocol(
14538            vec![
14539                WebUIFragment::raw("not an opening tag"),
14540                structural_fragment("streaming_root:comp-a"),
14541                WebUIFragment::raw(">"),
14542                WebUIFragment::component("comp-a"),
14543            ],
14544            completed_streaming_tail(),
14545        );
14546        assert_streaming_root_error(
14547            &detached_from_opening,
14548            "comp-a",
14549            "unclosed component opening-tag close",
14550        );
14551    }
14552
14553    #[test]
14554    fn mismatched_streaming_root_signal_is_rejected() {
14555        let protocol = streaming_root_validation_protocol(
14556            vec![
14557                WebUIFragment::raw("<comp-a"),
14558                structural_fragment("streaming_root:comp-b"),
14559                WebUIFragment::raw(">"),
14560                WebUIFragment::component("comp-a"),
14561            ],
14562            completed_streaming_tail(),
14563        );
14564
14565        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14566    }
14567
14568    #[test]
14569    fn pending_streaming_root_is_rejected_at_structural_ends() {
14570        let protocol = streaming_root_validation_protocol(
14571            vec![
14572                WebUIFragment::raw("<comp-a"),
14573                structural_fragment("streaming_root:comp-a"),
14574                WebUIFragment::raw(">"),
14575            ],
14576            vec![structural_fragment("boundary_end:0")],
14577        );
14578        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14579    }
14580
14581    #[test]
14582    fn streaming_root_outside_boundary_is_rejected() {
14583        let fragments = HashMap::from([
14584            (
14585                "index.html".to_string(),
14586                FragmentList {
14587                    fragments: vec![
14588                        WebUIFragment::raw("<html><head>"),
14589                        structural_fragment("head_start"),
14590                        structural_fragment("head_end"),
14591                        WebUIFragment::raw("</head><body>"),
14592                        structural_fragment("body_start"),
14593                        WebUIFragment::raw("<comp-a"),
14594                        structural_fragment("streaming_root:comp-a"),
14595                        WebUIFragment::raw(">"),
14596                        WebUIFragment::component("comp-a"),
14597                        WebUIFragment::raw("</comp-a>"),
14598                        structural_fragment("body_end"),
14599                        WebUIFragment::raw("</body></html>"),
14600                    ],
14601                    contains_boundary: false,
14602                },
14603            ),
14604            (
14605                "comp-a".to_string(),
14606                FragmentList {
14607                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14608                    contains_boundary: false,
14609                },
14610            ),
14611        ]);
14612        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14613        let mut writer = FlushTestWriter::default();
14614        let result = WebUIHandler::new().render_streaming(
14615            &protocol,
14616            &test_json!({}),
14617            &RenderOptions::new("index.html", "/"),
14618            &mut writer,
14619        );
14620        match result {
14621            Err(HandlerError::StreamingBoundary(err)) => {
14622                assert_eq!(err.signal, "comp-a");
14623                assert!(
14624                    err.reason
14625                        .contains("no boundary or generated component span"),
14626                    "reason: {}",
14627                    err.reason
14628                );
14629            }
14630            other => panic!("expected outside-boundary rejection, got {other:?}"),
14631        }
14632    }
14633
14634    fn parser_route_protocol(with_boundary: bool) -> Protocol {
14635        let mut parser = HtmlParser::with_options(DomStrategy::Light);
14636        parser
14637            .component_registry_mut()
14638            .register_component(ComponentRegistration::new(
14639                "route-page",
14640                "<p>route content</p>",
14641                None,
14642                true,
14643            ))
14644            .expect("register route component");
14645        let route = r#"<route path="/" component="route-page" exact />"#;
14646        let html = if with_boundary {
14647            format!(
14648                "<html><head></head><body>\
14649                 <boundary name=\"route\">{route}</boundary>\
14650                 </body></html>"
14651            )
14652        } else {
14653            format!("<html><head></head><body>{route}</body></html>")
14654        };
14655        parser
14656            .parse("index.html", &html)
14657            .expect("parse route streaming fixture");
14658        let mut document = WebUIProtocol::new(parser.into_fragment_records());
14659        document.components.insert(
14660            "route-page".to_string(),
14661            webui_protocol::ComponentData::default(),
14662        );
14663        Protocol::new(document)
14664    }
14665
14666    #[test]
14667    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14668        let protocol = parser_route_protocol(true);
14669        let mut writer = FlushTestWriter::default();
14670        WebUIHandler::new()
14671            .render_streaming(
14672                &protocol,
14673                &test_json!({}),
14674                &RenderOptions::new("index.html", "/"),
14675                &mut writer,
14676            )
14677            .unwrap();
14678
14679        assert!(
14680            writer
14681                .output
14682                .contains("<route-page data-ws><p>route content</p></route-page>"),
14683            "matched route host must be deferred before upgrade: {}",
14684            writer.output
14685        );
14686    }
14687
14688    #[test]
14689    fn streaming_parser_route_host_outside_boundary_is_rejected() {
14690        let protocol = parser_route_protocol(false);
14691        let mut writer = FlushTestWriter::default();
14692        let result = WebUIHandler::new().render_streaming(
14693            &protocol,
14694            &test_json!({}),
14695            &RenderOptions::new("index.html", "/"),
14696            &mut writer,
14697        );
14698
14699        match result {
14700            Err(HandlerError::StreamingBoundary(error)) => {
14701                assert_eq!(error.signal, "route-page");
14702                assert!(
14703                    error
14704                        .reason
14705                        .contains("no boundary or generated component span"),
14706                    "reason: {}",
14707                    error.reason
14708                );
14709            }
14710            other => panic!("expected route boundary rejection, got {other:?}"),
14711        }
14712        assert!(
14713            !writer.output.contains("<route-page>"),
14714            "an unmarked custom-element opening must never be completed"
14715        );
14716    }
14717
14718    #[test]
14719    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14720        let entry = vec![
14721            WebUIFragment::raw("<html><head>"),
14722            structural_fragment("head_start"),
14723            structural_fragment("head_end"),
14724            WebUIFragment::raw("</head><body>"),
14725            structural_fragment("body_start"),
14726            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14727            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14728                path: "/account".into(),
14729                fragment_id: "account-shell".into(),
14730                exact: false,
14731                ..Default::default()
14732            }),
14733            WebUIFragment::boundary_end(0),
14734            structural_fragment("body_end"),
14735            WebUIFragment::raw("</body></html>"),
14736        ];
14737        let fragments = HashMap::from([
14738            (
14739                "index.html".to_string(),
14740                FragmentList {
14741                    fragments: entry,
14742                    contains_boundary: true,
14743                },
14744            ),
14745            (
14746                "account-shell".to_string(),
14747                FragmentList {
14748                    fragments: vec![WebUIFragment::route_from(
14749                        webui_protocol::WebUiFragmentRoute {
14750                            path: "./details".into(),
14751                            fragment_id: "details-page".into(),
14752                            exact: true,
14753                            pending_component: "details-loading".into(),
14754                            error_component: "details-error".into(),
14755                            ..Default::default()
14756                        },
14757                    )],
14758                    contains_boundary: false,
14759                },
14760            ),
14761            (
14762                "details-page".to_string(),
14763                FragmentList {
14764                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
14765                    contains_boundary: false,
14766                },
14767            ),
14768            (
14769                "details-loading".to_string(),
14770                FragmentList {
14771                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14772                    contains_boundary: false,
14773                },
14774            ),
14775            (
14776                "details-error".to_string(),
14777                FragmentList {
14778                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
14779                    contains_boundary: false,
14780                },
14781            ),
14782        ]);
14783        let mut document = WebUIProtocol::new(fragments);
14784        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14785        for name in [
14786            "account-shell",
14787            "details-page",
14788            "details-loading",
14789            "details-error",
14790        ] {
14791            document.components.insert(
14792                name.to_string(),
14793                webui_protocol::ComponentData {
14794                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14795                    ..Default::default()
14796                },
14797            );
14798        }
14799        let protocol = Protocol::new(document);
14800        let handler = WebUIHandler::with_plugin(|| {
14801            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14802        });
14803        let mut writer = FlushTestWriter::default();
14804
14805        handler
14806            .render_streaming(
14807                &protocol,
14808                &test_json!({}),
14809                &RenderOptions::new("index.html", "/account/details"),
14810                &mut writer,
14811            )
14812            .unwrap();
14813
14814        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14815        assert!(
14816            first.contains(r#""details-loading":"#),
14817            "relative-route pending metadata missing: {first}"
14818        );
14819        assert!(
14820            first.contains(r#""details-error":"#),
14821            "relative-route error metadata missing: {first}"
14822        );
14823    }
14824
14825    #[test]
14826    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14827        let entry = vec![
14828            WebUIFragment::raw("<html><head>"),
14829            structural_fragment("head_start"),
14830            structural_fragment("head_end"),
14831            WebUIFragment::raw("</head><body>"),
14832            structural_fragment("body_start"),
14833            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14834            WebUIFragment::raw("<static-shell"),
14835            structural_fragment("streaming_root:static-shell"),
14836            WebUIFragment::raw(">"),
14837            WebUIFragment::component("static-shell"),
14838            WebUIFragment::raw("</static-shell>"),
14839            WebUIFragment::raw("<route-shell"),
14840            structural_fragment("streaming_root:route-shell"),
14841            WebUIFragment::raw(">"),
14842            WebUIFragment::component("route-shell"),
14843            WebUIFragment::raw("</route-shell>"),
14844            WebUIFragment::boundary_end(0),
14845            structural_fragment("body_end"),
14846            WebUIFragment::raw("</body></html>"),
14847        ];
14848        let fragments = HashMap::from([
14849            (
14850                "index.html".to_string(),
14851                FragmentList {
14852                    fragments: entry,
14853                    contains_boundary: true,
14854                },
14855            ),
14856            (
14857                "static-shell".to_string(),
14858                FragmentList {
14859                    fragments: vec![WebUIFragment::if_cond(
14860                        ConditionExpr::identifier("show_hidden"),
14861                        "static-hidden-if",
14862                    )],
14863                    contains_boundary: false,
14864                },
14865            ),
14866            (
14867                "static-hidden-if".to_string(),
14868                FragmentList {
14869                    fragments: vec![WebUIFragment::component("static-hidden")],
14870                    contains_boundary: false,
14871                },
14872            ),
14873            (
14874                "static-hidden".to_string(),
14875                FragmentList {
14876                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14877                    contains_boundary: false,
14878                },
14879            ),
14880            (
14881                "route-shell".to_string(),
14882                FragmentList {
14883                    fragments: vec![WebUIFragment::route_from(
14884                        webui_protocol::WebUiFragmentRoute {
14885                            path: "/account".into(),
14886                            fragment_id: "route-page".into(),
14887                            exact: true,
14888                            ..Default::default()
14889                        },
14890                    )],
14891                    contains_boundary: false,
14892                },
14893            ),
14894            (
14895                "route-page".to_string(),
14896                FragmentList {
14897                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
14898                    contains_boundary: false,
14899                },
14900            ),
14901        ]);
14902        let mut document = WebUIProtocol::new(fragments);
14903        document.set_css_strategy(webui_protocol::CssStrategy::Link);
14904        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14905        for (name, keys) in [
14906            ("static-shell", vec!["show_hidden", "static_count"]),
14907            ("static-hidden", vec!["hidden_count"]),
14908            ("route-shell", vec!["route_shell_count"]),
14909            ("route-page", vec!["route_count"]),
14910        ] {
14911            document.components.insert(
14912                name.to_string(),
14913                webui_protocol::ComponentData {
14914                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14915                    hydration_mode: StateProjectionMode::Keys as i32,
14916                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
14917                    css_href: format!("/{name}.css"),
14918                    ..Default::default()
14919                },
14920            );
14921        }
14922        document.populate_style_closures(&["index.html"]);
14923        let protocol = Protocol::new(document);
14924        assert_eq!(
14925            protocol
14926                .component_reachability()
14927                .is_route_dependent(protocol.component_index()["route-shell"]),
14928            Some(true),
14929            "fixture must exercise request-aware checkpoint reachability"
14930        );
14931        let handler = WebUIHandler::with_plugin(|| {
14932            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14933        });
14934        let mut writer = FlushTestWriter::default();
14935
14936        handler
14937            .render_streaming(
14938                &protocol,
14939                &test_json!({
14940                    "show_hidden": false,
14941                    "static_count": 1,
14942                    "hidden_count": 2,
14943                    "route_shell_count": 3,
14944                    "route_count": 4,
14945                    "unrelated": "private",
14946                }),
14947                &RenderOptions::new("index.html", "/account"),
14948                &mut writer,
14949            )
14950            .unwrap();
14951
14952        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14953        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14954            assert!(
14955                checkpoint.contains(&format!(r#""{template}":"#)),
14956                "{template} metadata missing: {checkpoint}"
14957            );
14958        }
14959        for state in [
14960            r#""show_hidden":false"#,
14961            r#""static_count":1"#,
14962            r#""hidden_count":2"#,
14963            r#""route_shell_count":3"#,
14964            r#""route_count":4"#,
14965        ] {
14966            assert!(
14967                checkpoint.contains(state),
14968                "{state} state missing: {checkpoint}"
14969            );
14970        }
14971        assert!(
14972            !checkpoint.contains("unrelated"),
14973            "checkpoint leaked unrelated state: {checkpoint}"
14974        );
14975        assert!(
14976            checkpoint.contains(
14977                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14978            ),
14979            "checkpoint metadata lost source order: {checkpoint}"
14980        );
14981        assert!(
14982            checkpoint.contains(r#""componentStyles":"#)
14983                && checkpoint.contains(r#""strategy":"link""#)
14984                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14985            "checkpoint is missing tree-local style metadata: {checkpoint}"
14986        );
14987        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14988            assert_eq!(
14989                writer
14990                    .output
14991                    .matches(&format!(r#"data-webui-resource="{resource}""#))
14992                    .count(),
14993                1,
14994                "Document delivery state must persist across streamed checkpoints"
14995            );
14996        }
14997    }
14998
14999    #[test]
15000    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15001        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
15002        // envelope must be locally scoped — templates/state for exactly the
15003        // components rendered since the previous checkpoint, with reused tags
15004        // carrying hydration state but never a duplicate template.
15005        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15006        let handler = WebUIHandler::with_plugin(|| {
15007            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15008        });
15009        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15010        let mut writer = FlushTestWriter::default();
15011        handler
15012            .render_streaming(
15013                &protocol,
15014                &state,
15015                &RenderOptions::new("index.html", "/"),
15016                &mut writer,
15017            )
15018            .unwrap();
15019
15020        assert_eq!(
15021            writer.flushes.len(),
15022            5,
15023            "bootstrap, three boundary commits, and one terminal"
15024        );
15025        let segment = |index: usize| -> &str {
15026            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15027        };
15028        let b0 = segment(0);
15029        let b1 = segment(1);
15030        let b2 = segment(2);
15031
15032        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
15033        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15034        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15035        assert!(
15036            b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15037            "b0: {b0}"
15038        );
15039        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15040        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15041        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15042
15043        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
15044        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15045        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15046        assert!(
15047            b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15048            "b1: {b1}"
15049        );
15050        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15051        assert!(
15052            !b1.contains(r#""templates":{"comp-a"#),
15053            "b1 re-sent comp-a: {b1}"
15054        );
15055        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15056
15057        // Boundary 2: comp-a reused — state present, template absent (empty delta).
15058        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15059        assert!(
15060            b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15061            "b2: {b2}"
15062        );
15063        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15064        assert!(
15065            !b2.contains(r#""templates""#),
15066            "b2 re-sent a template: {b2}"
15067        );
15068
15069        // Server-only state never leaks into any envelope.
15070        assert!(!writer.output.contains("serverOnly"));
15071    }
15072
15073    #[test]
15074    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15075        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15076        let handler = WebUIHandler::with_plugin(|| {
15077            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15078        });
15079        let mut writer = FlushTestWriter::default();
15080        handler
15081            .render_streaming(
15082                &protocol,
15083                &test_json!({ "a_count": 1, "b_count": 2 }),
15084                &RenderOptions::new("index.html", "/"),
15085                &mut writer,
15086            )
15087            .unwrap();
15088
15089        let segment = |index: usize| -> &str {
15090            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15091        };
15092        let checkpoint_styles = |checkpoint: &str| -> Value {
15093            let script = checkpoint
15094                .rfind(r#"<script type="application/json" data-webui-boundary"#)
15095                .unwrap();
15096            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15097            let payload_end =
15098                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15099            let record: Value =
15100                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15101            record[3]["componentStyles"].clone()
15102        };
15103
15104        let first = checkpoint_styles(segment(0));
15105        assert!(first["resources"]["comp-a"].is_object());
15106        assert!(first["resources"]["comp-b"].is_object());
15107        assert!(first["closures"]["index.html"].is_array());
15108        assert!(first["closures"]["comp-a"].is_array());
15109
15110        let second = checkpoint_styles(segment(1));
15111        assert_eq!(second["resources"], test_json!({}));
15112        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15113
15114        let repeated = checkpoint_styles(segment(2));
15115        assert_eq!(repeated["resources"], test_json!({}));
15116        assert_eq!(repeated["closures"], test_json!({}));
15117        for resource in ["comp-a", "comp-b"] {
15118            assert_eq!(
15119                [&first, &second, &repeated]
15120                    .iter()
15121                    .filter(|styles| {
15122                        styles["resources"]
15123                            .as_object()
15124                            .is_some_and(|resources| resources.contains_key(resource))
15125                    })
15126                    .count(),
15127                1,
15128                "{resource} style metadata was serialized more than once"
15129            );
15130        }
15131    }
15132
15133    #[test]
15134    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15135        let entry = vec![
15136            WebUIFragment::raw("<html><head>"),
15137            structural_fragment("head_start"),
15138            structural_fragment("head_end"),
15139            WebUIFragment::raw("</head><body>"),
15140            structural_fragment("body_start"),
15141            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15142            WebUIFragment::raw("<comp-a"),
15143            structural_fragment("streaming_root:comp-a"),
15144            WebUIFragment::raw(">"),
15145            WebUIFragment::component("comp-a"),
15146            WebUIFragment::raw("</comp-a>"),
15147            WebUIFragment::boundary_end(0),
15148            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15149            WebUIFragment::raw("<comp-hidden"),
15150            structural_fragment("streaming_root:comp-hidden"),
15151            WebUIFragment::raw(">"),
15152            WebUIFragment::component("comp-hidden"),
15153            WebUIFragment::raw("</comp-hidden>"),
15154            WebUIFragment::boundary_end(1),
15155            structural_fragment("body_end"),
15156            WebUIFragment::raw("</body></html>"),
15157        ];
15158        let fragments = HashMap::from([
15159            (
15160                "index.html".to_string(),
15161                FragmentList {
15162                    fragments: entry,
15163                    contains_boundary: true,
15164                },
15165            ),
15166            (
15167                "comp-a".to_string(),
15168                FragmentList {
15169                    fragments: vec![WebUIFragment::if_cond(
15170                        ConditionExpr::identifier("show_hidden"),
15171                        "hidden-if",
15172                    )],
15173                    contains_boundary: false,
15174                },
15175            ),
15176            (
15177                "hidden-if".to_string(),
15178                FragmentList {
15179                    fragments: vec![WebUIFragment::component("comp-hidden")],
15180                    contains_boundary: false,
15181                },
15182            ),
15183            (
15184                "comp-hidden".to_string(),
15185                FragmentList {
15186                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15187                    contains_boundary: false,
15188                },
15189            ),
15190        ]);
15191        let mut document = WebUIProtocol::new(fragments);
15192        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15193        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15194        document.components.insert(
15195            "comp-a".to_string(),
15196            webui_protocol::ComponentData {
15197                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15198                hydration_mode: StateProjectionMode::Keys as i32,
15199                hydration_keys: vec!["show_hidden".to_string()],
15200                ..Default::default()
15201            },
15202        );
15203        document.components.insert(
15204            "comp-hidden".to_string(),
15205            webui_protocol::ComponentData {
15206                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15207                css: ".hidden{display:block}".to_string(),
15208                hydration_mode: StateProjectionMode::Keys as i32,
15209                hydration_keys: vec!["hidden_count".to_string()],
15210                ..Default::default()
15211            },
15212        );
15213        document.populate_style_closures(&["index.html"]);
15214        let protocol = Protocol::new(document);
15215        let handler = WebUIHandler::with_plugin(|| {
15216            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15217        });
15218        let mut writer = FlushTestWriter::default();
15219
15220        handler
15221            .render_streaming(
15222                &protocol,
15223                &test_json!({
15224                    "show_hidden": false,
15225                    "hidden_count": 7,
15226                    "unrelated": "private",
15227                }),
15228                &RenderOptions::new("index.html", "/"),
15229                &mut writer,
15230            )
15231            .unwrap();
15232
15233        let boundary = |index: usize| -> &str {
15234            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15235        };
15236        let first = boundary(0);
15237        let second = boundary(1);
15238
15239        assert!(first.contains(r#""comp-a":"#), "first: {first}");
15240        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15241        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15242        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15243        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15244        assert!(
15245            first.contains(r#"<script type="importmap""#),
15246            "first: {first}"
15247        );
15248        assert!(
15249            !first.contains("unrelated"),
15250            "first leaked unrelated state: {first}"
15251        );
15252
15253        assert!(
15254            !second.contains(r#""templates""#),
15255            "second re-sent metadata: {second}"
15256        );
15257        assert!(
15258            !second.contains(r#"<script type="importmap""#),
15259            "second re-sent CSS: {second}"
15260        );
15261        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15262        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15263        assert_eq!(
15264            writer
15265                .output
15266                .matches(r#"<script type="importmap">"#)
15267                .count(),
15268            1,
15269            "import-map output: {}",
15270            writer.output
15271        );
15272    }
15273
15274    #[test]
15275    fn streaming_flush_count_is_boundaries_plus_one() {
15276        for &count in &[1usize, 3, 10, 100] {
15277            let hosts: Vec<&str> = (0..count)
15278                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15279                .collect();
15280            let protocol = disjoint_streaming_protocol(&hosts);
15281            let handler = WebUIHandler::with_plugin(|| {
15282                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15283            });
15284            let mut writer = FlushTestWriter::default();
15285            handler
15286                .render_streaming(
15287                    &protocol,
15288                    &test_json!({ "a_count": 1, "b_count": 2 }),
15289                    &RenderOptions::new("index.html", "/"),
15290                    &mut writer,
15291                )
15292                .unwrap();
15293            assert_eq!(
15294                writer.flushes.len(),
15295                count + 2,
15296                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15297            );
15298        }
15299    }
15300
15301    #[test]
15302    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15303        let mut fragments = HashMap::new();
15304        fragments.insert("comp-a".to_string(), FragmentList::default());
15305        fragments.insert("comp-b".to_string(), FragmentList::default());
15306        let mut protocol = WebUIProtocol::new(fragments);
15307        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15308        protocol.components.insert(
15309            "comp-a".to_string(),
15310            webui_protocol::ComponentData {
15311                hydration_mode: StateProjectionMode::Keys as i32,
15312                hydration_keys: vec!["a".to_string(), "shared".to_string()],
15313                ..Default::default()
15314            },
15315        );
15316        protocol.components.insert(
15317            "comp-b".to_string(),
15318            webui_protocol::ComponentData {
15319                hydration_mode: StateProjectionMode::Keys as i32,
15320                hydration_keys: vec!["b".to_string(), "shared".to_string()],
15321                ..Default::default()
15322            },
15323        );
15324
15325        let protocol = Protocol::new(protocol);
15326        let component_index = protocol.component_index();
15327        let reachability = protocol.component_reachability();
15328        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15329        assert!(!collect_hydration_key_ids_into(
15330            protocol.protocol(),
15331            reachability,
15332            [component_index["comp-a"]],
15333            &mut scratch,
15334        ));
15335        let keys = scratch
15336            .iter()
15337            .filter_map(|id| reachability.hydration_key(*id))
15338            .collect::<Vec<_>>();
15339        assert_eq!(keys, ["a", "shared"]);
15340        let pointer = scratch.as_ptr();
15341        let capacity = scratch.capacity();
15342
15343        assert!(!collect_hydration_key_ids_into(
15344            protocol.protocol(),
15345            reachability,
15346            [component_index["comp-b"]],
15347            &mut scratch,
15348        ));
15349        let keys = scratch
15350            .iter()
15351            .filter_map(|id| reachability.hydration_key(*id))
15352            .collect::<Vec<_>>();
15353        assert_eq!(keys, ["b", "shared"]);
15354        assert_eq!(scratch.as_ptr(), pointer);
15355        assert_eq!(scratch.capacity(), capacity);
15356    }
15357}