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,
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/// A response writer that can hand buffered bytes to its transport immediately.
264///
265/// Progressive hydration requires this semantic flush at every committed
266/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
267pub trait FlushWriter: ResponseWriter {
268    /// Hand all currently buffered bytes to the underlying transport.
269    fn flush(&mut self) -> Result<()>;
270}
271
272/// Options controlling how the handler renders a protocol.
273///
274/// The handler performs server-side route matching: matched routes are rendered
275/// visible with content; non-matched routes are rendered hidden and empty.
276pub struct RenderOptions<'a> {
277    /// The fragment ID to start rendering from (e.g., `"index.html"`).
278    pub entry_id: &'a str,
279    /// The URL path to match routes against (e.g., `"/contacts/42"`).
280    pub request_path: &'a str,
281    /// Optional CSP nonce for inline `<script>` tags.
282    /// When set, all inline scripts include `nonce="VALUE"` and a
283    /// `<meta name="webui-nonce">` tag is emitted for the client router.
284    pub nonce: Option<&'a str>,
285    /// Optional HTML to emit immediately before the document's
286    /// `</head>` close. Used for per-request `<link rel="preload">`
287    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
288    /// Inserted at the structural `head_end` boundary identified by
289    /// the parser — never matched against a byte pattern, so cannot
290    /// be tricked by `</head>` literals appearing in HTML comments,
291    /// `srcdoc` attributes, or inline scripts.
292    pub head_inject: Option<&'a str>,
293    /// Optional HTML to emit immediately before the document's
294    /// `</body>` close. Used for dev livereload `<script>`, analytics
295    /// snippets, OpenTelemetry trace IDs, etc.
296    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
297    pub body_inject: Option<&'a str>,
298}
299
300/// Reserved top-level state key carrying host-supplied boundary HTML.
301///
302/// The leading `$` marks this key as reserved host metadata rather than
303/// ordinary application state.
304///
305/// Recognized members, each an optional string:
306///
307/// | Member | Emitted at |
308/// | --- | --- |
309/// | `headEnd` | immediately before `</head>` |
310/// | `bodyStart` | immediately after `<body>` |
311/// | `bodyEnd` | immediately before `</body>` |
312///
313/// The key is always honored — it is part of the render state the host
314/// already supplies — and is stripped from the client hydration payload.
315pub const STATE_INJECT_KEY: &str = "$webui";
316
317/// Host-supplied boundary HTML resolved once per render from
318/// [`STATE_INJECT_KEY`].
319///
320/// Resolution happens when the render context is built, so each structural
321/// hook costs one `Option` check instead of a state map lookup. Every field
322/// borrows from the caller's state value — no clone, no allocation.
323#[derive(Clone, Copy, Default)]
324pub(crate) struct StateInject<'state> {
325    pub(crate) head_end: Option<&'state str>,
326    pub(crate) body_start: Option<&'state str>,
327    pub(crate) body_end: Option<&'state str>,
328}
329
330impl<'state> StateInject<'state> {
331    /// Resolve the reserved namespace from a render state value.
332    ///
333    /// Returns the empty set when the key is absent, or when any member is
334    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
335    /// key is an optional side channel, and a render must not fail because a
336    /// host wrote the wrong shape into it.
337    pub(crate) fn resolve(state: &'state Value) -> Self {
338        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
339            return Self::default();
340        };
341        let field = |name: &str| {
342            map.get(name)
343                .and_then(Value::as_str)
344                .filter(|html| !html.is_empty())
345        };
346        Self {
347            head_end: field("headEnd"),
348            body_start: field("bodyStart"),
349            body_end: field("bodyEnd"),
350        }
351    }
352}
353
354impl<'a> RenderOptions<'a> {
355    /// Create render options for the given entry fragment and request path.
356    #[must_use]
357    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
358        Self {
359            entry_id,
360            request_path,
361            nonce: None,
362            head_inject: None,
363            body_inject: None,
364        }
365    }
366
367    /// Set the CSP nonce for inline scripts. Pass an empty string to
368    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
369    /// content="">` would be browser-ignored noise.
370    #[must_use]
371    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
372        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
373        self
374    }
375
376    /// Set HTML to emit immediately before `</head>`.
377    /// Pass an empty string to disable (`None` semantics).
378    ///
379    /// # Safety (XSS warning)
380    ///
381    /// The provided HTML is written verbatim — **no HTML escaping is
382    /// performed**. Callers MUST ensure the content is fully trusted
383    /// (typically a `&'static str` or build-time-derived bytes such as
384    /// dev livereload script, image preload `<link>` tags, or A/B test
385    /// markers). Passing user-controlled or attacker-influenced content
386    /// here is a direct cross-site scripting vulnerability. If your
387    /// caller path may include untrusted data, escape with the host's
388    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
389    /// **before** calling this builder.
390    #[must_use]
391    pub fn with_head_inject(mut self, html: &'a str) -> Self {
392        self.head_inject = if html.is_empty() { None } else { Some(html) };
393        self
394    }
395
396    /// Set HTML to emit immediately before `</body>`.
397    /// Pass an empty string to disable (`None` semantics).
398    ///
399    /// # Safety (XSS warning)
400    ///
401    /// Same contract as [`with_head_inject`](Self::with_head_inject):
402    /// the HTML is written verbatim with **no escaping**, so callers
403    /// MUST ensure the content is fully trusted. Untrusted content is
404    /// a direct XSS vector.
405    #[must_use]
406    pub fn with_body_inject(mut self, html: &'a str) -> Self {
407        self.body_inject = if html.is_empty() { None } else { Some(html) };
408        self
409    }
410}
411
412/// The main WebUI handler that processes protocols and renders them.
413///
414/// The handler is stateless: plugin instances are created per-render from
415/// the stored factory function, allowing concurrent renders with `&self`.
416pub struct WebUIHandler {
417    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
418}
419
420#[derive(Clone, Copy, Eq, PartialEq)]
421enum StyleClosureInstall {
422    Static,
423    Routed,
424}
425
426pub(crate) struct ShadowStyleRoot {
427    component_index: u32,
428    static_closure_emitted: bool,
429    routed_resources: Vec<u32>,
430}
431
432struct LoopBinding<'protocol, 'state> {
433    name: &'protocol str,
434    value: &'state Value,
435}
436
437#[derive(Clone, Copy)]
438struct VisibleLoopScope {
439    start: usize,
440    end: usize,
441}
442
443impl VisibleLoopScope {
444    const EMPTY: Self = Self { start: 0, end: 0 };
445}
446
447#[derive(Clone, Copy)]
448struct LocalValueSources<'ctx, 'protocol, 'state> {
449    owned: &'ctx HashMap<String, Value>,
450    borrowed: &'ctx BorrowedScope<'protocol, 'state>,
451}
452
453#[derive(Default)]
454struct BorrowedScope<'protocol, 'state> {
455    inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
456    inline_len: usize,
457    overflow: Vec<(&'protocol str, &'state Value)>,
458}
459
460impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
461    fn get(&self, name: &str) -> Option<&'state Value> {
462        if self.inline_len == 0 {
463            return None;
464        }
465        if let Some((entry_name, value)) = self.inline[0].as_ref() {
466            if *entry_name == name {
467                return Some(*value);
468            }
469        }
470        for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
471            if *entry_name == name {
472                return Some(*value);
473            }
474        }
475        self.overflow
476            .iter()
477            .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
478    }
479
480    fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
481        for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
482            if *entry_name == name {
483                return Some(std::mem::replace(current, value));
484            }
485        }
486        for (entry_name, current) in &mut self.overflow {
487            if *entry_name == name {
488                return Some(std::mem::replace(current, value));
489            }
490        }
491        if self.inline_len < INLINE_SCOPE_SLOTS {
492            self.inline[self.inline_len] = Some((name, value));
493            self.inline_len += 1;
494        } else {
495            self.overflow.push((name, value));
496        }
497        None
498    }
499
500    fn remove(&mut self, name: &str) -> Option<&'state Value> {
501        if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
502            entry
503                .as_ref()
504                .is_some_and(|(entry_name, _)| *entry_name == name)
505        }) {
506            let removed = self.inline[index].take().map(|(_, value)| value);
507            if let Some(entry) = self.overflow.pop() {
508                self.inline[index] = Some(entry);
509            } else {
510                self.inline_len -= 1;
511                self.inline[index] = self.inline[self.inline_len].take();
512            }
513            return removed;
514        }
515        let index = self
516            .overflow
517            .iter()
518            .position(|(entry_name, _)| *entry_name == name)?;
519        Some(self.overflow.swap_remove(index).1)
520    }
521
522    fn clear(&mut self) {
523        for entry in &mut self.inline[..self.inline_len] {
524            *entry = None;
525        }
526        self.inline_len = 0;
527        self.overflow.clear();
528    }
529
530    fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
531        for (name, value) in self.inline[..self.inline_len].iter().flatten() {
532            target.insert((*name).to_owned(), (*value).clone());
533        }
534        for (name, value) in &self.overflow {
535            target.insert((*name).to_owned(), (*value).clone());
536        }
537    }
538}
539
540/// A fragment list paired with the render metadata prepared for it when the
541/// runtime [`Protocol`] was loaded.
542///
543/// `fragments` still points at the protocol's own storage, so passing this by
544/// value never copies or clones a fragment graph.
545#[derive(Clone, Copy)]
546pub(crate) struct RenderFragmentList<'protocol> {
547    fragments: &'protocol [WebUIFragment],
548    metadata: &'protocol [RenderFragmentMetadata],
549    attr_names: &'protocol str,
550    /// True when this list contains at least one `<webui-route>` fragment.
551    /// Renders skip the sibling route pre-scan entirely when it is false.
552    has_routes: bool,
553}
554
555impl<'protocol> RenderFragmentList<'protocol> {
556    /// Render slot the fragment at `index` descends into, if any.
557    fn target(self, index: usize) -> Option<usize> {
558        let target = self.metadata.get(index)?.target;
559        (target != NO_RENDER_SLOT).then_some(target as usize)
560    }
561
562    /// Canonical camelCase component prop name prepared for an attribute fragment.
563    fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
564        let prepared = self.metadata.get(index)?;
565        if prepared.attr_start == NO_ATTR_NAME {
566            return None;
567        }
568        let start = prepared.attr_start as usize;
569        self.attr_names
570            .get(start..start + prepared.attr_len as usize)
571    }
572}
573
574/// Sentinel for "this fragment does not descend into another fragment list".
575const NO_RENDER_SLOT: u32 = u32::MAX;
576/// Sentinel for "this fragment is not an attribute fragment".
577const NO_ATTR_NAME: u32 = u32::MAX;
578
579/// Per-fragment values hoisted out of the render loop at protocol load time.
580///
581/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
582/// protocol keeps one contiguous arena instead of one heap block per fragment.
583#[derive(Clone, Copy)]
584struct RenderFragmentMetadata {
585    /// Numeric slot of the fragment list this fragment renders into, avoiding a
586    /// string hash lookup per component, loop, condition, and template attribute.
587    /// [`NO_RENDER_SLOT`] when the fragment renders inline.
588    target: u32,
589    /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
590    attr_start: u32,
591    attr_len: u32,
592}
593
594/// Build-time render plan for every fragment list in a protocol.
595///
596/// Built once when a [`Protocol`] is created and shared immutably by every
597/// render. Fragment IDs are the same `Arc<str>` values the protocol already
598/// interns, metadata lives in one flat arena, and the fragment graphs
599/// themselves are never duplicated.
600pub(crate) struct RenderFragmentIndex {
601    ids: Box<[Arc<str>]>,
602    metadata: Box<[RenderFragmentMetadata]>,
603    /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
604    ranges: Box<[u32]>,
605    /// Every prepared component prop name concatenated into one allocation.
606    attr_names: Box<str>,
607    /// One bit per fragment list: does it contain a route fragment?
608    route_presence: Box<[u64]>,
609}
610
611/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
612///
613/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
614/// a large protocol is never eagerly walked to serve a small render.
615pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
616    cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
617    /// Memo slots for protocols with more fragment lists than the inline cache
618    /// holds, allocated on first use so a small render never pays for it and a
619    /// large one never falls back to hashing the fragment ID on every descent.
620    spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
621    index: &'protocol RenderFragmentIndex,
622    protocol: &'protocol WebUIProtocol,
623}
624
625impl RenderFragmentIndex {
626    /// Prepare the render plan for `protocol`.
627    ///
628    /// `ids` must be the protocol's sorted fragment IDs and `slots` its ID→slot
629    /// map, so render slots and the runtime [`Protocol`]'s continuation slots are
630    /// the same numbering. Reusing the map keeps preparation to one hash lookup
631    /// per reference instead of a string binary search.
632    pub(crate) fn new(
633        protocol: &WebUIProtocol,
634        ids: &[Arc<str>],
635        slots: &HashMap<Arc<str>, u32>,
636    ) -> Self {
637        let total_fragments = protocol
638            .fragments
639            .values()
640            .map(|list| list.fragments.len())
641            .sum();
642        let mut metadata = Vec::with_capacity(total_fragments);
643        let mut ranges = Vec::with_capacity(ids.len() + 1);
644        // Most attribute names are short; reserving up front keeps the shared
645        // arena from repeatedly reallocating and copying as it is filled.
646        let mut attr_names = String::with_capacity(total_fragments * 8);
647        let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
648
649        for (slot, id) in ids.iter().enumerate() {
650            // Record counts are bounded by the compiled graph, well inside u32.
651            #[allow(clippy::cast_possible_truncation)]
652            ranges.push(metadata.len() as u32);
653            let Some(list) = protocol.fragments.get(id.as_ref()) else {
654                continue;
655            };
656            let mut has_routes = false;
657            for fragment in &list.fragments {
658                let inner = fragment.fragment.as_ref();
659                if matches!(inner, Some(Fragment::Route(_))) {
660                    has_routes = true;
661                }
662                let target = fragment_target_id(fragment)
663                    .and_then(|target| slots.get(target).copied())
664                    .unwrap_or(NO_RENDER_SLOT);
665                let (attr_start, attr_len) = match inner {
666                    // `attr_skip` attributes are never collected into component
667                    // props, so preparing a name for them would be pure load-time
668                    // cost for something no render ever reads.
669                    Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
670                        #[allow(clippy::cast_possible_truncation)]
671                        let start = attr_names.len() as u32;
672                        let before = attr_names.len();
673                        push_component_attr_name(&mut attr_names, component_attr_source(attribute));
674                        #[allow(clippy::cast_possible_truncation)]
675                        let len = (attr_names.len() - before) as u32;
676                        (start, len)
677                    }
678                    _ => (NO_ATTR_NAME, 0),
679                };
680                metadata.push(RenderFragmentMetadata {
681                    target,
682                    attr_start,
683                    attr_len,
684                });
685            }
686            if has_routes {
687                route_presence[slot / 64] |= 1u64 << (slot % 64);
688            }
689        }
690        #[allow(clippy::cast_possible_truncation)]
691        ranges.push(metadata.len() as u32);
692
693        Self {
694            ids: ids.to_vec().into_boxed_slice(),
695            metadata: metadata.into_boxed_slice(),
696            ranges: ranges.into_boxed_slice(),
697            attr_names: attr_names.into_boxed_str(),
698            route_presence: route_presence.into_boxed_slice(),
699        }
700    }
701
702    /// Resolve a fragment ID to its stable numeric render slot.
703    pub(crate) fn index(&self, id: &str) -> Option<usize> {
704        self.ids
705            .binary_search_by(|candidate| candidate.as_ref().cmp(id))
706            .ok()
707    }
708
709    pub(crate) fn resolve<'protocol>(
710        &'protocol self,
711        protocol: &'protocol WebUIProtocol,
712    ) -> ResolvedRenderFragmentIndex<'protocol> {
713        ResolvedRenderFragmentIndex {
714            cache: std::array::from_fn(|_| Cell::new(None)),
715            spilled: OnceCell::new(),
716            index: self,
717            protocol,
718        }
719    }
720}
721
722impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
723    pub(crate) fn index(&self, id: &str) -> Option<usize> {
724        self.index.index(id)
725    }
726
727    /// Memo slot for `index`, spilling past the inline cache on demand.
728    fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
729        if index < INLINE_RENDER_FRAGMENT_LISTS {
730            return self.cache.get(index);
731        }
732        let spilled = self.spilled.get_or_init(|| {
733            let overflow = self
734                .index
735                .ids
736                .len()
737                .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
738            (0..overflow).map(|_| Cell::new(None)).collect()
739        });
740        spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
741    }
742
743    pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
744        let id = self.index.ids.get(index)?;
745        let fragment_list = match self.slot(index) {
746            Some(slot) => match slot.get() {
747                Some(cached) => cached,
748                None => {
749                    let resolved = self.protocol.fragments.get(id.as_ref())?;
750                    slot.set(Some(resolved));
751                    resolved
752                }
753            },
754            None => self.protocol.fragments.get(id.as_ref())?,
755        };
756        let start = *self.index.ranges.get(index)? as usize;
757        let end = *self.index.ranges.get(index + 1)? as usize;
758        let metadata = self.index.metadata.get(start..end)?;
759        let has_routes = self
760            .index
761            .route_presence
762            .get(index / 64)
763            .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
764        Some(RenderFragmentList {
765            fragments: &fragment_list.fragments,
766            metadata,
767            attr_names: &self.index.attr_names,
768            has_routes,
769        })
770    }
771
772    #[cfg(test)]
773    pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
774        self.index(id).and_then(|index| self.list(index))
775    }
776}
777
778/// Name that a component prop is derived from for an attribute fragment.
779///
780/// The render path strips a leading `:` twice on the complex-value branch and
781/// once on every other branch. Every field that selects the branch is a
782/// build-time constant, so the choice is made here and the prepared name stays
783/// byte-identical to the per-render conversion it replaces.
784fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
785    let takes_complex_branch = attribute.condition_tree.is_none()
786        && attribute.template.is_empty()
787        && !attribute.value.is_empty()
788        && !attribute.raw_value
789        && attribute.complex;
790    if takes_complex_branch {
791        attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
792    } else {
793        &attribute.name
794    }
795}
796
797/// Fragment ID a fragment descends into, or `None` when it renders inline.
798fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
799    match fragment.fragment.as_ref()? {
800        Fragment::Component(component) => Some(&component.fragment_id),
801        Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
802        Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
803        Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
804            Some(&attribute.template)
805        }
806        _ => None,
807    }
808}
809
810/// Context object for processing WebUI fragments
811pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
812    pub(crate) protocol: &'protocol WebUIProtocol,
813    /// Render plan prepared once when the runtime [`Protocol`] was loaded.
814    /// Fragment lists resolve through numeric slots instead of per-fragment
815    /// string hashing.
816    pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
817    /// Build-constant component asset style metadata serialized once when the
818    /// runtime [`Protocol`] is created.
819    pub(crate) component_asset_style_manifest: &'protocol str,
820    /// Deduplicated document-scoped component asset styles for Light DOM.
821    pub(crate) component_asset_style_links: &'protocol str,
822    pub(crate) state: &'state Value,
823    pub(crate) writer: &'output mut dyn ResponseWriter,
824    pub(crate) local_vars: HashMap<String, Value>,
825    /// Component-local values that still point into immutable request state.
826    local_borrowed_vars: BorrowedScope<'protocol, 'state>,
827    /// Borrowed loop bindings, in lexical order.
828    loop_vars: Vec<LoopBinding<'protocol, 'state>>,
829    /// Range of `loop_vars` visible to the current fragment scope. Component
830    /// bodies hide outer loop monikers while still allowing their own loops.
831    visible_loop_scope: VisibleLoopScope,
832    /// Accumulates component attribute values between attrStart and the component fragment.
833    pub(crate) component_attrs: HashMap<String, Value>,
834    /// State-backed component attributes accumulated without cloning.
835    component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
836    /// True only while parser-produced component opening-tag attributes are
837    /// being accumulated. Native element attributes render directly and never
838    /// enter `component_attrs`.
839    collecting_component_attrs: bool,
840    /// URL path for server-side route matching. Borrowed from
841    /// `RenderOptions<'a>::request_path` — zero-copy.
842    pub(crate) request_path: &'protocol str,
843    /// Base path for resolving relative route paths (`./`).
844    /// Updated as the handler descends into nested matched routes.
845    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
846    /// descent owns the recomputed path.
847    pub(crate) route_base: Cow<'protocol, str>,
848    /// Component names visited during rendering (for selective f-template emission
849    /// and CSS module dedup — only the first render of each component emits
850    /// its `<script type="importmap">` data-URI tag).
851    pub(crate) rendered_components: HashSet<String>,
852    /// Per-render plugin instance created from the handler's factory.
853    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
854    /// Current position in the route tree for outlet-based rendering.
855    /// Contains the children of the currently matched route fragment.
856    pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
857    /// Entry fragment ID — used to compute the initial inventory at head_end.
858    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
859    pub(crate) entry_id: &'protocol str,
860    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
861    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
862    pub(crate) nonce: Option<&'protocol str>,
863    /// Component-name → bit-position map built once when the runtime
864    /// [`Protocol`] is created and shared by every render.
865    pub(crate) component_index: &'protocol HashMap<String, u32>,
866    /// Style-resource ID → request-local dedup bit built with [`Protocol`].
867    pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
868    /// Inline style resources whose CSS contains a case-insensitive `</style`.
869    ///
870    /// Computed once when [`Protocol`] loads so repeated component instances can
871    /// write ordinary CSS without rescanning identical bytes on every request.
872    pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
873    /// Component tag → covering bundle chunk, built once per render.
874    ///
875    /// Empty (and allocation-free) for unbundled builds. Every style delivery
876    /// path resolves closure members through this so they all agree on what a
877    /// chunk already ships.
878    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
879    /// CSS strategy declared by the compiled protocol.
880    pub(crate) css_strategy: webui_protocol::CssStrategy,
881    /// HTML emitted at the structural `head_end` boundary (before
882    /// `</head>`), after the built-in nonce/CSS emissions.
883    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
884    /// (no per-render clone — saves an allocation when the host passes
885    /// a `&'static str` such as a dev livereload script).
886    pub(crate) head_inject: Option<&'protocol str>,
887    /// HTML emitted at the structural `body_end` boundary (before
888    /// `</body>`), after the built-in template metadata emissions.
889    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
890    pub(crate) body_inject: Option<&'protocol str>,
891    /// Host HTML resolved once per render from the reserved
892    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
893    /// emissions at each structural boundary, and after the corresponding
894    /// `RenderOptions` inject at `head_end` and `body_end`.
895    pub(crate) state_inject: StateInject<'state>,
896    /// Tracks whether the `head_end` hook has already fired in this
897    /// render. Defends against malformed protocols that emit the
898    /// signal more than once (e.g., a template with multiple `<head>`
899    /// tags) — without this, host-supplied `head_inject` HTML, CSS
900    /// resources, and the CSP `<meta>` nonce would be
901    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
902    pub(crate) head_end_emitted: bool,
903    /// Tracks whether the `body_start` hook has already fired in this
904    /// render. Defends against malformed protocols emitting the signal
905    /// twice — without this, state-supplied `bodyStart` HTML would be
906    /// duplicated.
907    pub(crate) body_start_emitted: bool,
908    /// Tracks whether compiler-owned component asset styles were emitted at
909    /// either the head or body fallback boundary.
910    pub(crate) component_asset_styles_emitted: bool,
911    /// Tracks whether the `body_end` hook has already fired in this
912    /// render. Defends against malformed protocols emitting the
913    /// signal twice — without this, hydration `<script>` blocks and
914    /// host-supplied `body_inject` would be duplicated.
915    pub(crate) body_end_emitted: bool,
916    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
917    pub(crate) route_index: &'protocol CompiledRouteIndex,
918    /// Counter for `data-ri` attributes on matched route elements.
919    /// Incremented each time a matched route is rendered, allowing O(1) element
920    /// binding on the client side instead of DOM-walking.
921    pub(crate) route_chain_index: usize,
922    /// Matched route metadata computed at most once when head styles or body
923    /// bootstrap first need it, then shared by streaming checkpoints.
924    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
925    /// Whether each matched route closure installs into the Document CSS tree.
926    ///
927    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
928    pub(crate) route_document_style_targets: Vec<bool>,
929    /// Request-reachable components in deterministic first-discovery order.
930    ///
931    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
932    /// `body_end` for hydration metadata so the fragment graph is not walked
933    /// twice per request.
934    pub(crate) reachable_components: Option<Vec<String>>,
935    /// Present only for the opt-in progressive streaming render path.
936    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
937    /// Reusable JSON serialization scratch buffer, owned by the render context.
938    /// The bootstrap/field/template helpers borrow it so each render reuses one
939    /// buffer across every serialized field and every streaming checkpoint
940    /// instead of allocating per value. It grows lazily on first serialization
941    /// and is dropped with the context at request end — no per-thread
942    /// high-water buffer is retained between requests.
943    pub(crate) json_scratch: Vec<u8>,
944    /// Small request-local pool of cleared scope maps reused across sibling
945    /// component roots. `process_component` recycles each finished component's
946    /// local/attr map here instead of dropping it, so a sibling reuses the
947    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
948    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
949    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
950    /// Resources delivered into the Document CSS tree. The empty set does not
951    /// allocate, and streaming retains it across checkpoints.
952    pub(crate) document_style_resources: HashSet<String>,
953    /// Active Shadow roots and their route-activated resource indexes. The
954    /// route vector allocates only when a matched Light route contributes CSS.
955    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
956    /// Capacity-preserving pool for borrowed component scope maps.
957    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
958}
959
960/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
961/// authored double- or triple-brace expressions because it closes the binding.
962pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
963
964pub(crate) fn structural_signal_value(
965    signal: &webui_protocol::WebUIFragmentSignal,
966) -> Option<&str> {
967    if !signal.raw {
968        return None;
969    }
970    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
971}
972
973pub(crate) fn write_interaction_marker(
974    tag: &str,
975    context: &mut WebUIProcessContext<'_, '_, '_>,
976) -> Result<()> {
977    if matches!(
978        context
979            .protocol
980            .components
981            .get(tag)
982            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
983        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
984    ) {
985        context.writer.write(" data-webui-interaction")?;
986    }
987    Ok(())
988}
989
990/// Find the end of a leading doctype without treating quoted `>` bytes in
991/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
992fn doctype_prefix_end(raw: &str) -> Option<usize> {
993    const PREFIX: &[u8] = b"<!doctype";
994
995    let bytes = raw.as_bytes();
996    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
997    while bytes
998        .get(cursor)
999        .is_some_and(|byte| byte.is_ascii_whitespace())
1000    {
1001        cursor += 1;
1002    }
1003
1004    let prefix_end = cursor.checked_add(PREFIX.len())?;
1005    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1006        return None;
1007    }
1008    let separator = *bytes.get(prefix_end)?;
1009    if separator != b'>' && !separator.is_ascii_whitespace() {
1010        return None;
1011    }
1012
1013    let mut quote = None;
1014    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1015        match quote {
1016            Some(active) if byte == active => quote = None,
1017            Some(_) => {}
1018            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1019            None if byte == b'>' => return Some(prefix_end + offset + 1),
1020            None => {}
1021        }
1022    }
1023    None
1024}
1025
1026/// Maximum scope maps retained in the request-local pool. Small: sibling
1027/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1028const SCOPE_POOL_CAP: usize = 8;
1029/// Borrowed component scopes typically contain only a handful of attributes.
1030const INLINE_SCOPE_SLOTS: usize = 4;
1031/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1032/// a typical app touches in one render while keeping the per-render zeroing cost
1033/// negligible; larger protocols fall back to the protocol map for the overflow
1034/// slots instead of eagerly materializing every list.
1035/// Inline memo slots for fragment lists.
1036///
1037/// Deliberately small: the array is zeroed on every render, so a shallow render
1038/// touching two fragments should not pay to clear slots it will never read.
1039/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1040/// memoized without charging that cost to the common shallow case.
1041const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1042const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1043
1044struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1045
1046impl Serialize for ComponentAssetStyleManifest<'_> {
1047    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1048    where
1049        S: serde::Serializer,
1050    {
1051        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1052        for entry in self.0 {
1053            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1054        }
1055        map.end()
1056    }
1057}
1058
1059fn serialize_component_asset_style_manifest(
1060    preloads: &[ComponentAssetStylePreload],
1061) -> std::result::Result<String, serde_json::Error> {
1062    if preloads.is_empty() {
1063        return Ok(String::new());
1064    }
1065    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1066    if json.contains("</") {
1067        Ok(json.replace("</", "<\\/"))
1068    } else {
1069        Ok(json)
1070    }
1071}
1072
1073fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1074    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1075    let mut seen = HashSet::with_capacity(href_count);
1076    let mut links = String::with_capacity(href_count.saturating_mul(48));
1077    for entry in preloads {
1078        for href in &entry.style_hrefs {
1079            if !seen.insert(href.as_str()) {
1080                continue;
1081            }
1082            links.push_str("<link rel=\"stylesheet\" href=\"");
1083            push_escaped_html_attribute(&mut links, href);
1084            links.push_str("\">");
1085        }
1086    }
1087    links
1088}
1089
1090fn push_escaped_html_attribute(output: &mut String, value: &str) {
1091    let mut start = 0;
1092    for (index, ch) in value.char_indices() {
1093        let escaped = match ch {
1094            '&' => Some("&amp;"),
1095            '"' => Some("&quot;"),
1096            '<' => Some("&lt;"),
1097            '>' => Some("&gt;"),
1098            _ => None,
1099        };
1100        let Some(entity) = escaped else {
1101            continue;
1102        };
1103        if start < index {
1104            output.push_str(&value[start..index]);
1105        }
1106        output.push_str(entity);
1107        start = index + ch.len_utf8();
1108    }
1109    if start < value.len() {
1110        output.push_str(&value[start..]);
1111    }
1112}
1113
1114/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1115/// empty. A fresh `HashMap` does not allocate until its first insert.
1116fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1117    pool.pop().unwrap_or_default()
1118}
1119
1120/// Return a spent scope map to the pool, clearing it but retaining its bucket
1121/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1122/// retained memory stays bounded.
1123fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1124    if pool.len() < SCOPE_POOL_CAP {
1125        map.clear();
1126        pool.push(map);
1127    }
1128}
1129
1130fn take_borrowed_scope<'protocol, 'state>(
1131    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1132) -> BorrowedScope<'protocol, 'state> {
1133    pool.pop().unwrap_or_default()
1134}
1135
1136fn recycle_borrowed_scope<'protocol, 'state>(
1137    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1138    mut scope: BorrowedScope<'protocol, 'state>,
1139) {
1140    if pool.len() < SCOPE_POOL_CAP {
1141        scope.clear();
1142        pool.push(scope);
1143    }
1144}
1145
1146pub(crate) struct WebUiBootstrap<'a> {
1147    pub(crate) declaration_id: Option<u32>,
1148    pub(crate) enclosing_span_instance_id: Option<u32>,
1149    pub(crate) state: &'a Value,
1150    pub(crate) state_selection: StateSelection<'a>,
1151    pub(crate) chain: &'a [Value],
1152    pub(crate) inventory: &'a str,
1153    pub(crate) nonce: Option<&'a str>,
1154    pub(crate) css_hrefs: &'a [&'a str],
1155    pub(crate) style_specs: &'a [&'a str],
1156    pub(crate) component_styles: &'a Value,
1157    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1158}
1159
1160/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1161///
1162/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1163/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1164/// `tabindex`) via the shared lookup table.
1165///
1166/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1167/// this, so it remains only as the single-name form of
1168/// [`push_component_attr_name`] for tests.
1169#[cfg(test)]
1170fn component_attr_name(name: &str) -> String {
1171    let mut buffer = String::new();
1172    push_component_attr_name(&mut buffer, name);
1173    buffer
1174}
1175
1176/// Append the canonical camelCase component prop name for `name` to `buffer`.
1177///
1178/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1179/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1180/// like `readonly`, `tabindex`) through the shared lookup table.
1181///
1182/// Used while preparing the render fragment index so a protocol with thousands
1183/// of attribute fragments grows one arena instead of allocating per name.
1184fn push_component_attr_name(buffer: &mut String, name: &str) {
1185    let stripped = name.strip_prefix(':').unwrap_or(name);
1186    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1187    // straight into the shared arena. That function returns an owned `String`,
1188    // so calling it here would allocate and drop one buffer per attribute while
1189    // preparing a protocol.
1190    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1191        buffer.push_str(property);
1192        return;
1193    }
1194    buffer.reserve(stripped.len());
1195    let mut capitalize_next = false;
1196    for ch in stripped.chars() {
1197        if ch == '-' {
1198            capitalize_next = true;
1199        } else if capitalize_next {
1200            buffer.extend(ch.to_uppercase());
1201            capitalize_next = false;
1202        } else {
1203            buffer.push(ch);
1204        }
1205    }
1206}
1207
1208#[cold]
1209#[inline(never)]
1210fn missing_component_attr_name_error() -> HandlerError {
1211    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1212}
1213
1214/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1215pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1216    if n == 0 {
1217        return writer.write("0");
1218    }
1219    // Max digits for a 64-bit usize is 20.
1220    let mut buf = [0u8; 20];
1221    let mut pos = buf.len();
1222    while n > 0 {
1223        pos -= 1;
1224        // n % 10 is always in 0..=9, fits in u8 without truncation.
1225        #[allow(clippy::cast_possible_truncation)]
1226        let digit = (n % 10) as u8;
1227        buf[pos] = b'0' + digit;
1228        n /= 10;
1229    }
1230    // Digits are always valid ASCII/UTF-8.
1231    match std::str::from_utf8(&buf[pos..]) {
1232        Ok(s) => writer.write(s),
1233        Err(_) => writer.write("0"),
1234    }
1235}
1236
1237pub(crate) fn write_script_safe_json<T>(
1238    writer: &mut dyn ResponseWriter,
1239    scratch: &mut Vec<u8>,
1240    value: &T,
1241) -> Result<()>
1242where
1243    T: Serialize + ?Sized,
1244{
1245    // Serialize into the caller's request-local `scratch`. The streaming path
1246    // emits one bootstrap envelope per committed boundary and the ordinary
1247    // body_end bootstrap serializes several fields, so reusing one buffer across
1248    // the render avoids a fresh allocation per value. The buffer grows lazily on
1249    // first use (no allocation until serialization needs it) and is dropped with
1250    // the render context — capacity is reused within a request but never
1251    // retained across requests.
1252    scratch.clear();
1253    serde_json::to_writer(&mut *scratch, value)
1254        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1255    let json = std::str::from_utf8(scratch)
1256        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1257    write_script_safe_json_str(writer, json)
1258}
1259
1260fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1261    let mut start = 0;
1262    while start < json.len() {
1263        let rest = &json[start..];
1264        let Some(offset) = rest.find("</") else {
1265            writer.write(rest)?;
1266            return Ok(());
1267        };
1268
1269        if offset > 0 {
1270            writer.write(&rest[..offset])?;
1271        }
1272        writer.write("<\\/")?;
1273        start += offset + 2;
1274    }
1275    Ok(())
1276}
1277
1278fn write_json_field_name(
1279    writer: &mut dyn ResponseWriter,
1280    wrote_field: &mut bool,
1281    name: &str,
1282) -> Result<()> {
1283    if *wrote_field {
1284        writer.write(",")?;
1285    }
1286    *wrote_field = true;
1287    writer.write("\"")?;
1288    writer.write(name)?;
1289    writer.write("\":")
1290}
1291
1292fn write_json_field<T>(
1293    writer: &mut dyn ResponseWriter,
1294    scratch: &mut Vec<u8>,
1295    wrote_field: &mut bool,
1296    name: &str,
1297    value: &T,
1298) -> Result<()>
1299where
1300    T: Serialize + ?Sized,
1301{
1302    write_json_field_name(writer, wrote_field, name)?;
1303    write_script_safe_json(writer, scratch, value)
1304}
1305
1306/// Serialize wrapper that projects an SSR state object down to only the
1307/// keys present in the build-time hydration allowlist.
1308///
1309/// This is the runtime half of the projected-hydration design: instead of
1310/// serializing the entire application state (potentially megabytes) on every
1311/// full-HTML render, only the fields a component actually hydrates are
1312/// emitted. The request allowlist conservatively includes every reachable
1313/// component's hydration keys so no field a component needs is dropped.
1314///
1315/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1316/// by compiled client metadata is browser-facing, so hosts must never place
1317/// secrets in browser render state.
1318///
1319/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1320/// is smaller: hydration keys with direct map lookup for wide states, or state
1321/// entries with binary-search membership for compact states. Non-object states
1322/// carry nothing hydratable and serialize as an empty object.
1323struct ProjectedState<'a> {
1324    value: &'a Value,
1325    keys: KeyView<'a>,
1326}
1327
1328impl Serialize for ProjectedState<'_> {
1329    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1330    where
1331        S: serde::Serializer,
1332    {
1333        let Value::Object(map) = self.value else {
1334            return serializer.serialize_map(Some(0))?.end();
1335        };
1336
1337        let mut out = serializer.serialize_map(None)?;
1338        if self.keys.len() < map.len() {
1339            let mut previous = None;
1340            for key in self.keys.iter() {
1341                if key == STATE_INJECT_KEY {
1342                    continue;
1343                }
1344                if previous == Some(key) {
1345                    continue;
1346                }
1347                previous = Some(key);
1348                if let Some(value) = map.get(key) {
1349                    out.serialize_entry(key, value)?;
1350                }
1351            }
1352        } else {
1353            for (key, value) in map {
1354                if key == STATE_INJECT_KEY {
1355                    continue;
1356                }
1357                if self.keys.contains(key.as_str()) {
1358                    out.serialize_entry(key, value)?;
1359                }
1360            }
1361        }
1362        out.end()
1363    }
1364}
1365
1366/// Write the SSR `state` into the bootstrap block according to the protocol's
1367/// build-time selection and escape it for safe embedding in a `<script>`.
1368///
1369/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1370/// payload — a large state with a small hydratable surface — serde ever only
1371/// touches the projected subset. Serialization reuses the proven
1372/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1373/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1374/// when every key is hydratable and collapses to a few bytes when it is not.
1375/// Buffering the projected bytes and escaping once is measurably faster than
1376/// streaming through a per-token `io::Write` adapter, and the projected buffer
1377/// is tiny in the common case.
1378/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1379/// omitted.
1380///
1381/// The reserved key carries host-supplied boundary HTML, not application
1382/// state, so it must never reach the client hydration payload — shipping it
1383/// would both duplicate the markup as a JSON string and expose the host's
1384/// inject channel to client code. Filtering happens during serialization so
1385/// the state tree is never cloned.
1386struct StateWithoutReservedKey<'a> {
1387    value: &'a Value,
1388}
1389
1390impl Serialize for StateWithoutReservedKey<'_> {
1391    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1392    where
1393        S: serde::Serializer,
1394    {
1395        let Value::Object(map) = self.value else {
1396            return self.value.serialize(serializer);
1397        };
1398        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1399        for (key, value) in map {
1400            if key == STATE_INJECT_KEY {
1401                continue;
1402            }
1403            out.serialize_entry(key, value)?;
1404        }
1405        out.end()
1406    }
1407}
1408
1409/// Write the complete state, stripping the reserved inject key when present.
1410///
1411/// The membership test is a single map lookup and the common case — no
1412/// reserved key — takes the original zero-overhead path unchanged.
1413fn write_full_state(
1414    writer: &mut dyn ResponseWriter,
1415    scratch: &mut Vec<u8>,
1416    state: &Value,
1417) -> Result<()> {
1418    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1419        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1420    }
1421    write_script_safe_json(writer, scratch, state)
1422}
1423
1424pub(crate) fn write_selected_state(
1425    writer: &mut dyn ResponseWriter,
1426    scratch: &mut Vec<u8>,
1427    state: &Value,
1428    selection: &StateSelection<'_>,
1429) -> Result<()> {
1430    let keys = match selection {
1431        StateSelection::Full => return write_full_state(writer, scratch, state),
1432        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1433        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1434    };
1435    if keys.is_empty() {
1436        return writer.write("{}");
1437    }
1438
1439    // Projection membership may use binary search, so a mis-sorted key set
1440    // would silently drop hydration keys. The key allowlist is produced sorted +
1441    // deduped at build time; this guard makes hand-built protocols that violate
1442    // the invariant fail loudly in tests at zero release cost.
1443    debug_assert!(
1444        keys.iter()
1445            .zip(keys.iter().skip(1))
1446            .all(|(left, right)| left <= right),
1447        "hydration keys must be sorted for binary-search projection"
1448    );
1449    if let Value::Object(map) = state {
1450        let selects_entire_map =
1451            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1452        if selects_entire_map {
1453            return write_full_state(writer, scratch, state);
1454        }
1455    }
1456    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1457}
1458
1459// Covers the common route surface without trusting protocol-derived counts for
1460// an eager allocation; larger key sets grow only as actual keys are visited.
1461pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1462
1463/// Request-scoped state selection derived from reachable component metadata.
1464pub(crate) enum StateSelection<'a> {
1465    /// Preserve the complete state value.
1466    Full,
1467    /// Project an object to a sorted, deduplicated key allowlist.
1468    Keys(Vec<&'a str>),
1469    /// Project using the streaming continuation's interned hydration key IDs.
1470    ///
1471    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1472    /// sorted key slice and the projection needs no borrowed-string buffer at
1473    /// all — the streaming record keeps its scratch as plain integers that
1474    /// survive every semantic step.
1475    KeyIds(HydrationKeySelection<'a>),
1476}
1477
1478/// A projection expressed as interned hydration key IDs.
1479#[derive(Clone, Copy)]
1480pub(crate) struct HydrationKeySelection<'a> {
1481    pub(crate) ids: &'a [u32],
1482    pub(crate) index: &'a ComponentReachabilityIndex,
1483}
1484
1485impl<'a> HydrationKeySelection<'a> {
1486    fn key(self, position: usize) -> Option<&'a str> {
1487        let id = self.ids.get(position).copied()?;
1488        self.index.hydration_key(id)
1489    }
1490}
1491
1492/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1493#[derive(Clone, Copy)]
1494enum KeyView<'a> {
1495    Borrowed(&'a [&'a str]),
1496    Ids(HydrationKeySelection<'a>),
1497}
1498
1499impl<'a> KeyView<'a> {
1500    fn len(self) -> usize {
1501        match self {
1502            Self::Borrowed(keys) => keys.len(),
1503            Self::Ids(selection) => selection.ids.len(),
1504        }
1505    }
1506
1507    fn is_empty(self) -> bool {
1508        self.len() == 0
1509    }
1510
1511    fn get(self, position: usize) -> Option<&'a str> {
1512        match self {
1513            Self::Borrowed(keys) => keys.get(position).copied(),
1514            Self::Ids(selection) => selection.key(position),
1515        }
1516    }
1517
1518    fn iter(self) -> impl Iterator<Item = &'a str> {
1519        (0..self.len()).map_while(move |position| self.get(position))
1520    }
1521
1522    /// Sorted-membership probe used when the state object is smaller than the
1523    /// allowlist.
1524    fn contains(self, key: &str) -> bool {
1525        match self {
1526            Self::Borrowed(keys) => keys
1527                .binary_search_by(|candidate| str::cmp(candidate, key))
1528                .is_ok(),
1529            Self::Ids(selection) => selection
1530                .ids
1531                .binary_search_by(|id| {
1532                    selection
1533                        .index
1534                        .hydration_key(*id)
1535                        .map_or(std::cmp::Ordering::Less, |candidate| {
1536                            str::cmp(candidate, key)
1537                        })
1538                })
1539                .is_ok(),
1540        }
1541    }
1542}
1543
1544#[derive(Clone, Copy)]
1545enum ComponentStateSurface {
1546    Hydration,
1547    Navigation,
1548}
1549
1550/// Select initial state for the components reachable on this request path.
1551///
1552/// Non-WebUI protocols preserve full state without walking component surfaces.
1553/// WebUI protocols project exact surfaces, while any unknown surface restores
1554/// the full state for correctness.
1555pub(crate) fn collect_hydration_state<'a, 'b>(
1556    protocol: &'a WebUIProtocol,
1557    components: impl IntoIterator<Item = &'b str>,
1558) -> StateSelection<'a> {
1559    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1560        return StateSelection::Full;
1561    }
1562    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1563}
1564
1565/// Fill a reusable hydration key-ID allowlist for a streaming record.
1566///
1567/// Components arrive as inventory indexes, so the projection never resolves a
1568/// component name, never hashes it against the compiled component map, and
1569/// never sorts strings: the interned runs concatenate and the integer sort
1570/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1571/// requires sending full state instead.
1572pub(crate) fn collect_hydration_key_ids_into(
1573    protocol: &WebUIProtocol,
1574    index: &ComponentReachabilityIndex,
1575    components: impl IntoIterator<Item = u32>,
1576    ids: &mut Vec<u32>,
1577) -> bool {
1578    ids.clear();
1579    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1580        return true;
1581    }
1582    for component in components {
1583        if index.extend_hydration_keys(component, ids) {
1584            ids.clear();
1585            return true;
1586        }
1587    }
1588    ids.sort_unstable();
1589    ids.dedup();
1590    false
1591}
1592
1593/// Select state for client-created components reachable during navigation.
1594pub(crate) fn collect_navigation_state<'a, 'b>(
1595    protocol: &'a WebUIProtocol,
1596    components: impl IntoIterator<Item = &'b str>,
1597) -> StateSelection<'a> {
1598    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1599}
1600
1601fn collect_component_state<'a, 'b>(
1602    protocol: &'a WebUIProtocol,
1603    components: impl IntoIterator<Item = &'b str>,
1604    surface: ComponentStateSurface,
1605) -> StateSelection<'a> {
1606    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1607    if collect_component_state_into(protocol, components, surface, &mut keys) {
1608        StateSelection::Full
1609    } else {
1610        StateSelection::Keys(keys)
1611    }
1612}
1613
1614/// Fill a reusable state-key allowlist. Returns `true` when correctness
1615/// requires sending full state instead of the collected keys.
1616fn collect_component_state_into<'a, 'b>(
1617    protocol: &'a WebUIProtocol,
1618    components: impl IntoIterator<Item = &'b str>,
1619    surface: ComponentStateSurface,
1620    keys: &mut Vec<&'a str>,
1621) -> bool {
1622    keys.clear();
1623    for name in components {
1624        let Some(component) = protocol.components.get(name) else {
1625            return true;
1626        };
1627        let (mode, component_keys) = match surface {
1628            ComponentStateSurface::Hydration => {
1629                (component.hydration_mode, &component.hydration_keys)
1630            }
1631            ComponentStateSurface::Navigation => (
1632                match component.navigation_mode {
1633                    Some(mode) => mode,
1634                    None if !component.navigation_keys.is_empty() => {
1635                        StateProjectionMode::Keys as i32
1636                    }
1637                    None => return true,
1638                },
1639                &component.navigation_keys,
1640            ),
1641        };
1642        if mode == StateProjectionMode::All as i32 {
1643            return true;
1644        }
1645        if mode == StateProjectionMode::Keys as i32
1646            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1647        {
1648            keys.extend(component_keys.iter().map(String::as_str));
1649        } else if mode != StateProjectionMode::None as i32 {
1650            return true;
1651        }
1652    }
1653    keys.sort_unstable();
1654    keys.dedup();
1655    false
1656}
1657
1658pub(crate) fn write_webui_bootstrap(
1659    writer: &mut dyn ResponseWriter,
1660    scratch: &mut Vec<u8>,
1661    bootstrap: WebUiBootstrap<'_>,
1662) -> Result<()> {
1663    let mut wrote_field = false;
1664
1665    writer.write("{")?;
1666    if let Some(declaration_id) = bootstrap.declaration_id {
1667        write_json_field(
1668            writer,
1669            scratch,
1670            &mut wrote_field,
1671            "declarationId",
1672            &declaration_id,
1673        )?;
1674    }
1675    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1676        write_json_field(
1677            writer,
1678            scratch,
1679            &mut wrote_field,
1680            "enclosingSpanInstanceId",
1681            &span_id,
1682        )?;
1683    }
1684    if !bootstrap.chain.is_empty() {
1685        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1686    }
1687    // Definitions must be visible before template metadata can cause the
1688    // runtime to create a component root.
1689    write_json_field(
1690        writer,
1691        scratch,
1692        &mut wrote_field,
1693        "componentStyles",
1694        bootstrap.component_styles,
1695    )?;
1696    if !bootstrap.css_hrefs.is_empty() {
1697        write_json_field(
1698            writer,
1699            scratch,
1700            &mut wrote_field,
1701            "css",
1702            bootstrap.css_hrefs,
1703        )?;
1704    }
1705    write_json_field(
1706        writer,
1707        scratch,
1708        &mut wrote_field,
1709        "inventory",
1710        bootstrap.inventory,
1711    )?;
1712    if let Some(nonce) = bootstrap.nonce {
1713        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1714    }
1715    write_json_field_name(writer, &mut wrote_field, "state")?;
1716    write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
1717    if !bootstrap.style_specs.is_empty() {
1718        write_json_field(
1719            writer,
1720            scratch,
1721            &mut wrote_field,
1722            "styles",
1723            bootstrap.style_specs,
1724        )?;
1725    }
1726    if bootstrap
1727        .templates
1728        .iter()
1729        .any(|template| !template.template_json.is_empty())
1730    {
1731        write_json_field_name(writer, &mut wrote_field, "templates")?;
1732        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1733    }
1734    writer.write("}")
1735}
1736
1737fn write_webui_data_block(
1738    writer: &mut dyn ResponseWriter,
1739    scratch: &mut Vec<u8>,
1740    bootstrap: WebUiBootstrap<'_>,
1741) -> Result<()> {
1742    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1743    if let Some(nonce) = bootstrap.nonce {
1744        writer.write(" nonce=\"")?;
1745        writer.write(nonce)?;
1746        writer.write("\"")?;
1747    }
1748
1749    writer.write(">")?;
1750    write_webui_bootstrap(writer, scratch, bootstrap)?;
1751    writer.write("</script>\n")
1752}
1753
1754fn write_component_asset_style_manifest(
1755    writer: &mut dyn ResponseWriter,
1756    manifest: &str,
1757    nonce: Option<&str>,
1758) -> Result<()> {
1759    if manifest.is_empty() {
1760        return Ok(());
1761    }
1762    writer.write("<script type=\"application/json\" id=\"")?;
1763    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1764    writer.write("\"")?;
1765    if let Some(nonce) = nonce {
1766        writer.write(" nonce=\"")?;
1767        writer.write(&crate::html_encode::encode_safe(nonce))?;
1768        writer.write("\"")?;
1769    }
1770    writer.write(">")?;
1771    writer.write(manifest)?;
1772    writer.write("</script>")
1773}
1774
1775fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1776    if context.component_asset_styles_emitted {
1777        return Ok(());
1778    }
1779    context.component_asset_styles_emitted = true;
1780    context.writer.write(context.component_asset_style_links)?;
1781    write_component_asset_style_manifest(
1782        context.writer,
1783        context.component_asset_style_manifest,
1784        context.nonce,
1785    )
1786}
1787
1788fn write_webui_template_json_map(
1789    writer: &mut dyn ResponseWriter,
1790    scratch: &mut Vec<u8>,
1791    templates: &[WebUiTemplatePayload<'_>],
1792) -> Result<()> {
1793    writer.write("{")?;
1794    let mut wrote = false;
1795    for template in templates {
1796        if template.template_json.is_empty() {
1797            continue;
1798        }
1799        if wrote {
1800            writer.write(",")?;
1801        }
1802        wrote = true;
1803        write_script_safe_json(writer, scratch, template.tag_name)?;
1804        writer.write(":")?;
1805        write_script_safe_json_str(writer, template.template_json)?;
1806    }
1807    writer.write("}")
1808}
1809
1810fn resolve_value_from_sources<'ctx, 'state>(
1811    path: &str,
1812    loop_vars: &'ctx [LoopBinding<'_, 'state>],
1813    visible_loop_scope: VisibleLoopScope,
1814    local_values: LocalValueSources<'ctx, '_, 'state>,
1815    state: &'state Value,
1816) -> Option<Cow<'ctx, Value>>
1817where
1818    'state: 'ctx,
1819{
1820    if let Some(binding) = loop_vars
1821        .get(visible_loop_scope.start..visible_loop_scope.end)
1822        .and_then(|bindings| bindings.last())
1823    {
1824        let name = binding.name;
1825        if path.len() == name.len() && path == name {
1826            return Some(Cow::Borrowed(binding.value));
1827        }
1828        if path.len() > name.len()
1829            && path.as_bytes().get(name.len()) == Some(&b'.')
1830            && path.starts_with(name)
1831        {
1832            if let Some(value) =
1833                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1834            {
1835                return Some(value);
1836            }
1837            return find_value_by_dotted_path_ref(path, state);
1838        }
1839    }
1840
1841    if let Some(first_part) = path.split('.').next() {
1842        if let Some(local_value) = local_values.borrowed.get(first_part) {
1843            if first_part.len() == path.len() {
1844                return Some(Cow::Borrowed(local_value));
1845            }
1846            let remaining = &path[first_part.len() + 1..];
1847            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1848                return Some(value);
1849            }
1850            return find_value_by_dotted_path_ref(path, state);
1851        }
1852        if let Some(local_value) = local_values.owned.get(first_part) {
1853            if first_part.len() == path.len() {
1854                return Some(Cow::Borrowed(local_value));
1855            }
1856            let remaining = &path[first_part.len() + 1..];
1857            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1858                return Some(value);
1859            }
1860            return find_value_by_dotted_path_ref(path, state);
1861        }
1862
1863        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1864            .iter()
1865            .rev()
1866        {
1867            if binding.name != first_part {
1868                continue;
1869            }
1870            if first_part.len() == path.len() {
1871                return Some(Cow::Borrowed(binding.value));
1872            }
1873            let remaining = &path[first_part.len() + 1..];
1874            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1875                return Some(value);
1876            }
1877            return find_value_by_dotted_path_ref(path, state);
1878        }
1879    }
1880
1881    find_value_by_dotted_path_ref(path, state)
1882}
1883
1884fn resolve_borrowed_collection<'state>(
1885    path: &str,
1886    loop_vars: &[LoopBinding<'_, 'state>],
1887    visible_loop_scope: VisibleLoopScope,
1888    local_values: LocalValueSources<'_, '_, 'state>,
1889    state: &'state Value,
1890) -> Option<&'state [Value]> {
1891    let first_part = path.split('.').next()?;
1892    if let Some(binding) = loop_vars
1893        .get(visible_loop_scope.start..visible_loop_scope.end)
1894        .and_then(|bindings| bindings.last())
1895        .filter(|binding| binding.name == first_part)
1896    {
1897        if first_part.len() == path.len() {
1898            return binding.value.as_array().map(Vec::as_slice);
1899        }
1900        let remaining = &path[first_part.len() + 1..];
1901        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1902            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1903            Some(Cow::Owned(_)) => None,
1904            None => borrowed_state_array(path, state),
1905        };
1906    }
1907    if let Some(local_value) = local_values.borrowed.get(first_part) {
1908        let local_match = if first_part.len() == path.len() {
1909            Some(Cow::Borrowed(local_value))
1910        } else {
1911            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1912        };
1913        return match local_match {
1914            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1915            Some(Cow::Owned(_)) => None,
1916            None => borrowed_state_array(path, state),
1917        };
1918    }
1919    if let Some(local_value) = local_values.owned.get(first_part) {
1920        let local_match = if first_part.len() == path.len() {
1921            Some(Cow::Borrowed(local_value))
1922        } else {
1923            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1924        };
1925        if local_match.is_some() {
1926            return None;
1927        }
1928        return borrowed_state_array(path, state);
1929    }
1930
1931    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1932        .iter()
1933        .rev()
1934    {
1935        if binding.name != first_part {
1936            continue;
1937        }
1938        if first_part.len() == path.len() {
1939            return binding.value.as_array().map(Vec::as_slice);
1940        }
1941        let remaining = &path[first_part.len() + 1..];
1942        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1943            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1944            Some(Cow::Owned(_)) => None,
1945            None => borrowed_state_array(path, state),
1946        };
1947    }
1948
1949    borrowed_state_array(path, state)
1950}
1951
1952fn resolve_state_backed_value<'state>(
1953    path: &str,
1954    loop_vars: &[LoopBinding<'_, 'state>],
1955    visible_loop_scope: VisibleLoopScope,
1956    local_values: LocalValueSources<'_, '_, 'state>,
1957    state: &'state Value,
1958) -> Option<&'state Value> {
1959    let first_part = path.split('.').next()?;
1960    if let Some(binding) = loop_vars
1961        .get(visible_loop_scope.start..visible_loop_scope.end)
1962        .and_then(|bindings| bindings.last())
1963        .filter(|binding| binding.name == first_part)
1964    {
1965        if first_part.len() == path.len() {
1966            return Some(binding.value);
1967        }
1968        let remaining = &path[first_part.len() + 1..];
1969        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1970            Some(Cow::Borrowed(value)) => Some(value),
1971            Some(Cow::Owned(_)) => None,
1972            None => borrowed_state_value(path, state),
1973        };
1974    }
1975    if let Some(local_value) = local_values.borrowed.get(first_part) {
1976        if first_part.len() == path.len() {
1977            return Some(local_value);
1978        }
1979        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1980            Some(Cow::Borrowed(value)) => Some(value),
1981            Some(Cow::Owned(_)) => None,
1982            None => borrowed_state_value(path, state),
1983        };
1984    }
1985    if let Some(local_value) = local_values.owned.get(first_part) {
1986        if first_part.len() == path.len() {
1987            return None;
1988        }
1989        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1990            Some(_) => None,
1991            None => borrowed_state_value(path, state),
1992        };
1993    }
1994
1995    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1996        .iter()
1997        .rev()
1998    {
1999        if binding.name != first_part {
2000            continue;
2001        }
2002        if first_part.len() == path.len() {
2003            return Some(binding.value);
2004        }
2005        let remaining = &path[first_part.len() + 1..];
2006        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2007            Some(Cow::Borrowed(value)) => Some(value),
2008            Some(Cow::Owned(_)) => None,
2009            None => borrowed_state_value(path, state),
2010        };
2011    }
2012
2013    borrowed_state_value(path, state)
2014}
2015
2016fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2017    match find_value_by_dotted_path_ref(path, state) {
2018        Some(Cow::Borrowed(value)) => Some(value),
2019        Some(Cow::Owned(_)) | None => None,
2020    }
2021}
2022
2023fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2024    match find_value_by_dotted_path_ref(path, state)? {
2025        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2026        Cow::Owned(_) => None,
2027    }
2028}
2029
2030impl WebUIHandler {
2031    /// Create a new WebUI handler with no plugin.
2032    pub fn new() -> Self {
2033        Self {
2034            plugin_factory: None,
2035        }
2036    }
2037
2038    /// Create a new WebUI handler with a plugin factory.
2039    ///
2040    /// Each render call creates a fresh plugin instance from the factory,
2041    /// enabling concurrent renders with `&self`.
2042    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2043        Self {
2044            plugin_factory: Some(factory),
2045        }
2046    }
2047
2048    #[cfg(test)]
2049    fn handle(
2050        &self,
2051        document: &WebUIProtocol,
2052        state: &Value,
2053        options: &RenderOptions<'_>,
2054        writer: &mut dyn ResponseWriter,
2055    ) -> Result<()> {
2056        let protocol = Protocol::new(document.clone());
2057        self.render(&protocol, state, options, writer)
2058    }
2059
2060    /// Process a fragment by its prepared render slot, falling back to an ID
2061    /// lookup when the caller has no prepared slot (handler-generated hosts).
2062    ///
2063    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2064    /// such as loop iteration variables. This is separate from the global `state`.
2065    fn process_fragment_target<'data>(
2066        &self,
2067        target: Option<usize>,
2068        fragment_id: &str,
2069        context: &mut WebUIProcessContext<'data, '_, '_>,
2070    ) -> Result<()> {
2071        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2072            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2073        };
2074        let Some(fragment_list) = context.render_fragments.list(index) else {
2075            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2076        };
2077        self.process_fragment(fragment_list, context)
2078    }
2079
2080    /// Process a vector of fragments.
2081    ///
2082    /// The `context` maintains scope-specific variables that can be accessed by fragments
2083    /// during rendering, while `state` contains the global application state.
2084    fn process_fragment<'data>(
2085        &self,
2086        fragment_list: RenderFragmentList<'data>,
2087        context: &mut WebUIProcessContext<'data, '_, '_>,
2088    ) -> Result<()> {
2089        self.process_fragment_from(fragment_list, 0, context)
2090    }
2091
2092    fn process_fragment_from<'data>(
2093        &self,
2094        fragment_list: RenderFragmentList<'data>,
2095        start: usize,
2096        context: &mut WebUIProcessContext<'data, '_, '_>,
2097    ) -> Result<()> {
2098        let fragments = fragment_list.fragments;
2099        // Pre-scan: find the best matching route among sibling routes by specificity.
2100        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2101        // Resolves relative paths (`./`) using the current route_base.
2102        // Lists prepared without any route fragment cannot produce a match, so
2103        // the scan is skipped entirely for them.
2104        let best_route = if fragment_list.has_routes {
2105            route_renderer::find_best_route_match(
2106                fragments,
2107                context.request_path,
2108                &context.route_base,
2109                context.route_index,
2110            )
2111        } else {
2112            None
2113        };
2114        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2115    }
2116
2117    fn process_fragment_range<'data>(
2118        &self,
2119        fragment_list: RenderFragmentList<'data>,
2120        range: std::ops::Range<usize>,
2121        best_route: &Option<(String, route_matcher::RouteMatch)>,
2122        context: &mut WebUIProcessContext<'data, '_, '_>,
2123    ) -> Result<()> {
2124        let fragments = fragment_list.fragments;
2125        let Some(selected) = fragments.get(range.clone()) else {
2126            return Err(invalid_fragment_range_error(&range, fragments.len()));
2127        };
2128        for (offset, item) in selected.iter().enumerate() {
2129            let index = range.start + offset;
2130            if context.streaming.is_some() {
2131                validate_pending_streaming_root(item, context)?;
2132                validate_streaming_root_opening(&fragments[..index], item)?;
2133            }
2134            // The prepared target is only read by fragments that descend, so it
2135            // is resolved per-arm. Hoisting it here would charge every raw text
2136            // fragment for a lookup it never uses.
2137            match item.fragment.as_ref() {
2138                Some(Fragment::Raw(raw)) => {
2139                    context.writer.write(&raw.value)?;
2140                }
2141                Some(Fragment::Component(component)) => {
2142                    self.process_component(
2143                        component,
2144                        fragment_list.target(index),
2145                        ComponentHostOrigin::ParserProduced,
2146                        context,
2147                    )?;
2148                }
2149                Some(Fragment::ForLoop(for_loop)) => {
2150                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2151                }
2152                Some(Fragment::Signal(signal)) => {
2153                    self.process_signal(signal, context)?;
2154                }
2155                Some(Fragment::IfCond(if_cond)) => {
2156                    self.process_if(if_cond, fragment_list.target(index), context)?;
2157                }
2158                Some(Fragment::Attribute(attr)) => {
2159                    self.process_attribute(
2160                        attr,
2161                        fragment_list.target(index),
2162                        fragment_list.component_attr_name(index),
2163                        context,
2164                    )?;
2165                }
2166                Some(Fragment::Plugin(plugin_frag)) => {
2167                    if let Some(p) = &mut context.plugin {
2168                        p.on_element_data(&plugin_frag.data, context.writer)?;
2169                    }
2170                }
2171                Some(Fragment::Route(route_frag)) => {
2172                    self.process_route(route_frag, best_route, context)?;
2173                }
2174                Some(Fragment::Outlet(_)) => {
2175                    self.process_outlet(context)?;
2176                }
2177                Some(Fragment::Boundary(_)) => {}
2178                None => {}
2179            }
2180        }
2181        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2182    }
2183
2184    /// Process an `<outlet />` directive.
2185    ///
2186    /// Matches children from the currently active route's `children` field
2187    /// against the request path, renders the matched child `<webui-route>`
2188    /// elements directly at this position (no wrapper element).
2189    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2190        let mut children = std::mem::take(&mut context.route_children);
2191        if children.is_empty() {
2192            return Ok(());
2193        }
2194
2195        // Find the best matching child route
2196        let request_segments = route_matcher::split_request_path(context.request_path);
2197        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2198        for (idx, child) in children.iter().enumerate() {
2199            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2200                context.route_index,
2201                &child.path,
2202                &context.route_base,
2203                &request_segments,
2204                child.exact,
2205            ) {
2206                let is_better = best
2207                    .as_ref()
2208                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2209                if is_better {
2210                    best = Some((idx, m));
2211                }
2212            }
2213        }
2214
2215        // Extract grandchildren from the matched child to avoid cloning.
2216        // We swap out the children vec so we can move it into context without
2217        // cloning, then swap an empty vec back for the sibling rendering pass.
2218        let grandchildren = if let Some((idx, _)) = &best {
2219            std::mem::take(&mut children[*idx].children)
2220        } else {
2221            Vec::new()
2222        };
2223
2224        if let Some((idx, ref rm)) = best {
2225            let matched_child = &children[idx];
2226            let comp = &matched_child.fragment_id;
2227
2228            if !comp.is_empty() {
2229                let saved_route_base = context.route_base.clone();
2230                let saved_route_children = std::mem::take(&mut context.route_children);
2231
2232                if rm.consumed_segments > 0 {
2233                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
2234                        context.request_path,
2235                        rm.consumed_segments,
2236                    ));
2237                }
2238
2239                context.route_children = grandchildren;
2240
2241                // Emit matched <webui-route>
2242                context.writer.write("<webui-route")?;
2243                context.writer.write(" path=\"")?;
2244                context.writer.write(&matched_child.path)?;
2245                context.writer.write("\"")?;
2246                context.writer.write(" component=\"")?;
2247                context.writer.write(comp)?;
2248                context.writer.write("\"")?;
2249                if matched_child.exact {
2250                    context.writer.write(" exact")?;
2251                }
2252                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2253                // Emit data-ri for O(1) client-side element binding
2254                let ri = context.route_chain_index;
2255                context.route_chain_index += 1;
2256                context.writer.write(" data-ri=\"")?;
2257                write_usize(context.writer, ri)?;
2258                context.writer.write("\" active>")?;
2259
2260                if !Self::component_owns_css_tree(comp, context.protocol) {
2261                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2262                }
2263                if !matched_child.content_fragment_id.is_empty() {
2264                    self.process_fragment_target(
2265                        None,
2266                        &matched_child.content_fragment_id,
2267                        context,
2268                    )?;
2269                }
2270
2271                context.writer.write("<")?;
2272                context.writer.write(comp)?;
2273                if let Some(p) = &context.plugin {
2274                    p.write_route_component_state(context.state, context.writer)?;
2275                }
2276                write_interaction_marker(comp, context)?;
2277                prepare_generated_streaming_root(comp, context)?;
2278                context.writer.write(">")?;
2279
2280                self.process_component(
2281                    &webui_protocol::WebUIFragmentComponent {
2282                        fragment_id: comp.clone(),
2283                    },
2284                    None,
2285                    ComponentHostOrigin::HandlerGenerated,
2286                    context,
2287                )?;
2288
2289                context.writer.write("</")?;
2290                context.writer.write(comp)?;
2291                context.writer.write(">")?;
2292                context.writer.write("</webui-route>")?;
2293
2294                context.route_base = saved_route_base;
2295                context.route_children = saved_route_children;
2296            }
2297        }
2298
2299        // Render non-matched siblings as hidden
2300        for (idx, child) in children.iter().enumerate() {
2301            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2302            if !is_matched && !child.fragment_id.is_empty() {
2303                context.writer.write("<webui-route")?;
2304                context.writer.write(" path=\"")?;
2305                context.writer.write(&child.path)?;
2306                context.writer.write("\"")?;
2307                context.writer.write(" component=\"")?;
2308                context.writer.write(&child.fragment_id)?;
2309                context.writer.write("\"")?;
2310                if child.exact {
2311                    context.writer.write(" exact")?;
2312                }
2313                route_renderer::write_route_navigation_attrs(context.writer, child)?;
2314                context
2315                    .writer
2316                    .write(" style=\"display:none\"></webui-route>")?;
2317            }
2318        }
2319
2320        Ok(())
2321    }
2322
2323    /// Emit a `<script type="importmap">` tag that registers a component's
2324    /// CSS module under its specifier via a `data:text/css,…` URI.
2325    ///
2326    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2327    /// independent importmap that the browser merges at the document
2328    /// level. The per-render CSP nonce is applied when set (importmap
2329    /// scripts honor `script-src`).
2330    ///
2331    /// Example for `my-comp` with CSS `span{color:blue;}`:
2332    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2333    fn emit_css_module_importmap(
2334        &self,
2335        specifier: &str,
2336        css: &str,
2337        context: &mut WebUIProcessContext,
2338    ) -> Result<()> {
2339        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2340        context.writer.write(&tag)?;
2341        Ok(())
2342    }
2343
2344    /// Install one compiler-ordered closure into the active CSS tree.
2345    fn emit_component_style_closure(
2346        &self,
2347        root: &str,
2348        install: StyleClosureInstall,
2349        context: &mut WebUIProcessContext,
2350    ) -> Result<()> {
2351        if context.protocol.style_closures.is_empty() {
2352            return Ok(());
2353        }
2354        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2355            match context.shadow_style_roots.last() {
2356                Some(shadow_root) => {
2357                    if !shadow_root.static_closure_emitted {
2358                        return Err(HandlerError::Invariant(
2359                            "routed styles reached a ShadowRoot before its static style hook"
2360                                .to_string(),
2361                        ));
2362                    }
2363                    let root_name = context
2364                        .component_index
2365                        .iter()
2366                        .find_map(|(name, index)| {
2367                            (*index == shadow_root.component_index).then_some(name.as_str())
2368                        })
2369                        .ok_or_else(|| {
2370                            HandlerError::Invariant(
2371                                "active Shadow style root lost its protocol index".to_string(),
2372                            )
2373                        })?;
2374                    Some(
2375                        context
2376                            .protocol
2377                            .style_closures
2378                            .get(root_name)
2379                            .ok_or_else(|| {
2380                                HandlerError::Invariant(format!(
2381                            "component style closure metadata is missing Shadow root `{root_name}`"
2382                        ))
2383                            })?,
2384                    )
2385                }
2386                None => None,
2387            }
2388        } else {
2389            None
2390        };
2391        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2392            HandlerError::Invariant(format!(
2393                "component style closure metadata is missing root `{root}`"
2394            ))
2395        })?;
2396        let is_document_tree = context.shadow_style_roots.is_empty();
2397        let strategy = context.css_strategy;
2398
2399        // A bundled build delivers merged chunks; otherwise every component
2400        // delivers its own stylesheet. Both walk the closure in the same order,
2401        // so the emitted cascade is identical either way. Bundling is a
2402        // build-wide decision, so the two never mix within one protocol.
2403        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2404        let mut emitted_resources = HashSet::with_capacity(unit_count);
2405
2406        for position in 0..unit_count {
2407            let unit = context
2408                .protocol
2409                .style_closure_unit(closure, &context.style_chunk_index, position)
2410                .ok_or_else(|| {
2411                    HandlerError::Invariant(format!(
2412                        "component style closure `{root}` references out-of-range unit {position}"
2413                    ))
2414                })?;
2415            let (name, chunk) = (unit.name, unit.chunk);
2416            if !emitted_resources.insert(name) {
2417                continue;
2418            }
2419            let resource = unit.resource.ok_or_else(|| match chunk {
2420                Some(index) => HandlerError::Invariant(format!(
2421                    "component style closure `{root}` references missing style chunk {index}"
2422                )),
2423                None => HandlerError::Invariant(format!(
2424                    "component style closure `{root}` references missing resource `{name}`"
2425                )),
2426            })?;
2427            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2428                continue;
2429            }
2430            if let Some(static_closure) = shadow_static_closure {
2431                let resource_index = match chunk {
2432                    Some(index) => {
2433                        if static_closure.style_chunks.contains(&index) {
2434                            continue;
2435                        }
2436                        index
2437                    }
2438                    None => {
2439                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2440                            continue;
2441                        }
2442                        context.component_index.get(name).copied().ok_or_else(|| {
2443                            HandlerError::Invariant(format!(
2444                                "component style resource `{name}` is missing its protocol index"
2445                            ))
2446                        })?
2447                    }
2448                };
2449                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2450                    HandlerError::Invariant(
2451                        "active Shadow style root disappeared during routed style delivery"
2452                            .to_string(),
2453                    )
2454                })?;
2455                if shadow_root.routed_resources.contains(&resource_index) {
2456                    continue;
2457                }
2458                shadow_root.routed_resources.push(resource_index);
2459            }
2460
2461            match strategy {
2462                webui_protocol::CssStrategy::Link => {
2463                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2464                    context
2465                        .writer
2466                        .write(&crate::html_encode::encode_safe(resource))?;
2467                    context.writer.write("\" data-webui-resource=\"")?;
2468                    context
2469                        .writer
2470                        .write(&crate::html_encode::encode_safe(name))?;
2471                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2472                }
2473                strategy => {
2474                    context.writer.write("<style")?;
2475                    if let Some(nonce) = context.nonce {
2476                        context.writer.write(" nonce=\"")?;
2477                        context
2478                            .writer
2479                            .write(&crate::html_encode::encode_safe(nonce))?;
2480                        context.writer.write("\"")?;
2481                    }
2482                    context.writer.write(" data-webui-resource=\"")?;
2483                    context
2484                        .writer
2485                        .write(&crate::html_encode::encode_safe(name))?;
2486                    context.writer.write("\" data-webui-strategy=\"")?;
2487                    context
2488                        .writer
2489                        .write(if strategy == webui_protocol::CssStrategy::Module {
2490                            "module"
2491                        } else {
2492                            "style"
2493                        })?;
2494                    context.writer.write("\">")?;
2495                    if context.style_resources_requiring_escape.contains(name) {
2496                        crate::html_encode::write_style_text(context.writer, resource)?;
2497                    } else {
2498                        context.writer.write(resource)?;
2499                    }
2500                    context.writer.write("</style>")?;
2501                }
2502            }
2503        }
2504        Ok(())
2505    }
2506
2507    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2508        if context.route_chain.is_none() {
2509            let plan = crate::route_handler::collect_route_chain_plan(
2510                context.protocol,
2511                context.entry_id,
2512                context.request_path,
2513                context.route_index,
2514            );
2515            context.route_document_style_targets = plan.document_style_targets;
2516            context.route_chain = Some(plan.entries);
2517        }
2518    }
2519
2520    fn emit_active_route_styles(
2521        &self,
2522        preloaded: &mut Vec<u32>,
2523        context: &mut WebUIProcessContext,
2524    ) -> Result<()> {
2525        Self::ensure_request_route_chain(context);
2526        let Some(chain) = context.route_chain.take() else {
2527            return Err(route_style_plan_missing_error());
2528        };
2529        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2530        if chain.len() != document_targets.len() {
2531            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2532            context.route_document_style_targets = document_targets;
2533            context.route_chain = Some(chain);
2534            return Err(error);
2535        }
2536
2537        let result = (|| {
2538            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2539                if *targets_document {
2540                    self.emit_component_style_closure(
2541                        &entry.component,
2542                        StyleClosureInstall::Static,
2543                        context,
2544                    )?;
2545                    continue;
2546                }
2547
2548                if context.css_strategy != webui_protocol::CssStrategy::Link {
2549                    continue;
2550                }
2551                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2552            }
2553            Ok(())
2554        })();
2555
2556        context.route_document_style_targets = document_targets;
2557        context.route_chain = Some(chain);
2558        result
2559    }
2560
2561    fn emit_component_style_preloads(
2562        &self,
2563        root: &str,
2564        preloaded: &mut Vec<u32>,
2565        context: &mut WebUIProcessContext,
2566    ) -> Result<()> {
2567        let Some(closure) = context.protocol.style_closures.get(root) else {
2568            return Ok(());
2569        };
2570        // A chunk shared by several tree-local roots needs one preload.
2571        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2572        for position in 0..unit_count {
2573            let Some(unit) =
2574                context
2575                    .protocol
2576                    .style_closure_unit(closure, &context.style_chunk_index, position)
2577            else {
2578                continue;
2579            };
2580            let Some(href) = unit.resource else {
2581                continue;
2582            };
2583            let name = unit.name;
2584            if context.document_style_resources.contains(name) {
2585                continue;
2586            }
2587            let Some(&resource_index) = context.style_resource_index.get(name) else {
2588                continue;
2589            };
2590            if preloaded.contains(&resource_index) {
2591                continue;
2592            }
2593            context
2594                .writer
2595                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2596            context
2597                .writer
2598                .write(&crate::html_encode::encode_safe(href))?;
2599            context.writer.write("\">")?;
2600            preloaded.push(resource_index);
2601        }
2602        Ok(())
2603    }
2604
2605    fn emit_reachable_shadow_preloads(
2606        &self,
2607        preloaded: &mut Vec<u32>,
2608        context: &mut WebUIProcessContext,
2609    ) -> Result<()> {
2610        if context.css_strategy != webui_protocol::CssStrategy::Link {
2611            return Ok(());
2612        }
2613        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2614            crate::route_handler::collect_reachable_component_order_for_request(
2615                context.protocol,
2616                context.entry_id,
2617                context.request_path,
2618                context.route_index,
2619            )
2620        });
2621        let result = (|| {
2622            for component in &reachable {
2623                if context.protocol.component_uses_shadow_dom(component) {
2624                    self.emit_component_style_preloads(component, preloaded, context)?;
2625                }
2626            }
2627            Ok(())
2628        })();
2629        context.reachable_components = Some(reachable);
2630        result
2631    }
2632
2633    fn process_shadow_style_signal(
2634        &self,
2635        root: &str,
2636        context: &mut WebUIProcessContext,
2637    ) -> Result<()> {
2638        let Some(root_index) = context.component_index.get(root).copied() else {
2639            return Err(HandlerError::Invariant(format!(
2640                "Shadow style hook `{root}` references an unknown component"
2641            )));
2642        };
2643        if context
2644            .shadow_style_roots
2645            .last()
2646            .map(|shadow_root| shadow_root.component_index)
2647            != Some(root_index)
2648        {
2649            return Err(HandlerError::Invariant(format!(
2650                "Shadow style hook `{root}` does not match the active component root"
2651            )));
2652        }
2653        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2654        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2655            HandlerError::Invariant(
2656                "active Shadow style root disappeared after its static style hook".to_string(),
2657            )
2658        })?;
2659        shadow_root.static_closure_emitted = true;
2660        Ok(())
2661    }
2662
2663    /// Emit a component's CSS module importmap on its first render
2664    /// (deduped by `rendered_components`) into the component's light DOM,
2665    /// so the browser registers it under the component's specifier
2666    /// before the shadow root template is parsed. See
2667    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2668    ///
2669    /// Only components rendered on the current route get inline definitions;
2670    /// navigation responses carry later definitions in `componentStyles`.
2671    fn emit_css_module(
2672        &self,
2673        component: &webui_protocol::WebUIFragmentComponent,
2674        context: &mut WebUIProcessContext,
2675    ) -> Result<()> {
2676        if context.css_strategy != webui_protocol::CssStrategy::Module {
2677            return Ok(());
2678        }
2679        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2680            streaming_template_already_sent(
2681                streaming,
2682                context.component_index,
2683                &component.fragment_id,
2684            )
2685        });
2686        if !metadata_already_streamed
2687            && !context.rendered_components.contains(&component.fragment_id)
2688        {
2689            if let Some(css) = context
2690                .protocol
2691                .components
2692                .get(&component.fragment_id)
2693                .map(|c| c.css.as_str())
2694                .filter(|s| !s.is_empty())
2695            {
2696                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2697            }
2698        }
2699        Ok(())
2700    }
2701
2702    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2703    fn process_route(
2704        &self,
2705        route_frag: &webui_protocol::WebUiFragmentRoute,
2706        best_route: &Option<(String, route_matcher::RouteMatch)>,
2707        context: &mut WebUIProcessContext,
2708    ) -> Result<()> {
2709        let is_matched = best_route
2710            .as_ref()
2711            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2712
2713        context.writer.write("<webui-route")?;
2714        context.writer.write(" path=\"")?;
2715        context.writer.write(&route_frag.path)?;
2716        context.writer.write("\"")?;
2717        if !route_frag.fragment_id.is_empty() {
2718            context.writer.write(" component=\"")?;
2719            context.writer.write(&route_frag.fragment_id)?;
2720            context.writer.write("\"")?;
2721        }
2722        if route_frag.exact {
2723            context.writer.write(" exact")?;
2724        }
2725        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2726
2727        if is_matched {
2728            // Emit data-ri for O(1) client-side element binding
2729            let ri = context.route_chain_index;
2730            context.route_chain_index += 1;
2731            context.writer.write(" data-ri=\"")?;
2732            write_usize(context.writer, ri)?;
2733            context.writer.write("\" active>")?;
2734
2735            let saved_route_base = context.route_base.clone();
2736            let saved_route_children = std::mem::take(&mut context.route_children);
2737            if let Some((_, ref rm)) = best_route {
2738                context.route_base = Cow::Owned(route_matcher::compute_route_base(
2739                    context.request_path,
2740                    rm.consumed_segments,
2741                ));
2742            }
2743            context.route_children = route_frag.children.clone();
2744
2745            if !route_frag.content_fragment_id.is_empty() {
2746                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2747            }
2748
2749            if !route_frag.fragment_id.is_empty() {
2750                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2751                    self.emit_component_style_closure(
2752                        &route_frag.fragment_id,
2753                        StyleClosureInstall::Routed,
2754                        context,
2755                    )?;
2756                }
2757                let comp = webui_protocol::WebUIFragmentComponent {
2758                    fragment_id: route_frag.fragment_id.clone(),
2759                };
2760
2761                context.writer.write("<")?;
2762                context.writer.write(&route_frag.fragment_id)?;
2763                if let Some(p) = &context.plugin {
2764                    p.write_route_component_state(context.state, context.writer)?;
2765                }
2766                write_interaction_marker(&route_frag.fragment_id, context)?;
2767                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2768                context.writer.write(">")?;
2769
2770                self.process_component(
2771                    &comp,
2772                    None,
2773                    ComponentHostOrigin::HandlerGenerated,
2774                    context,
2775                )?;
2776
2777                context.writer.write("</")?;
2778                context.writer.write(&route_frag.fragment_id)?;
2779                context.writer.write(">")?;
2780            }
2781            context.route_base = saved_route_base;
2782            context.route_children = saved_route_children;
2783        } else {
2784            context.writer.write(" style=\"display:none\">")?;
2785        }
2786
2787        context.writer.write("</webui-route>")?;
2788        Ok(())
2789    }
2790
2791    /// Process a component fragment.
2792    fn process_component(
2793        &self,
2794        component: &webui_protocol::WebUIFragmentComponent,
2795        target: Option<usize>,
2796        origin: ComponentHostOrigin,
2797        context: &mut WebUIProcessContext,
2798    ) -> Result<()> {
2799        if context.streaming.is_some() {
2800            consume_streaming_component_root(&component.fragment_id, origin, context)?;
2801            // Capture only after root parity succeeds, so malformed protocols
2802            // cannot contribute unmarked hosts to a checkpoint.
2803            record_checkpoint_tag(context, &component.fragment_id);
2804        }
2805
2806        // Emit the component's CSS module importmap into its light DOM and track
2807        // the component as rendered on first encounter only. `rendered_components`
2808        // is a set, so gating the `insert` (and its `String` clone) behind the
2809        // first-encounter check avoids allocating a throwaway `String` for every
2810        // duplicate instance while keeping the set contents identical.
2811        if !context.rendered_components.contains(&component.fragment_id) {
2812            self.emit_css_module(component, context)?;
2813            context
2814                .rendered_components
2815                .insert(component.fragment_id.clone());
2816        }
2817
2818        let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2819        if owns_css_tree {
2820            Self::push_shadow_style_root(&component.fragment_id, context)?;
2821        }
2822
2823        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
2824        let saved_local_vars = std::mem::take(&mut context.local_vars);
2825        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2826        // The component's accumulated attrs become its local vars; the next
2827        // sibling accumulates into a recycled (capacity-preserving) map from the
2828        // request-local pool instead of a freshly allocated `HashMap`.
2829        let saved_component_attrs = std::mem::replace(
2830            &mut context.component_attrs,
2831            take_scope_map(&mut context.scope_pool),
2832        );
2833        let saved_component_borrowed_attrs = std::mem::replace(
2834            &mut context.component_borrowed_attrs,
2835            take_borrowed_scope(&mut context.borrowed_scope_pool),
2836        );
2837        context.local_vars = saved_component_attrs;
2838        context.local_borrowed_vars = saved_component_borrowed_attrs;
2839        context.collecting_component_attrs = false;
2840        let saved_loop_scope = context.visible_loop_scope;
2841        context.visible_loop_scope = VisibleLoopScope {
2842            start: context.loop_vars.len(),
2843            end: context.loop_vars.len(),
2844        };
2845
2846        if let Some(p) = &mut context.plugin {
2847            p.push_scope();
2848        }
2849
2850        let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2851        context.visible_loop_scope = saved_loop_scope;
2852
2853        if owns_css_tree {
2854            Self::pop_shadow_style_root(&component.fragment_id, context)?;
2855        }
2856        render_result?;
2857
2858        if let Some(p) = &mut context.plugin {
2859            p.pop_scope();
2860        }
2861
2862        // Restore parent scope, recycling this component's local map (its
2863        // accumulated attrs) back into the pool so a sibling reuses its capacity.
2864        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2865        recycle_scope_map(&mut context.scope_pool, used_locals);
2866        let used_borrowed_locals =
2867            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2868        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2869        // The attr accumulator (pulled from the pool above) is cleared for the
2870        // next sibling while retaining its bucket capacity.
2871        context.component_attrs.clear();
2872        context.component_borrowed_attrs.clear();
2873
2874        Ok(())
2875    }
2876
2877    #[inline]
2878    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2879        !protocol.style_closures.is_empty()
2880            && protocol
2881                .components
2882                .get(component)
2883                .is_some_and(|data| data.uses_shadow_dom)
2884    }
2885
2886    pub(crate) fn push_shadow_style_root(
2887        component: &str,
2888        context: &mut WebUIProcessContext,
2889    ) -> Result<()> {
2890        if !context.protocol.style_closures.contains_key(component) {
2891            return Err(HandlerError::Invariant(format!(
2892                "component style closure metadata is missing Shadow root `{component}`"
2893            )));
2894        }
2895        let root_index = context
2896            .component_index
2897            .get(component)
2898            .copied()
2899            .ok_or_else(|| {
2900                HandlerError::Invariant(format!(
2901                    "Shadow component `{component}` is missing its protocol index"
2902                ))
2903            })?;
2904        context.shadow_style_roots.push(ShadowStyleRoot {
2905            component_index: root_index,
2906            static_closure_emitted: false,
2907            routed_resources: Vec::new(),
2908        });
2909        Ok(())
2910    }
2911
2912    pub(crate) fn pop_shadow_style_root(
2913        component: &str,
2914        context: &mut WebUIProcessContext,
2915    ) -> Result<()> {
2916        context.shadow_style_roots.pop().ok_or_else(|| {
2917            HandlerError::Invariant(format!(
2918                "Shadow component `{component}` lost its active style root"
2919            ))
2920        })?;
2921        Ok(())
2922    }
2923
2924    /// Resolve a dotted path into owned state for values retained by the context.
2925    fn resolve_value_owned(
2926        &self,
2927        path: &str,
2928        context: &WebUIProcessContext<'_, '_, '_>,
2929    ) -> Option<Value> {
2930        resolve_value_from_sources(
2931            path,
2932            &context.loop_vars,
2933            context.visible_loop_scope,
2934            LocalValueSources {
2935                owned: &context.local_vars,
2936                borrowed: &context.local_borrowed_vars,
2937            },
2938            context.state,
2939        )
2940        .map(Cow::into_owned)
2941    }
2942
2943    /// Evaluate a condition expression against the current context.
2944    ///
2945    /// Uses a resolver closure that checks local variables first, then falls
2946    /// back to global state — avoiding a full clone of the state tree.
2947    /// Missing identifier operands are falsy before logical operators are applied.
2948    /// Missing predicate values make the complete condition false.
2949    fn evaluate_condition(
2950        &self,
2951        condition: &webui_protocol::ConditionExpr,
2952        context: &WebUIProcessContext,
2953    ) -> Result<bool> {
2954        let loop_vars = &context.loop_vars;
2955        let visible_loop_scope = context.visible_loop_scope;
2956        let local_values = LocalValueSources {
2957            owned: &context.local_vars,
2958            borrowed: &context.local_borrowed_vars,
2959        };
2960        let state = context.state;
2961        match evaluate_with_resolver(condition, |path| {
2962            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
2963        }) {
2964            Ok(result) => Ok(result),
2965            Err(ExpressionError::MissingValue(_)) => Ok(false),
2966            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
2967        }
2968    }
2969
2970    /// Process a for loop fragment.
2971    ///
2972    /// Creates a new context for each iteration that includes the current loop item.
2973    /// This allows nested templates to access both the loop variable and any parent context.
2974    /// Example: `for item in items` makes "item" available in the loop body.
2975    fn process_for_loop<'protocol, 'state>(
2976        &self,
2977        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2978        target: Option<usize>,
2979        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2980    ) -> Result<()> {
2981        if let Some(items) = resolve_borrowed_collection(
2982            &for_loop.collection,
2983            &context.loop_vars,
2984            context.visible_loop_scope,
2985            LocalValueSources {
2986                owned: &context.local_vars,
2987                borrowed: &context.local_borrowed_vars,
2988            },
2989            context.state,
2990        ) {
2991            return self.process_borrowed_for_loop(for_loop, target, items, context);
2992        }
2993        self.process_owned_for_loop(for_loop, target, context)
2994    }
2995
2996    fn process_borrowed_for_loop<'protocol, 'state>(
2997        &self,
2998        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2999        target: Option<usize>,
3000        items: &'state [Value],
3001        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3002    ) -> Result<()> {
3003        if let Some(plugin) = &mut context.plugin {
3004            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3005        }
3006
3007        let item_name = for_loop.item.as_str();
3008        let saved_value = context.local_vars.remove(item_name);
3009        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3010        let saved_scope = context.visible_loop_scope;
3011        for (index, item) in items.iter().enumerate() {
3012            if let Some(plugin) = &mut context.plugin {
3013                plugin.on_repeat_item_start(index, context.writer)?;
3014                plugin.push_scope();
3015            }
3016
3017            context.loop_vars.push(LoopBinding {
3018                name: item_name,
3019                value: item,
3020            });
3021            context.visible_loop_scope.end = context.loop_vars.len();
3022            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3023            context.loop_vars.pop();
3024            context.visible_loop_scope = saved_scope;
3025
3026            if let Some(plugin) = &mut context.plugin {
3027                plugin.pop_scope();
3028                plugin.on_repeat_item_end(index, context.writer)?;
3029            }
3030        }
3031        if let Some(value) = saved_value {
3032            context.local_vars.insert(item_name.to_string(), value);
3033        }
3034        if let Some(value) = saved_borrowed_value {
3035            context.local_borrowed_vars.insert(item_name, value);
3036        }
3037
3038        if let Some(plugin) = &mut context.plugin {
3039            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3040        }
3041        Ok(())
3042    }
3043
3044    fn process_owned_for_loop<'protocol, 'state>(
3045        &self,
3046        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3047        target: Option<usize>,
3048        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3049    ) -> Result<()> {
3050        let collection_name = &for_loop.collection;
3051
3052        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3053        // Hydration comments are always emitted regardless of collection presence.
3054        let items = match self.resolve_value_owned(collection_name, context) {
3055            Some(Value::Array(arr)) => arr,
3056            Some(_) => {
3057                return Err(HandlerError::TypeError(format!(
3058                    "Collection '{}' is not an array",
3059                    collection_name
3060                )))
3061            }
3062            None => Vec::new(),
3063        };
3064
3065        if let Some(p) = &mut context.plugin {
3066            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3067        }
3068
3069        // Hot-loop optimisation: the loop variable name is `String`-keyed
3070        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3071        // the key) on every iteration — a 1000-item loop pays 2000 String
3072        // clones for the key alone. Instead, we save the outer-scope
3073        // value (if any) ONCE before the loop, install the key ONCE with
3074        // an empty placeholder, then overwrite the value in-place each
3075        // iteration via `get_mut`. Restoration at the end happens once.
3076        let item_name = for_loop.item.as_str();
3077        let saved_value = context.local_vars.remove(item_name);
3078        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3079        // Pre-insert the key so per-iteration `get_mut` is infallible.
3080        // Cost: at most one `String::from(item_name)` for the lifetime
3081        // of the loop, regardless of iteration count.
3082        if !items.is_empty() {
3083            context
3084                .local_vars
3085                .insert(item_name.to_string(), Value::Null);
3086        }
3087        for (i, item) in items.into_iter().enumerate() {
3088            if let Some(p) = &mut context.plugin {
3089                p.on_repeat_item_start(i, context.writer)?;
3090                p.push_scope();
3091            }
3092
3093            // O(1) value swap; no key allocation.
3094            if let Some(slot) = context.local_vars.get_mut(item_name) {
3095                *slot = item;
3096            }
3097            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3098
3099            if let Some(p) = &mut context.plugin {
3100                p.pop_scope();
3101                p.on_repeat_item_end(i, context.writer)?;
3102            }
3103        }
3104        // Restore outer scope (or remove the placeholder we installed).
3105        match saved_value {
3106            Some(value) => {
3107                context.local_vars.insert(item_name.to_string(), value);
3108            }
3109            None => {
3110                context.local_vars.remove(item_name);
3111            }
3112        }
3113        if let Some(value) = saved_borrowed_value {
3114            context.local_borrowed_vars.insert(item_name, value);
3115        }
3116
3117        if let Some(p) = &mut context.plugin {
3118            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3119        }
3120
3121        Ok(())
3122    }
3123
3124    /// Process a signal fragment.
3125    ///
3126    /// Looks up the value in the context first (for local variables), then in the global state.
3127    /// This prioritization allows local variables (like loop items) to override global state.
3128    /// If the value is not found in either scope, an empty string is returned.
3129    #[inline]
3130    fn process_signal<'data>(
3131        &self,
3132        signal: &'data webui_protocol::WebUIFragmentSignal,
3133        context: &mut WebUIProcessContext<'data, '_, '_>,
3134    ) -> Result<()> {
3135        if signal.raw {
3136            self.process_raw_signal(signal, context)
3137        } else {
3138            self.process_state_signal(signal, false, context)
3139        }
3140    }
3141
3142    #[inline(never)]
3143    fn process_raw_signal<'data>(
3144        &self,
3145        signal: &'data webui_protocol::WebUIFragmentSignal,
3146        context: &mut WebUIProcessContext<'data, '_, '_>,
3147    ) -> Result<()> {
3148        let Some(structural_value) = structural_signal_value(signal) else {
3149            return self.process_state_signal(signal, !signal.raw_text_context, context);
3150        };
3151
3152        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3153            return self.process_shadow_style_signal(root, context);
3154        }
3155
3156        if context.streaming.is_some()
3157            && self.process_streaming_signal(structural_value, context)?
3158        {
3159            return Ok(());
3160        }
3161
3162        // Hook: emit nonce meta and Document-owned CSS before </head>.
3163        // Guarded by `head_end_emitted` so a malformed protocol cannot
3164        // emit nonce/preloads/inject more than once per render.
3165        if structural_value == "head_end" && !context.head_end_emitted {
3166            context.head_end_emitted = true;
3167            if let Some(nonce) = context.nonce {
3168                context
3169                    .writer
3170                    .write("<meta name=\"webui-nonce\" content=\"")?;
3171                context
3172                    .writer
3173                    .write(&crate::html_encode::encode_safe(nonce))?;
3174                context.writer.write("\">")?;
3175            }
3176
3177            // Render-policy CSS is emitted before component styles so an
3178            // authored declaration still wins on a tie.
3179            if !context.protocol.component_render_css.is_empty() {
3180                context.writer.write("<style data-webui-render-policy")?;
3181                if let Some(nonce) = context.nonce {
3182                    context.writer.write(" nonce=\"")?;
3183                    context
3184                        .writer
3185                        .write(&crate::html_encode::encode_safe(nonce))?;
3186                    context.writer.write("\"")?;
3187                }
3188                context.writer.write(">")?;
3189                context
3190                    .writer
3191                    .write(&context.protocol.component_render_css)?;
3192                context.writer.write("</style>")?;
3193            }
3194
3195            if !context.protocol.style_closures.is_empty() {
3196                let entry_id = context.entry_id;
3197                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3198                let mut preloaded = Vec::new();
3199                self.emit_active_route_styles(&mut preloaded, context)?;
3200                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3201            }
3202
3203            // Compiler-resolved `modulepreload` hints for the shared chunks
3204            // the page's module entries statically import. Those chunks are
3205            // named only inside the entry's own bytes, so without this the
3206            // browser must download and parse the entry before it can even
3207            // discover them.
3208            //
3209            // Emitted after the CSS links on purpose: CSS is render-blocking
3210            // and owns first paint, while these own first interaction, so the
3211            // stylesheet requests go out first. The list arrives pre-ordered
3212            // (largest chunk first) and pre-resolved from the build, so this
3213            // is a straight write with no per-request work.
3214            if !context.protocol.module_preloads.is_empty() {
3215                for href in &context.protocol.module_preloads {
3216                    context
3217                        .writer
3218                        .write("<link rel=\"modulepreload\" href=\"")?;
3219                    context.writer.write(href)?;
3220                    context.writer.write("\">")?;
3221                }
3222            }
3223
3224            write_component_asset_styles(context)?;
3225
3226            // Per-render `head_inject` HTML — image preloads, A/B test
3227            // markers, etc. supplied by the host via RenderOptions.
3228            // Emitted at the structural head_end boundary, after the
3229            // built-in nonce + CSS-link emissions, so host injects
3230            // appear immediately before `</head>`.
3231            if let Some(html) = context.head_inject {
3232                context.writer.write(html)?;
3233            }
3234
3235            // Reserved-state `headEnd` HTML, last at this boundary so a
3236            // host that sets both channels gets a deterministic order:
3237            // built-in emissions, then `RenderOptions`, then state.
3238            if let Some(html) = context.state_inject.head_end {
3239                context.writer.write(html)?;
3240            }
3241        }
3242
3243        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3244        // by its own dedup flag so a malformed protocol cannot duplicate it.
3245        if structural_value == "body_start" && !context.body_start_emitted {
3246            context.body_start_emitted = true;
3247            write_component_asset_styles(context)?;
3248            if let Some(html) = context.state_inject.body_start {
3249                context.writer.write(html)?;
3250            }
3251        }
3252
3253        // Hook: emit component templates and host body_inject before </body>.
3254        // Single guarded block so the dedup flag protects both the
3255        // hydration emission and the host inject from a malformed
3256        // protocol that fires `body_end` more than once per render.
3257        if structural_value == "body_end" && !context.body_end_emitted {
3258            context.body_end_emitted = true;
3259            if context.plugin.is_some() {
3260                // Emit templates for all REACHABLE components on the current route,
3261                // not just those rendered in this SSR pass. Components inside false
3262                // <if> blocks or empty <for> loops are reachable via client-side
3263                // state changes and need their templates available without a server
3264                // round-trip. The graph walker follows conditional and loop branches
3265                // unconditionally, but only descends into the matched route chain —
3266                // components on other routes are delivered via SPA partial navigation.
3267                let reachable = context
3268                    .reachable_components
3269                    .take()
3270                    .unwrap_or_else(|| {
3271                        crate::route_handler::collect_reachable_component_order_for_request(
3272                            context.protocol,
3273                            context.entry_id,
3274                            context.request_path,
3275                            context.route_index,
3276                        )
3277                    })
3278                    .into_iter()
3279                    .collect::<HashSet<_>>();
3280                let state_selection =
3281                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3282
3283                // Emit CSS module importmaps for reachable-but-unrendered
3284                // components so the framework can adopt them when an `<if>`
3285                // condition flips true client-side.
3286                if context.css_strategy == webui_protocol::CssStrategy::Module {
3287                    for name in &reachable {
3288                        if !context.rendered_components.contains(name) {
3289                            if let Some(css) = context
3290                                .protocol
3291                                .components
3292                                .get(name)
3293                                .map(|c| c.css.as_str())
3294                                .filter(|s| !s.is_empty())
3295                            {
3296                                self.emit_css_module_importmap(name, css, context)?;
3297                            }
3298                        }
3299                    }
3300                }
3301
3302                // Try to collect split WebUI template payloads. If the plugin
3303                // returns None (non-WebUI templates, e.g. FAST), fall back to
3304                // separate emission.
3305                let template_payloads = context
3306                    .plugin
3307                    .as_ref()
3308                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3309
3310                if template_payloads.is_none() {
3311                    // Non-JS templates (FAST plugins) - emit separately
3312                    if let Some(ref p) = context.plugin {
3313                        p.emit_templates(
3314                            context.protocol,
3315                            &reachable,
3316                            context.nonce,
3317                            context.writer,
3318                        )?;
3319                    }
3320                }
3321
3322                // Compute the inventory hex from actually rendered components.
3323                let inventory_hex = crate::route_handler::encode_component_inventory(
3324                    &context.rendered_components,
3325                    context.component_index,
3326                );
3327
3328                // Chain
3329                Self::ensure_request_route_chain(context);
3330                let chain = context.route_chain.as_deref().ok_or_else(|| {
3331                    HandlerError::Invariant(
3332                        "request route chain disappeared after collection".to_string(),
3333                    )
3334                })?;
3335                let chain_json: Vec<Value> = chain
3336                    .iter()
3337                    .map(crate::route_handler::RouteChainEntry::to_json)
3338                    .collect();
3339
3340                // CSS hrefs emitted during SSR (Link-strategy components)
3341                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3342                let mut css_hrefs: Vec<&str> = Vec::new();
3343                if is_link {
3344                    for name in &reachable {
3345                        if let Some(href) = context
3346                            .protocol
3347                            .components
3348                            .get(name)
3349                            .map(|c| c.css_href.as_str())
3350                            .filter(|h| !h.is_empty())
3351                        {
3352                            css_hrefs.push(href);
3353                        }
3354                    }
3355                }
3356
3357                // Module style specifiers emitted during SSR
3358                let mut style_specs: Vec<&str> = Vec::new();
3359                if context.css_strategy == webui_protocol::CssStrategy::Module {
3360                    for name in &reachable {
3361                        if context
3362                            .protocol
3363                            .components
3364                            .get(name)
3365                            .map(|c| !c.css.is_empty())
3366                            .unwrap_or(false)
3367                        {
3368                            style_specs.push(name);
3369                        }
3370                    }
3371                }
3372
3373                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3374                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3375                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3376                style_roots.push(context.entry_id);
3377                for entry in chain {
3378                    style_roots.push(entry.component.as_str());
3379                    if !entry.pending_component.is_empty() {
3380                        style_roots.push(entry.pending_component.as_str());
3381                    }
3382                    if !entry.error_component.is_empty() {
3383                        style_roots.push(entry.error_component.as_str());
3384                    }
3385                }
3386                style_roots.extend(reachable.iter().map(String::as_str));
3387                let component_styles =
3388                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3389                write_webui_data_block(
3390                    context.writer,
3391                    &mut context.json_scratch,
3392                    WebUiBootstrap {
3393                        declaration_id: None,
3394                        enclosing_span_instance_id: None,
3395                        state: context.state,
3396                        state_selection,
3397                        chain: &chain_json,
3398                        inventory: &inventory_hex,
3399                        nonce: context.nonce,
3400                        css_hrefs: &css_hrefs,
3401                        style_specs: &style_specs,
3402                        component_styles: &component_styles,
3403                        templates: payloads,
3404                    },
3405                )?;
3406
3407                // Let the active plugin emit any framework-specific executable
3408                // side channel. FAST plugins default to no-op; WebUI installs
3409                // templateFns. Client packages parse #webui-data lazily.
3410                if let Some(ref plugin) = context.plugin {
3411                    plugin.emit_bootstrap_extension(
3412                        BootstrapExtensionContext {
3413                            protocol: context.protocol,
3414                            components: &reachable,
3415                            payloads,
3416                            nonce: context.nonce,
3417                        },
3418                        context.writer,
3419                    )?;
3420                }
3421            }
3422
3423            // Per-render `body_inject` HTML — dev livereload script,
3424            // analytics, etc. supplied by the host via RenderOptions.
3425            // Inside the dedup block but outside the plugin-only
3426            // sub-block above, so it fires regardless of whether a
3427            // hydration plugin is active. Appears immediately before
3428            // `</body>`.
3429            if let Some(html) = context.body_inject {
3430                context.writer.write(html)?;
3431            }
3432
3433            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3434            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3435            if let Some(html) = context.state_inject.body_end {
3436                context.writer.write(html)?;
3437            }
3438        }
3439
3440        // Structural signals are never state lookups. In particular, ordinary
3441        // rendering ignores boundary/root markers byte-for-byte, while authored
3442        // raw bindings with the same visible key remain ordinary state.
3443        Ok(())
3444    }
3445
3446    /// Process a plain (non-structural) signal by resolving its value and
3447    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3448    ///
3449    /// `owns_html_range` only controls whether the active plugin emits
3450    /// replaceable sibling markers around the value (`on_binding_start/end`);
3451    /// it never affects escaping. In particular, `signal.raw_text_context`
3452    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3453    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3454    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3455    /// escaped binding inside an HTML raw-text element is an authoring
3456    /// footgun rather than something this function can safely correct.
3457    #[inline]
3458    fn process_state_signal(
3459        &self,
3460        signal: &webui_protocol::WebUIFragmentSignal,
3461        owns_html_range: bool,
3462        context: &mut WebUIProcessContext,
3463    ) -> Result<()> {
3464        if let Some(p) = &mut context.plugin {
3465            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3466        }
3467
3468        if let Some(value) = resolve_value_from_sources(
3469            &signal.value,
3470            &context.loop_vars,
3471            context.visible_loop_scope,
3472            LocalValueSources {
3473                owned: &context.local_vars,
3474                borrowed: &context.local_borrowed_vars,
3475            },
3476            context.state,
3477        ) {
3478            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3479        }
3480
3481        if let Some(p) = &mut context.plugin {
3482            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3483        }
3484        Ok(())
3485    }
3486
3487    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3488    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3489    ///
3490    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3491    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3492    /// context: it does not know whether it is writing into a raw-text
3493    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3494    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3495    fn write_signal_value(
3496        &self,
3497        value: &Value,
3498        raw: bool,
3499        writer: &mut dyn ResponseWriter,
3500    ) -> Result<()> {
3501        if raw {
3502            match value {
3503                Value::String(s) => writer.write(s),
3504                _ => writer.write(&value.to_string()),
3505            }
3506        } else {
3507            match value {
3508                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3509                _ => {
3510                    let s = value.to_string();
3511                    writer.write(&crate::html_encode::encode_safe(&s))
3512                }
3513            }
3514        }
3515    }
3516
3517    /// Process an if condition fragment.
3518    fn process_if(
3519        &self,
3520        if_cond: &webui_protocol::WebUIFragmentIf,
3521        target: Option<usize>,
3522        context: &mut WebUIProcessContext,
3523    ) -> Result<()> {
3524        let condition = if_cond
3525            .condition
3526            .as_ref()
3527            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3528        let condition_met = self.evaluate_condition(condition, context)?;
3529
3530        if let Some(p) = &mut context.plugin {
3531            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3532        }
3533
3534        if condition_met {
3535            if let Some(p) = &mut context.plugin {
3536                p.push_scope();
3537            }
3538
3539            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3540
3541            if let Some(p) = &mut context.plugin {
3542                p.pop_scope();
3543            }
3544        }
3545
3546        if let Some(p) = &mut context.plugin {
3547            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3548        }
3549
3550        Ok(())
3551    }
3552
3553    /// Process an attribute fragment by rendering the attribute name/value pair.
3554    ///
3555    /// `template_target` and `component_name` are prepared once per protocol by
3556    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3557    /// camelCase prop-name conversion is repeated per render.
3558    fn process_attribute<'protocol, 'state>(
3559        &self,
3560        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3561        template_target: Option<usize>,
3562        component_name: Option<&'protocol str>,
3563        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3564    ) -> Result<()> {
3565        // Initialize component attribute accumulator on attrStart. Clearing the
3566        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3567        if attr.attr_start {
3568            context.component_attrs.clear();
3569            context.component_borrowed_attrs.clear();
3570            context.collecting_component_attrs = true;
3571        }
3572
3573        // Boolean attribute with condition tree
3574        if let Some(condition) = &attr.condition_tree {
3575            let condition_met = self.evaluate_condition(condition, context)?;
3576
3577            if context.collecting_component_attrs && !attr.attr_skip {
3578                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3579                context.component_borrowed_attrs.remove(name);
3580                context
3581                    .component_attrs
3582                    .insert(name.to_owned(), Value::Bool(condition_met));
3583            }
3584
3585            if condition_met {
3586                context.writer.write(" ")?;
3587                context.writer.write(&attr.name)?;
3588            }
3589            return Ok(());
3590        }
3591
3592        // Template attribute (mixed static + dynamic)
3593        if !attr.template.is_empty() {
3594            let raw_value =
3595                self.render_template_attr_value(&attr.template, template_target, context)?;
3596            let escaped = crate::html_encode::encode_safe(&raw_value);
3597            write_attr(context.writer, &attr.name, &escaped)?;
3598
3599            if context.collecting_component_attrs && !attr.attr_skip {
3600                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3601                context.component_borrowed_attrs.remove(name);
3602                context
3603                    .component_attrs
3604                    .insert(name.to_owned(), Value::String(raw_value));
3605            }
3606            return Ok(());
3607        }
3608
3609        // Simple attribute
3610        if !attr.value.is_empty() {
3611            if attr.raw_value {
3612                // Static attribute — value is the literal string
3613                write_attr(context.writer, &attr.name, &attr.value)?;
3614                if context.collecting_component_attrs && !attr.attr_skip {
3615                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3616                    context.component_borrowed_attrs.remove(name);
3617                    context
3618                        .component_attrs
3619                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3620                }
3621            } else if attr.complex {
3622                // Complex attribute — resolve value, don't render to HTML, store as state
3623                if context.collecting_component_attrs && !attr.attr_skip {
3624                    let state_backed_value = if context.streaming.is_none() {
3625                        resolve_state_backed_value(
3626                            &attr.value,
3627                            &context.loop_vars,
3628                            context.visible_loop_scope,
3629                            LocalValueSources {
3630                                owned: &context.local_vars,
3631                                borrowed: &context.local_borrowed_vars,
3632                            },
3633                            context.state,
3634                        )
3635                    } else {
3636                        None
3637                    };
3638                    if let Some(value) = state_backed_value {
3639                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3640                        context.component_attrs.remove(name);
3641                        context.component_borrowed_attrs.insert(name, value);
3642                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3643                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3644                        context.component_borrowed_attrs.remove(name);
3645                        context.component_attrs.insert(name.to_owned(), value);
3646                    }
3647                }
3648            } else {
3649                // Dynamic attribute — resolve and render
3650                // Streaming continuations must own component locals across host
3651                // calls, so borrowing there would add a second lookup before the
3652                // VM materializes the same value.
3653                let state_backed_value = if context.collecting_component_attrs
3654                    && !attr.attr_skip
3655                    && context.streaming.is_none()
3656                {
3657                    resolve_state_backed_value(
3658                        &attr.value,
3659                        &context.loop_vars,
3660                        context.visible_loop_scope,
3661                        LocalValueSources {
3662                            owned: &context.local_vars,
3663                            borrowed: &context.local_borrowed_vars,
3664                        },
3665                        context.state,
3666                    )
3667                } else {
3668                    None
3669                };
3670                // Reuse the immutable-state lookup for both HTML output and the
3671                // child scope instead of resolving every component attribute twice.
3672                let value = match state_backed_value {
3673                    Some(value) => Some(Cow::Borrowed(value)),
3674                    None => resolve_value_from_sources(
3675                        &attr.value,
3676                        &context.loop_vars,
3677                        context.visible_loop_scope,
3678                        LocalValueSources {
3679                            owned: &context.local_vars,
3680                            borrowed: &context.local_borrowed_vars,
3681                        },
3682                        context.state,
3683                    ),
3684                };
3685                // Always emit the attribute so FAST hydration markers
3686                // (`data-fe`) match the DOM node structure.
3687                match value.as_deref() {
3688                    Some(Value::String(s)) => {
3689                        write_attr(
3690                            context.writer,
3691                            &attr.name,
3692                            &crate::html_encode::encode_safe(s),
3693                        )?;
3694                    }
3695                    Some(Value::Null) | None => {
3696                        write_attr(context.writer, &attr.name, "")?;
3697                    }
3698                    Some(other) => {
3699                        let s = other.to_string();
3700                        write_attr(
3701                            context.writer,
3702                            &attr.name,
3703                            &crate::html_encode::encode_safe(&s),
3704                        )?;
3705                    }
3706                }
3707
3708                if context.collecting_component_attrs && !attr.attr_skip {
3709                    if let Some(borrowed) = state_backed_value {
3710                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3711                        context.component_attrs.remove(name);
3712                        context.component_borrowed_attrs.insert(name, borrowed);
3713                    } else {
3714                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3715                        context.component_borrowed_attrs.remove(name);
3716                        context.component_attrs.insert(
3717                            name.to_owned(),
3718                            value
3719                                .map(Cow::into_owned)
3720                                .unwrap_or(Value::String(String::new())),
3721                        );
3722                    }
3723                }
3724            }
3725        }
3726
3727        Ok(())
3728    }
3729
3730    /// Render a template attribute's fragments into a raw (unescaped) string.
3731    fn render_template_attr_value(
3732        &self,
3733        template_id: &str,
3734        target: Option<usize>,
3735        context: &WebUIProcessContext,
3736    ) -> Result<String> {
3737        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3738            return Err(HandlerError::MissingFragment(template_id.to_string()));
3739        };
3740        let fragments = context
3741            .render_fragments
3742            .list(index)
3743            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3744        let mut raw_value = String::new();
3745        for frag in fragments.fragments {
3746            match frag.fragment.as_ref() {
3747                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3748                Some(Fragment::Signal(signal)) => {
3749                    if let Some(value) = resolve_value_from_sources(
3750                        &signal.value,
3751                        &context.loop_vars,
3752                        context.visible_loop_scope,
3753                        LocalValueSources {
3754                            owned: &context.local_vars,
3755                            borrowed: &context.local_borrowed_vars,
3756                        },
3757                        context.state,
3758                    ) {
3759                        match value.as_ref() {
3760                            Value::String(s) => raw_value.push_str(s),
3761                            _ => raw_value.push_str(&value.to_string()),
3762                        }
3763                    }
3764                }
3765                _ => {}
3766            }
3767        }
3768        Ok(raw_value)
3769    }
3770
3771    /// Render the UI based on the protocol and state.
3772    pub fn render<'a>(
3773        &self,
3774        protocol: &'a Protocol,
3775        state: &'a Value,
3776        options: &RenderOptions<'a>,
3777        writer: &'a mut dyn ResponseWriter,
3778    ) -> Result<()> {
3779        protocol.ensure_style_metadata()?;
3780        let document = protocol.protocol();
3781        let render_fragments = protocol.render_fragments().resolve(document);
3782        let Some(entry_index) = render_fragments.index(options.entry_id) else {
3783            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3784        };
3785        let entry = render_fragments
3786            .list(entry_index)
3787            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3788        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3789        let has_document_head_boundary = !entry_owns_css_tree
3790            && !document.style_closures.is_empty()
3791            && entry.fragments.iter().any(|fragment| {
3792                matches!(
3793                    fragment.fragment.as_ref(),
3794                    Some(Fragment::Signal(signal))
3795                        if structural_signal_value(signal) == Some("head_end")
3796                )
3797            });
3798        let doctype_split = (!entry_owns_css_tree
3799            && !has_document_head_boundary
3800            && !document.style_closures.is_empty())
3801        .then(|| {
3802            let Some(Fragment::Raw(raw)) = entry
3803                .fragments
3804                .first()
3805                .and_then(|fragment| fragment.fragment.as_ref())
3806            else {
3807                return None;
3808            };
3809            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3810        })
3811        .flatten();
3812        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3813        let mut context = WebUIProcessContext {
3814            protocol: document,
3815            render_fragments,
3816            component_asset_style_manifest,
3817            component_asset_style_links: protocol.component_asset_style_links(),
3818            state,
3819            writer,
3820            local_vars: HashMap::new(),
3821            local_borrowed_vars: BorrowedScope::default(),
3822            loop_vars: Vec::new(),
3823            visible_loop_scope: VisibleLoopScope::EMPTY,
3824            component_attrs: HashMap::new(),
3825            component_borrowed_attrs: BorrowedScope::default(),
3826            collecting_component_attrs: false,
3827            request_path: options.request_path,
3828            route_base: Cow::Borrowed("/"),
3829            rendered_components: HashSet::new(),
3830            plugin: self.plugin_factory.map(|f| f()),
3831            route_children: Vec::new(),
3832            entry_id: options.entry_id,
3833            // Same defensive normalisation as `handle()`. See the
3834            // doc-comment there for the CSP-outage rationale.
3835            nonce: options.nonce.filter(|s| !s.is_empty()),
3836            head_inject: options.head_inject.filter(|s| !s.is_empty()),
3837            body_inject: options.body_inject.filter(|s| !s.is_empty()),
3838            state_inject: StateInject::resolve(state),
3839            head_end_emitted: false,
3840            body_start_emitted: false,
3841            component_asset_styles_emitted: false,
3842            component_index: protocol.component_index(),
3843            style_resource_index: protocol.style_resource_index(),
3844            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
3845            style_chunk_index: protocol.protocol().style_chunk_index(),
3846            css_strategy: protocol.css_strategy(),
3847            body_end_emitted: false,
3848            route_index: protocol.route_index(),
3849            route_chain_index: 0,
3850            route_chain: None,
3851            route_document_style_targets: Vec::new(),
3852            reachable_components: None,
3853            streaming: None,
3854            json_scratch: Vec::new(),
3855            scope_pool: Vec::new(),
3856            document_style_resources: HashSet::new(),
3857            shadow_style_roots: Vec::new(),
3858            borrowed_scope_pool: Vec::new(),
3859        };
3860
3861        if entry_owns_css_tree {
3862            Self::push_shadow_style_root(options.entry_id, &mut context)?;
3863        }
3864
3865        let render_result = if let Some((first_raw, split)) = doctype_split {
3866            context.writer.write(&first_raw[..split])?;
3867            self.emit_component_style_closure(
3868                options.entry_id,
3869                StyleClosureInstall::Static,
3870                &mut context,
3871            )?;
3872            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3873            context.writer.write(&first_raw[split..])?;
3874            self.process_fragment_from(entry, 1, &mut context)
3875        } else {
3876            if !entry_owns_css_tree
3877                && !has_document_head_boundary
3878                && !document.style_closures.is_empty()
3879            {
3880                self.emit_component_style_closure(
3881                    options.entry_id,
3882                    StyleClosureInstall::Static,
3883                    &mut context,
3884                )?;
3885                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3886            }
3887            self.process_fragment(entry, &mut context)
3888        };
3889
3890        if entry_owns_css_tree {
3891            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3892        }
3893        render_result?;
3894        writer.end()?;
3895
3896        Ok(())
3897    }
3898}
3899
3900impl Default for WebUIHandler {
3901    fn default() -> Self {
3902        Self::new()
3903    }
3904}
3905
3906/// Write ` name="value"` to the writer without allocating a format string.
3907fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3908    writer.write_attribute(name, value)
3909}
3910
3911#[cfg(test)]
3912fn handle(
3913    protocol: &WebUIProtocol,
3914    state: &Value,
3915    options: &RenderOptions<'_>,
3916    writer: &mut dyn ResponseWriter,
3917) -> Result<()> {
3918    let handler = WebUIHandler::new();
3919    handler.handle(protocol, state, options, writer)
3920}
3921
3922#[cfg(test)]
3923mod tests {
3924    use super::*;
3925    use crate::streaming::STREAMING_MARKER;
3926    use std::cell::RefCell;
3927    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
3928    use webui_protocol::{
3929        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
3930        WebUIFragmentAttribute, WebUiFragmentRoute,
3931    };
3932    use webui_test_utils::test_json;
3933
3934    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
3935        WebUIFragment::signal(
3936            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
3937            true,
3938        )
3939    }
3940
3941    #[test]
3942    fn borrowed_collection_reuses_state_array() {
3943        let state = test_json!({
3944            "items": [
3945                {"name": "first"},
3946                {"name": "second"}
3947            ]
3948        });
3949        let local_vars = HashMap::new();
3950        let local_borrowed_vars = BorrowedScope::default();
3951        let loop_vars = Vec::new();
3952        let items = resolve_borrowed_collection(
3953            "items",
3954            &loop_vars,
3955            VisibleLoopScope::EMPTY,
3956            LocalValueSources {
3957                owned: &local_vars,
3958                borrowed: &local_borrowed_vars,
3959            },
3960            &state,
3961        )
3962        .unwrap_or_else(|| panic!("state array should be borrowed"));
3963        let state_items = state["items"]
3964            .as_array()
3965            .unwrap_or_else(|| panic!("fixture items should be an array"));
3966        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3967    }
3968
3969    #[test]
3970    fn owned_local_collection_keeps_precedence() {
3971        let state = test_json!({"items": [{"name": "global"}]});
3972        let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
3973        let local_borrowed_vars = BorrowedScope::default();
3974        assert!(resolve_borrowed_collection(
3975            "items",
3976            &[],
3977            VisibleLoopScope::EMPTY,
3978            LocalValueSources {
3979                owned: &local_vars,
3980                borrowed: &local_borrowed_vars,
3981            },
3982            &state,
3983        )
3984        .is_none());
3985    }
3986
3987    #[test]
3988    fn borrowed_component_collection_reuses_state_array() {
3989        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
3990        let contacts = &state["teams"][0]["contacts"];
3991        let mut local_borrowed_vars = BorrowedScope::default();
3992        local_borrowed_vars.insert("contacts", contacts);
3993        let local_vars = HashMap::new();
3994        let items = resolve_borrowed_collection(
3995            "contacts",
3996            &[],
3997            VisibleLoopScope::EMPTY,
3998            LocalValueSources {
3999                owned: &local_vars,
4000                borrowed: &local_borrowed_vars,
4001            },
4002            &state,
4003        )
4004        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4005        let state_items = contacts
4006            .as_array()
4007            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4008        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4009    }
4010
4011    #[test]
4012    fn nested_borrowed_collection_reuses_child_array() {
4013        let state = test_json!({
4014            "items": [{
4015                "children": [
4016                    {"name": "first"},
4017                    {"name": "second"}
4018                ]
4019            }]
4020        });
4021        let item = &state["items"][0];
4022        let loop_vars = vec![LoopBinding {
4023            name: "item",
4024            value: item,
4025        }];
4026        let local_vars = HashMap::new();
4027        let local_borrowed_vars = BorrowedScope::default();
4028        let children = resolve_borrowed_collection(
4029            "item.children",
4030            &loop_vars,
4031            VisibleLoopScope { start: 0, end: 1 },
4032            LocalValueSources {
4033                owned: &local_vars,
4034                borrowed: &local_borrowed_vars,
4035            },
4036            &state,
4037        )
4038        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4039        let state_children = item["children"]
4040            .as_array()
4041            .unwrap_or_else(|| panic!("fixture children should be an array"));
4042        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4043    }
4044
4045    #[test]
4046    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4047        let state = test_json!({
4048            "item": {"name": "global", "fallback": "global fallback"},
4049            "outer": {"name": "outer"},
4050            "inner": {"name": "inner"}
4051        });
4052        let loop_vars = vec![
4053            LoopBinding {
4054                name: "item",
4055                value: &state["outer"],
4056            },
4057            LoopBinding {
4058                name: "item",
4059                value: &state["inner"],
4060            },
4061        ];
4062        let local_vars = HashMap::new();
4063        let local_borrowed_vars = BorrowedScope::default();
4064        let sources = LocalValueSources {
4065            owned: &local_vars,
4066            borrowed: &local_borrowed_vars,
4067        };
4068
4069        assert_eq!(
4070            resolve_value_from_sources(
4071                "item.name",
4072                &loop_vars,
4073                VisibleLoopScope { start: 0, end: 2 },
4074                sources,
4075                &state,
4076            )
4077            .as_deref()
4078            .and_then(Value::as_str),
4079            Some("inner")
4080        );
4081        assert_eq!(
4082            resolve_value_from_sources(
4083                "item.fallback",
4084                &loop_vars,
4085                VisibleLoopScope { start: 0, end: 2 },
4086                sources,
4087                &state,
4088            )
4089            .as_deref()
4090            .and_then(Value::as_str),
4091            Some("global fallback")
4092        );
4093        assert_eq!(
4094            resolve_value_from_sources(
4095                "item.name",
4096                &loop_vars,
4097                VisibleLoopScope { start: 2, end: 2 },
4098                sources,
4099                &state,
4100            )
4101            .as_deref()
4102            .and_then(Value::as_str),
4103            Some("global")
4104        );
4105    }
4106
4107    #[test]
4108    fn state_backed_resolver_matches_general_borrowed_resolution() {
4109        let state = test_json!({
4110            "global": {"name": "global"},
4111            "inner": {"fallback": "global fallback"},
4112            "borrowed": {"name": "borrowed"},
4113            "items": [1, 2, 3]
4114        });
4115        let loop_values = test_json!({
4116            "outer": {"name": "outer"},
4117            "inner": {"name": "inner"}
4118        });
4119        let loop_vars = vec![
4120            LoopBinding {
4121                name: "outer",
4122                value: &loop_values["outer"],
4123            },
4124            LoopBinding {
4125                name: "inner",
4126                value: &loop_values["inner"],
4127            },
4128        ];
4129        let mut local_borrowed_vars = BorrowedScope::default();
4130        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4131        let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4132        let sources = LocalValueSources {
4133            owned: &local_vars,
4134            borrowed: &local_borrowed_vars,
4135        };
4136        let scope = VisibleLoopScope { start: 0, end: 2 };
4137
4138        for path in [
4139            "global.name",
4140            "inner.name",
4141            "inner.fallback",
4142            "outer.name",
4143            "borrowed.name",
4144        ] {
4145            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4146                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4147            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4148                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4149            let Cow::Borrowed(resolved) = resolved else {
4150                panic!("{path} should remain borrowed");
4151            };
4152            assert!(
4153                std::ptr::eq(state_backed, resolved),
4154                "{path} resolved from different sources"
4155            );
4156        }
4157
4158        assert!(
4159            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4160            "owned local values must use the general resolver"
4161        );
4162        assert!(
4163            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4164                .is_none(),
4165            "synthetic values must use the general resolver"
4166        );
4167    }
4168
4169    #[test]
4170    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4171        let values = [
4172            Value::String("a".to_string()),
4173            Value::String("b".to_string()),
4174            Value::String("c".to_string()),
4175            Value::String("d".to_string()),
4176            Value::String("e".to_string()),
4177            Value::String("f".to_string()),
4178            Value::String("replacement".to_string()),
4179        ];
4180        let mut scope = BorrowedScope::default();
4181        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4182            assert!(scope.insert(name, &values[index]).is_none());
4183        }
4184        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4185        assert_eq!(scope.overflow.len(), 2);
4186        assert_eq!(scope.get("a"), Some(&values[0]));
4187        assert_eq!(scope.get("f"), Some(&values[5]));
4188        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4189        assert_eq!(scope.get("a"), Some(&values[6]));
4190        assert_eq!(scope.remove("b"), Some(&values[1]));
4191        assert!(scope.get("b").is_none());
4192        assert_eq!(scope.get("f"), Some(&values[5]));
4193        scope.clear();
4194        assert!(scope.get("a").is_none());
4195        assert!(scope.overflow.is_empty());
4196    }
4197
4198    // A simple test writer implementation
4199    struct TestWriter {
4200        content: RefCell<String>,
4201        ended: RefCell<bool>,
4202    }
4203
4204    impl TestWriter {
4205        fn new() -> Self {
4206            Self {
4207                content: RefCell::new(String::new()),
4208                ended: RefCell::new(false),
4209            }
4210        }
4211
4212        fn get_content(&self) -> String {
4213            self.content.borrow().clone()
4214        }
4215
4216        fn is_ended(&self) -> bool {
4217            *self.ended.borrow()
4218        }
4219    }
4220
4221    impl ResponseWriter for TestWriter {
4222        fn write(&mut self, content: &str) -> Result<()> {
4223            self.content.borrow_mut().push_str(content);
4224            Ok(())
4225        }
4226
4227        fn end(&mut self) -> Result<()> {
4228            *self.ended.borrow_mut() = true;
4229            Ok(())
4230        }
4231    }
4232
4233    #[test]
4234    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4235        struct AttributeWriter {
4236            output: String,
4237        }
4238
4239        impl ResponseWriter for AttributeWriter {
4240            fn write(&mut self, content: &str) -> Result<()> {
4241                self.output.push_str(content);
4242                Ok(())
4243            }
4244
4245            crate::string_response_writer_methods!(output);
4246
4247            fn end(&mut self) -> Result<()> {
4248                Ok(())
4249            }
4250        }
4251
4252        let mut writer = AttributeWriter {
4253            output: String::new(),
4254        };
4255        write_attr(&mut writer, "data-id", "42")
4256            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4257        writer
4258            .write_boolean_attribute("disabled")
4259            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4260        assert_eq!(writer.output, " data-id=\"42\" disabled");
4261    }
4262
4263    #[test]
4264    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4265        let mut fragments = HashMap::new();
4266        fragments.insert(
4267            "index.html".to_string(),
4268            FragmentList {
4269                fragments: vec![
4270                    WebUIFragment {
4271                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4272                            name: ":data-title".to_string(),
4273                            value: "source".to_string(),
4274                            complex: true,
4275                            attr_start: true,
4276                            ..Default::default()
4277                        })),
4278                    },
4279                    WebUIFragment::component("prepared-child"),
4280                ],
4281                contains_boundary: false,
4282            },
4283        );
4284        fragments.insert(
4285            "prepared-child".to_string(),
4286            FragmentList {
4287                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4288                contains_boundary: false,
4289            },
4290        );
4291        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4292        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4293        let entry = resolved
4294            .list_by_id("index.html")
4295            .unwrap_or_else(|| panic!("entry render plan should exist"));
4296        let child_index = protocol
4297            .render_fragments()
4298            .index("prepared-child")
4299            .unwrap_or_else(|| panic!("component target should be indexed"));
4300        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4301        assert_eq!(entry.target(1), Some(child_index));
4302        assert!(!entry.has_routes);
4303        let source_entry = protocol
4304            .protocol()
4305            .fragments
4306            .get("index.html")
4307            .unwrap_or_else(|| panic!("source entry should exist"));
4308        assert!(std::ptr::eq(
4309            entry.fragments.as_ptr(),
4310            source_entry.fragments.as_ptr()
4311        ));
4312
4313        let state = test_json!({"source": "Prepared"});
4314        let options = RenderOptions::new("index.html", "/");
4315        for handler in [
4316            WebUIHandler::new(),
4317            WebUIHandler::with_plugin(|| {
4318                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4319            }),
4320        ] {
4321            let mut writer = TestWriter::new();
4322            assert!(handler
4323                .render(&protocol, &state, &options, &mut writer)
4324                .is_ok());
4325            assert!(writer.get_content().contains("Prepared"));
4326        }
4327    }
4328
4329    #[test]
4330    fn prepared_graph_records_route_presence_per_fragment_list() {
4331        let mut fragments = HashMap::new();
4332        fragments.insert(
4333            "index.html".to_string(),
4334            FragmentList {
4335                fragments: vec![WebUIFragment::route("/", "dash-page")],
4336                contains_boundary: false,
4337            },
4338        );
4339        fragments.insert(
4340            "dash-page".to_string(),
4341            FragmentList {
4342                fragments: vec![WebUIFragment::raw("dashboard")],
4343                contains_boundary: false,
4344            },
4345        );
4346        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4347        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4348        let entry = resolved
4349            .list_by_id("index.html")
4350            .unwrap_or_else(|| panic!("entry render plan should exist"));
4351        let page = resolved
4352            .list_by_id("dash-page")
4353            .unwrap_or_else(|| panic!("page render plan should exist"));
4354        // Only lists that actually contain a route pay for the sibling scan.
4355        assert!(entry.has_routes);
4356        assert!(!page.has_routes);
4357    }
4358
4359    #[test]
4360    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4361        // Preparation writes the camelCase conversion straight into the shared
4362        // arena instead of calling `attribute_to_camel`, which allocates. The two
4363        // must stay byte-identical, including the irregular lookup-table entries.
4364        let names = [
4365            "data-title",
4366            ":data-title",
4367            "aria-labelledby",
4368            "aria-valuemax",
4369            "readonly",
4370            "tabindex",
4371            "class",
4372            "for",
4373            "maxlength",
4374            "novalidate",
4375            "already-camel",
4376            "trailing-",
4377            "-leading",
4378            "multi-part-attribute-name",
4379            "single",
4380            "",
4381        ];
4382        for name in names {
4383            let stripped = name.strip_prefix(':').unwrap_or(name);
4384            assert_eq!(
4385                component_attr_name(name),
4386                webui_protocol::attrs::attribute_to_camel(stripped),
4387                "prepared name diverged for `{name}`"
4388            );
4389        }
4390    }
4391
4392    #[test]
4393    fn prepared_component_prop_names_match_per_branch_stripping() {
4394        // The render path strips a leading `:` twice on the complex-value branch
4395        // and once elsewhere. Preparation must reproduce that per branch or a
4396        // `::`-prefixed binding would change the emitted component prop name.
4397        let complex = WebUIFragmentAttribute {
4398            name: "::data-title".to_string(),
4399            value: "source".to_string(),
4400            complex: true,
4401            ..Default::default()
4402        };
4403        assert_eq!(component_attr_source(&complex), ":data-title");
4404        assert_eq!(
4405            component_attr_name(component_attr_source(&complex)),
4406            "dataTitle"
4407        );
4408
4409        // `raw_value` takes the static branch, which strips only once.
4410        let raw = WebUIFragmentAttribute {
4411            raw_value: true,
4412            ..complex.clone()
4413        };
4414        assert_eq!(component_attr_source(&raw), "::data-title");
4415
4416        // A template attribute returns before the complex branch is reached.
4417        let templated = WebUIFragmentAttribute {
4418            template: "tpl".to_string(),
4419            ..complex.clone()
4420        };
4421        assert_eq!(component_attr_source(&templated), "::data-title");
4422
4423        // An empty value never reaches the complex branch either.
4424        let empty_value = WebUIFragmentAttribute {
4425            value: String::new(),
4426            ..complex.clone()
4427        };
4428        assert_eq!(component_attr_source(&empty_value), "::data-title");
4429
4430        // Ordinary single-colon bindings are unaffected on every branch.
4431        let ordinary = WebUIFragmentAttribute {
4432            name: ":data-title".to_string(),
4433            ..complex
4434        };
4435        assert_eq!(component_attr_source(&ordinary), "data-title");
4436        assert_eq!(
4437            component_attr_name(component_attr_source(&ordinary)),
4438            "dataTitle"
4439        );
4440    }
4441
4442    #[test]
4443    fn render_slots_match_continuation_slots() {
4444        // The streaming VM reads prepared metadata with the continuation slot
4445        // directly. That is only sound while both numberings index the same
4446        // sorted fragment IDs, so pin the invariant here rather than paying for
4447        // an ID lookup per streamed fragment frame.
4448        let mut fragments = HashMap::new();
4449        for id in [
4450            "index.html",
4451            "zeta-last",
4452            "alpha-first",
4453            "middle-one",
4454            "middle-two",
4455        ] {
4456            fragments.insert(
4457                id.to_string(),
4458                FragmentList {
4459                    fragments: vec![WebUIFragment::raw(id)],
4460                    contains_boundary: false,
4461                },
4462            );
4463        }
4464        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4465        let index = protocol.render_fragments();
4466        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4467            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4468        {
4469            let id = protocol
4470                .fragment_id(slot)
4471                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4472            assert_eq!(
4473                index.index(id),
4474                Some(slot as usize),
4475                "render slot must equal continuation slot for `{id}`"
4476            );
4477            assert_eq!(protocol.fragment_slot(id), Some(slot));
4478        }
4479    }
4480
4481    #[test]
4482    fn prepared_graph_reports_missing_fragments_without_panicking() {
4483        let mut fragments = HashMap::new();
4484        fragments.insert(
4485            "index.html".to_string(),
4486            FragmentList {
4487                fragments: vec![WebUIFragment::component("absent-child")],
4488                contains_boundary: false,
4489            },
4490        );
4491        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4492        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4493        let entry = resolved
4494            .list_by_id("index.html")
4495            .unwrap_or_else(|| panic!("entry render plan should exist"));
4496        // An unresolvable reference keeps a `None` target and must not index
4497        // out of range or silently render an unrelated fragment list.
4498        assert_eq!(entry.target(0), None);
4499        assert!(resolved.list_by_id("absent-child").is_none());
4500        assert!(resolved.list(usize::MAX).is_none());
4501
4502        let state = test_json!({});
4503        let options = RenderOptions::new("index.html", "/");
4504        let mut writer = TestWriter::new();
4505        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4506            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4507            other => panic!("expected a missing fragment error, got {other:?}"),
4508        }
4509
4510        let mut writer = TestWriter::new();
4511        match WebUIHandler::new().render(
4512            &protocol,
4513            &state,
4514            &RenderOptions::new("absent-entry", "/"),
4515            &mut writer,
4516        ) {
4517            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4518            other => panic!("expected a missing entry error, got {other:?}"),
4519        }
4520    }
4521
4522    #[test]
4523    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4524        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4525        let mut fragments = HashMap::with_capacity(list_count);
4526        for index in 0..list_count {
4527            fragments.insert(
4528                format!("fragment-{index}"),
4529                FragmentList {
4530                    fragments: vec![WebUIFragment::raw("content")],
4531                    contains_boundary: false,
4532                },
4533            );
4534        }
4535        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4536        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4537        // Nothing is materialized until a slot is actually rendered.
4538        assert!(resolved.cache[0].get().is_none());
4539        assert!(resolved.spilled.get().is_none());
4540        assert!(resolved.list(0).is_some());
4541        assert!(resolved.cache[0].get().is_some());
4542        // Rendering only inline slots never allocates the spill region.
4543        assert!(resolved.spilled.get().is_none());
4544        // Slots past the inline cache spill once, then stay memoized so a deep
4545        // descent never re-hashes the fragment ID.
4546        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4547        let spilled = resolved
4548            .spilled
4549            .get()
4550            .expect("a slot past the inline cache should allocate the spill region");
4551        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4552        assert!(spilled[0].get().is_some());
4553        assert!(resolved.list(list_count - 1).is_some());
4554
4555        for (id, source) in &protocol.protocol().fragments {
4556            let prepared = resolved
4557                .list_by_id(id)
4558                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4559            // Borrowed, never duplicated: the prepared list points at protocol storage.
4560            assert!(std::ptr::eq(
4561                prepared.fragments.as_ptr(),
4562                source.fragments.as_ptr()
4563            ));
4564        }
4565    }
4566
4567    #[test]
4568    fn test_handle_raw() {
4569        // Create a simple protocol
4570        let mut fragments = HashMap::new();
4571        fragments.insert(
4572            "index.html".to_string(),
4573            FragmentList {
4574                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4575                contains_boundary: false,
4576            },
4577        );
4578
4579        let protocol = WebUIProtocol::new(fragments);
4580        let state = test_json!({});
4581
4582        // Create a test writer
4583        let mut writer = TestWriter::new();
4584
4585        // Handle the protocol
4586        assert!(
4587            handle(
4588                &protocol,
4589                &state,
4590                &RenderOptions::new("index.html", "/"),
4591                &mut writer
4592            )
4593            .is_ok(),
4594            "Failed to handle raw protocol"
4595        );
4596
4597        // Check the output
4598        assert_eq!(writer.get_content(), "Hello, WebUI!");
4599        assert!(writer.is_ended());
4600    }
4601
4602    #[test]
4603    fn test_handle_signal() {
4604        // Create a protocol with a signal
4605        let mut fragments = HashMap::new();
4606        fragments.insert(
4607            "index.html".to_string(),
4608            FragmentList {
4609                fragments: vec![
4610                    WebUIFragment::raw("Hello, "),
4611                    WebUIFragment::signal("name", false),
4612                    WebUIFragment::raw("!"),
4613                ],
4614                contains_boundary: false,
4615            },
4616        );
4617
4618        let protocol = WebUIProtocol::new(fragments);
4619        let state = test_json!({"name": "WebUI"});
4620
4621        // Create a test writer
4622        let mut writer = TestWriter::new();
4623
4624        // Handle the protocol
4625        assert!(
4626            handle(
4627                &protocol,
4628                &state,
4629                &RenderOptions::new("index.html", "/"),
4630                &mut writer
4631            )
4632            .is_ok(),
4633            "Failed to handle signal protocol"
4634        );
4635
4636        // Check the output
4637        assert_eq!(writer.get_content(), "Hello, WebUI!");
4638        assert!(writer.is_ended());
4639    }
4640
4641    #[test]
4642    fn test_handle_for_loop() {
4643        // Create a protocol with a for loop
4644        let mut fragments = HashMap::new();
4645        fragments.insert(
4646            "index.html".to_string(),
4647            FragmentList {
4648                fragments: vec![
4649                    WebUIFragment::raw("People: "),
4650                    WebUIFragment::for_loop("person", "people", "person-item"),
4651                ],
4652                contains_boundary: false,
4653            },
4654        );
4655
4656        fragments.insert(
4657            "person-item".to_string(),
4658            FragmentList {
4659                fragments: vec![
4660                    WebUIFragment::signal("person.name", false),
4661                    WebUIFragment::raw(", "),
4662                ],
4663                contains_boundary: false,
4664            },
4665        );
4666
4667        let protocol = WebUIProtocol::new(fragments);
4668        let state = test_json!({
4669            "people": [
4670                {"name": "Alice"},
4671                {"name": "Bob"},
4672                {"name": "Charlie"}
4673            ]
4674        });
4675
4676        // Create a test writer
4677        let mut writer = TestWriter::new();
4678
4679        // Handle the protocol
4680        assert!(
4681            handle(
4682                &protocol,
4683                &state,
4684                &RenderOptions::new("index.html", "/"),
4685                &mut writer
4686            )
4687            .is_ok(),
4688            "Failed to handle for loop protocol"
4689        );
4690
4691        // Check the output
4692        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4693        assert!(writer.is_ended());
4694    }
4695
4696    #[test]
4697    fn test_handle_if_condition() {
4698        // Create a protocol with an if condition
4699        let mut fragments = HashMap::new();
4700        fragments.insert(
4701            "index.html".to_string(),
4702            FragmentList {
4703                fragments: vec![
4704                    WebUIFragment::raw("Status: "),
4705                    WebUIFragment::if_cond(
4706                        webui_protocol::ConditionExpr::identifier("isActive"),
4707                        "active-content",
4708                    ),
4709                    WebUIFragment::raw("End"),
4710                ],
4711                contains_boundary: false,
4712            },
4713        );
4714
4715        fragments.insert(
4716            "active-content".to_string(),
4717            FragmentList {
4718                fragments: vec![WebUIFragment::raw("Active")],
4719                contains_boundary: false,
4720            },
4721        );
4722
4723        let protocol = WebUIProtocol::new(fragments);
4724
4725        // Test with isActive = true
4726        let state_true = test_json!({"isActive": true});
4727        let mut writer_true = TestWriter::new();
4728        assert!(
4729            handle(
4730                &protocol,
4731                &state_true,
4732                &RenderOptions::new("index.html", "/"),
4733                &mut writer_true
4734            )
4735            .is_ok(),
4736            "Failed to handle if condition (true case)"
4737        );
4738        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4739        assert!(writer_true.is_ended());
4740
4741        // Test with isActive = false
4742        let state_false = test_json!({"isActive": false});
4743        let mut writer_false = TestWriter::new();
4744        assert!(
4745            handle(
4746                &protocol,
4747                &state_false,
4748                &RenderOptions::new("index.html", "/"),
4749                &mut writer_false
4750            )
4751            .is_ok(),
4752            "Failed to handle if condition (false case)"
4753        );
4754        assert_eq!(writer_false.get_content(), "Status: End");
4755        assert!(writer_false.is_ended());
4756    }
4757
4758    #[test]
4759    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4760        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>"#;
4761        let mut parser = HtmlParser::new();
4762        parser
4763            .parse("index.html", source)
4764            .expect("parse missing-path condition fixture");
4765        let protocol = WebUIProtocol::new(parser.into_fragment_records());
4766        let state = test_json!({
4767            "items": [
4768                {"label": "Normal"},
4769                {"label": "Search", "searchPresentation": true}
4770            ]
4771        });
4772        let mut writer = TestWriter::new();
4773
4774        handle(
4775            &protocol,
4776            &state,
4777            &RenderOptions::new("index.html", "/"),
4778            &mut writer,
4779        )
4780        .expect("render missing-path condition fixture");
4781
4782        assert_eq!(
4783            writer.get_content(),
4784            "top-negated<span>Normal</span><mark>Search</mark>"
4785        );
4786    }
4787
4788    #[test]
4789    fn native_dynamic_attribute_does_not_leak_into_next_component() {
4790        let mut fragments = HashMap::new();
4791        fragments.insert(
4792            "index.html".to_string(),
4793            FragmentList {
4794                fragments: vec![
4795                    WebUIFragment::raw("<div"),
4796                    WebUIFragment {
4797                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4798                            WebUIFragmentAttribute {
4799                                name: "title".into(),
4800                                value: "nativeTitle".into(),
4801                                ..Default::default()
4802                            },
4803                        )),
4804                    },
4805                    WebUIFragment::raw("></div><my-comp>"),
4806                    WebUIFragment::component("my-comp"),
4807                    WebUIFragment::raw("</my-comp>"),
4808                ],
4809                contains_boundary: false,
4810            },
4811        );
4812        fragments.insert(
4813            "my-comp".to_string(),
4814            FragmentList {
4815                fragments: vec![WebUIFragment::signal("title", false)],
4816                contains_boundary: false,
4817            },
4818        );
4819        let protocol = WebUIProtocol::new(fragments);
4820        let state = test_json!({
4821            "nativeTitle": "native",
4822            "title": "global"
4823        });
4824        let mut writer = TestWriter::new();
4825        handle(
4826            &protocol,
4827            &state,
4828            &RenderOptions::new("index.html", "/"),
4829            &mut writer,
4830        )
4831        .unwrap_or_else(|error| panic!("render failed: {error}"));
4832        assert_eq!(
4833            writer.get_content(),
4834            "<div title=\"native\"></div><my-comp>global</my-comp>"
4835        );
4836    }
4837
4838    #[test]
4839    fn test_handle_component() {
4840        // Create a protocol with a component
4841        let mut fragments = HashMap::new();
4842        fragments.insert(
4843            "index.html".to_string(),
4844            FragmentList {
4845                fragments: vec![
4846                    WebUIFragment::raw("Component: "),
4847                    WebUIFragment::component("my-component"),
4848                ],
4849                contains_boundary: false,
4850            },
4851        );
4852
4853        fragments.insert(
4854            "my-component".to_string(),
4855            FragmentList {
4856                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
4857                contains_boundary: false,
4858            },
4859        );
4860
4861        let protocol = WebUIProtocol::new(fragments);
4862        let state = test_json!({});
4863
4864        // Create a test writer
4865        let mut writer = TestWriter::new();
4866
4867        // Handle the protocol
4868        assert!(
4869            handle(
4870                &protocol,
4871                &state,
4872                &RenderOptions::new("index.html", "/"),
4873                &mut writer
4874            )
4875            .is_ok(),
4876            "Failed to handle component protocol"
4877        );
4878
4879        // Check the output
4880        assert_eq!(
4881            writer.get_content(),
4882            "Component: <div>Component Content</div>"
4883        );
4884        assert!(writer.is_ended());
4885    }
4886
4887    #[test]
4888    fn test_missing_fragment() {
4889        // Create a protocol with a missing fragment reference
4890        let mut fragments = HashMap::new();
4891        fragments.insert(
4892            "index.html".to_string(),
4893            FragmentList {
4894                fragments: vec![WebUIFragment::component("missing-component")],
4895                contains_boundary: false,
4896            },
4897        );
4898
4899        let protocol = WebUIProtocol::new(fragments);
4900        let state = test_json!({});
4901
4902        // Create a test writer
4903        let mut writer = TestWriter::new();
4904
4905        // Handle the protocol
4906        let result = handle(
4907            &protocol,
4908            &state,
4909            &RenderOptions::new("index.html", "/"),
4910            &mut writer,
4911        );
4912
4913        // Expect an error
4914        assert!(result.is_err());
4915        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4916            assert_eq!(fragment_id, "missing-component");
4917        } else {
4918            panic!("Expected MissingFragment error");
4919        }
4920    }
4921
4922    #[test]
4923    fn test_missing_signal_renders_empty() {
4924        // A signal referencing a field absent from state should render as empty
4925        let mut fragments = HashMap::new();
4926        fragments.insert(
4927            "index.html".to_string(),
4928            FragmentList {
4929                fragments: vec![
4930                    WebUIFragment::raw("Hello, "),
4931                    WebUIFragment::signal("missing_field", false),
4932                    WebUIFragment::raw("!"),
4933                ],
4934                contains_boundary: false,
4935            },
4936        );
4937
4938        let protocol = WebUIProtocol::new(fragments);
4939        let state = test_json!({});
4940
4941        let mut writer = TestWriter::new();
4942
4943        assert!(
4944            handle(
4945                &protocol,
4946                &state,
4947                &RenderOptions::new("index.html", "/"),
4948                &mut writer
4949            )
4950            .is_ok(),
4951            "Missing signal should not produce an error"
4952        );
4953
4954        assert_eq!(writer.get_content(), "Hello, !");
4955        assert!(writer.is_ended());
4956    }
4957
4958    // ── Boolean attribute rendering tests ─────────────────────────────
4959
4960    #[test]
4961    fn test_boolean_attr_true() {
4962        let mut fragments = HashMap::new();
4963        fragments.insert(
4964            "index.html".to_string(),
4965            FragmentList {
4966                fragments: vec![
4967                    WebUIFragment::raw("<button"),
4968                    WebUIFragment::attribute_boolean(
4969                        "disabled",
4970                        ConditionExpr::identifier("isDisabled"),
4971                    ),
4972                    WebUIFragment::raw(">Click</button>"),
4973                ],
4974                contains_boundary: false,
4975            },
4976        );
4977        let protocol = WebUIProtocol::new(fragments);
4978        let state = test_json!({"isDisabled": true});
4979        let mut writer = TestWriter::new();
4980        handle(
4981            &protocol,
4982            &state,
4983            &RenderOptions::new("index.html", "/"),
4984            &mut writer,
4985        )
4986        .unwrap();
4987        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
4988    }
4989
4990    #[test]
4991    fn test_boolean_attr_false() {
4992        let mut fragments = HashMap::new();
4993        fragments.insert(
4994            "index.html".to_string(),
4995            FragmentList {
4996                fragments: vec![
4997                    WebUIFragment::raw("<button"),
4998                    WebUIFragment::attribute_boolean(
4999                        "disabled",
5000                        ConditionExpr::identifier("isDisabled"),
5001                    ),
5002                    WebUIFragment::raw(">Click</button>"),
5003                ],
5004                contains_boundary: false,
5005            },
5006        );
5007        let protocol = WebUIProtocol::new(fragments);
5008        let state = test_json!({"isDisabled": false});
5009        let mut writer = TestWriter::new();
5010        handle(
5011            &protocol,
5012            &state,
5013            &RenderOptions::new("index.html", "/"),
5014            &mut writer,
5015        )
5016        .unwrap();
5017        assert_eq!(writer.get_content(), "<button>Click</button>");
5018    }
5019
5020    #[test]
5021    fn test_boolean_attr_missing() {
5022        let mut fragments = HashMap::new();
5023        fragments.insert(
5024            "index.html".to_string(),
5025            FragmentList {
5026                fragments: vec![
5027                    WebUIFragment::raw("<input type=\"checkbox\""),
5028                    WebUIFragment::attribute_boolean(
5029                        "checked",
5030                        ConditionExpr::identifier("checked"),
5031                    ),
5032                    WebUIFragment::raw(">"),
5033                ],
5034                contains_boundary: false,
5035            },
5036        );
5037        let protocol = WebUIProtocol::new(fragments);
5038        let state = test_json!({});
5039        let mut writer = TestWriter::new();
5040        handle(
5041            &protocol,
5042            &state,
5043            &RenderOptions::new("index.html", "/"),
5044            &mut writer,
5045        )
5046        .unwrap();
5047        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5048    }
5049
5050    #[test]
5051    fn test_boolean_attr_multiple() {
5052        let mut fragments = HashMap::new();
5053        fragments.insert(
5054            "index.html".to_string(),
5055            FragmentList {
5056                fragments: vec![
5057                    WebUIFragment::raw("<input type=\"checkbox\""),
5058                    WebUIFragment::attribute_boolean(
5059                        "checked",
5060                        ConditionExpr::identifier("checked"),
5061                    ),
5062                    WebUIFragment::attribute_boolean(
5063                        "disabled",
5064                        ConditionExpr::identifier("disabled"),
5065                    ),
5066                    WebUIFragment::raw(">"),
5067                ],
5068                contains_boundary: false,
5069            },
5070        );
5071        let protocol = WebUIProtocol::new(fragments);
5072        let state = test_json!({"checked": true, "disabled": false});
5073        let mut writer = TestWriter::new();
5074        handle(
5075            &protocol,
5076            &state,
5077            &RenderOptions::new("index.html", "/"),
5078            &mut writer,
5079        )
5080        .unwrap();
5081        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5082    }
5083
5084    // ── Simple attribute rendering tests ──────────────────────────────
5085
5086    #[test]
5087    fn test_attribute_with_value() {
5088        let mut fragments = HashMap::new();
5089        fragments.insert(
5090            "index.html".to_string(),
5091            FragmentList {
5092                fragments: vec![
5093                    WebUIFragment::raw("<input"),
5094                    WebUIFragment::attribute("value", "inputValue"),
5095                    WebUIFragment::raw(">"),
5096                ],
5097                contains_boundary: false,
5098            },
5099        );
5100        let protocol = WebUIProtocol::new(fragments);
5101        let state = test_json!({"inputValue": "Hello"});
5102        let mut writer = TestWriter::new();
5103        handle(
5104            &protocol,
5105            &state,
5106            &RenderOptions::new("index.html", "/"),
5107            &mut writer,
5108        )
5109        .unwrap();
5110        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5111    }
5112
5113    #[test]
5114    fn test_attribute_with_falsy_numeric() {
5115        let mut fragments = HashMap::new();
5116        fragments.insert(
5117            "index.html".to_string(),
5118            FragmentList {
5119                fragments: vec![
5120                    WebUIFragment::raw("<div name=\"test\""),
5121                    WebUIFragment::attribute("handle", "number"),
5122                    WebUIFragment::raw("></div>"),
5123                ],
5124                contains_boundary: false,
5125            },
5126        );
5127        let protocol = WebUIProtocol::new(fragments);
5128        let state = test_json!({"number": 0});
5129        let mut writer = TestWriter::new();
5130        handle(
5131            &protocol,
5132            &state,
5133            &RenderOptions::new("index.html", "/"),
5134            &mut writer,
5135        )
5136        .unwrap();
5137        assert_eq!(
5138            writer.get_content(),
5139            "<div name=\"test\" handle=\"0\"></div>"
5140        );
5141    }
5142
5143    // ── Dynamic attribute escaping for non-string JSON types ─────────
5144
5145    #[test]
5146    fn test_attribute_array_value_is_escaped() {
5147        let mut fragments = HashMap::new();
5148        fragments.insert(
5149            "index.html".to_string(),
5150            FragmentList {
5151                fragments: vec![
5152                    WebUIFragment::raw("<a"),
5153                    WebUIFragment::attribute("href", "value"),
5154                    WebUIFragment::raw(">demo</a>"),
5155                ],
5156                contains_boundary: false,
5157            },
5158        );
5159        let protocol = WebUIProtocol::new(fragments);
5160        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5161        let mut writer = TestWriter::new();
5162        handle(
5163            &protocol,
5164            &state,
5165            &RenderOptions::new("index.html", "/"),
5166            &mut writer,
5167        )
5168        .unwrap();
5169        let content = writer.get_content();
5170        // All inner double quotes must be entity-escaped so that the
5171        // browser never sees a second attribute boundary.
5172        assert!(
5173            content.contains("&quot;"),
5174            "Double quotes inside attribute value must be escaped: {content}"
5175        );
5176        // The href attribute value must be a single contiguous quoted
5177        // string — no extra attributes should appear.
5178        assert_eq!(
5179            content.matches("=\"").count(),
5180            1,
5181            "Only one attribute assignment expected: {content}"
5182        );
5183    }
5184
5185    #[test]
5186    fn test_attribute_object_value_is_escaped() {
5187        let mut fragments = HashMap::new();
5188        fragments.insert(
5189            "index.html".to_string(),
5190            FragmentList {
5191                fragments: vec![
5192                    WebUIFragment::raw("<div"),
5193                    WebUIFragment::attribute("data-cfg", "cfg"),
5194                    WebUIFragment::raw("></div>"),
5195                ],
5196                contains_boundary: false,
5197            },
5198        );
5199        let protocol = WebUIProtocol::new(fragments);
5200        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5201        let mut writer = TestWriter::new();
5202        handle(
5203            &protocol,
5204            &state,
5205            &RenderOptions::new("index.html", "/"),
5206            &mut writer,
5207        )
5208        .unwrap();
5209        let content = writer.get_content();
5210        assert!(
5211            content.contains("&quot;"),
5212            "Double quotes inside attribute value must be escaped: {content}"
5213        );
5214        assert_eq!(
5215            content.matches("=\"").count(),
5216            1,
5217            "Only one attribute assignment expected: {content}"
5218        );
5219    }
5220
5221    // ── Template attribute rendering tests ────────────────────────────
5222
5223    #[test]
5224    fn test_mixed_attribute_template() {
5225        let mut fragments = HashMap::new();
5226        fragments.insert(
5227            "index.html".to_string(),
5228            FragmentList {
5229                fragments: vec![
5230                    WebUIFragment::raw("<input"),
5231                    WebUIFragment::attribute_template("value", "attr-1"),
5232                    WebUIFragment::raw(">"),
5233                ],
5234                contains_boundary: false,
5235            },
5236        );
5237        fragments.insert(
5238            "attr-1".to_string(),
5239            FragmentList {
5240                fragments: vec![
5241                    WebUIFragment::raw("hello "),
5242                    WebUIFragment::signal("item", false),
5243                ],
5244                contains_boundary: false,
5245            },
5246        );
5247        let protocol = WebUIProtocol::new(fragments);
5248        let state = test_json!({"item": "world"});
5249        let mut writer = TestWriter::new();
5250        handle(
5251            &protocol,
5252            &state,
5253            &RenderOptions::new("index.html", "/"),
5254            &mut writer,
5255        )
5256        .unwrap();
5257        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5258    }
5259
5260    // ── Raw signal rendering test ─────────────────────────────────────
5261
5262    #[test]
5263    fn test_raw_signal_not_escaped() {
5264        let mut fragments = HashMap::new();
5265        fragments.insert(
5266            "index.html".to_string(),
5267            FragmentList {
5268                fragments: vec![
5269                    WebUIFragment::signal("html", false),
5270                    WebUIFragment::signal("html", true),
5271                ],
5272                contains_boundary: false,
5273            },
5274        );
5275        let protocol = WebUIProtocol::new(fragments);
5276        let state = test_json!({"html": "<strong>hi</strong>"});
5277        let mut writer = TestWriter::new();
5278        handle(
5279            &protocol,
5280            &state,
5281            &RenderOptions::new("index.html", "/"),
5282            &mut writer,
5283        )
5284        .unwrap();
5285        assert_eq!(
5286            writer.get_content(),
5287            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5288        );
5289    }
5290
5291    // ── Nested for loop tests ─────────────────────────────────────────
5292
5293    #[test]
5294    fn test_nested_for_loop() {
5295        let mut fragments = HashMap::new();
5296        fragments.insert(
5297            "index.html".to_string(),
5298            FragmentList {
5299                fragments: vec![
5300                    WebUIFragment::raw("<div>"),
5301                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5302                    WebUIFragment::raw("</div>"),
5303                ],
5304                contains_boundary: false,
5305            },
5306        );
5307        fragments.insert(
5308            "outer".to_string(),
5309            FragmentList {
5310                fragments: vec![
5311                    WebUIFragment::raw("<div>"),
5312                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5313                    WebUIFragment::raw("</div>"),
5314                ],
5315                contains_boundary: false,
5316            },
5317        );
5318        fragments.insert(
5319            "inner".to_string(),
5320            FragmentList {
5321                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5322                contains_boundary: false,
5323            },
5324        );
5325        let protocol = WebUIProtocol::new(fragments);
5326        let state = test_json!({
5327            "outerItems": [
5328                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5329                {"innerItems": [{"name": "C"}]}
5330            ]
5331        });
5332        let mut writer = TestWriter::new();
5333        handle(
5334            &protocol,
5335            &state,
5336            &RenderOptions::new("index.html", "/"),
5337            &mut writer,
5338        )
5339        .unwrap();
5340        assert_eq!(
5341            writer.get_content(),
5342            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5343        );
5344    }
5345
5346    #[test]
5347    fn test_nested_for_with_signals() {
5348        let mut fragments = HashMap::new();
5349        fragments.insert(
5350            "index.html".to_string(),
5351            FragmentList {
5352                fragments: vec![
5353                    WebUIFragment::raw("<div>"),
5354                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5355                    WebUIFragment::raw("</div>"),
5356                ],
5357                contains_boundary: false,
5358            },
5359        );
5360        fragments.insert(
5361            "outerTemplate".to_string(),
5362            FragmentList {
5363                fragments: vec![
5364                    WebUIFragment::raw("<div>"),
5365                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5366                    WebUIFragment::raw("</div>"),
5367                ],
5368                contains_boundary: false,
5369            },
5370        );
5371        fragments.insert(
5372            "innerTemplate".to_string(),
5373            FragmentList {
5374                fragments: vec![
5375                    WebUIFragment::raw("<span>"),
5376                    WebUIFragment::signal("innerItem.name", false),
5377                    WebUIFragment::raw("</span>"),
5378                ],
5379                contains_boundary: false,
5380            },
5381        );
5382        let protocol = WebUIProtocol::new(fragments);
5383        let state = test_json!({
5384            "outerItems": [
5385                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5386                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5387            ]
5388        });
5389        let mut writer = TestWriter::new();
5390        handle(
5391            &protocol,
5392            &state,
5393            &RenderOptions::new("index.html", "/"),
5394            &mut writer,
5395        )
5396        .unwrap();
5397        assert_eq!(
5398            writer.get_content(),
5399            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5400        );
5401    }
5402
5403    #[test]
5404    fn test_nested_for_with_global_state() {
5405        let mut fragments = HashMap::new();
5406        fragments.insert(
5407            "index.html".to_string(),
5408            FragmentList {
5409                fragments: vec![
5410                    WebUIFragment::raw("<div>"),
5411                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5412                    WebUIFragment::raw("</div>"),
5413                ],
5414                contains_boundary: false,
5415            },
5416        );
5417        fragments.insert(
5418            "outerTemplate".to_string(),
5419            FragmentList {
5420                fragments: vec![
5421                    WebUIFragment::raw("<div>"),
5422                    WebUIFragment::signal("globalOuter", false),
5423                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5424                    WebUIFragment::raw("</div>"),
5425                ],
5426                contains_boundary: false,
5427            },
5428        );
5429        fragments.insert(
5430            "innerTemplate".to_string(),
5431            FragmentList {
5432                fragments: vec![
5433                    WebUIFragment::raw("<span>"),
5434                    WebUIFragment::signal("innerItem.name", false),
5435                    WebUIFragment::signal("globalInner", false),
5436                    WebUIFragment::raw("</span>"),
5437                ],
5438                contains_boundary: false,
5439            },
5440        );
5441        let protocol = WebUIProtocol::new(fragments);
5442        let state = test_json!({
5443            "globalOuter": "GO",
5444            "globalInner": "GI",
5445            "outerItems": [
5446                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5447                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5448            ]
5449        });
5450        let mut writer = TestWriter::new();
5451        handle(
5452            &protocol,
5453            &state,
5454            &RenderOptions::new("index.html", "/"),
5455            &mut writer,
5456        )
5457        .unwrap();
5458        assert_eq!(
5459            writer.get_content(),
5460            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5461        );
5462    }
5463
5464    // ── For + If state scoping tests ──────────────────────────────────
5465
5466    #[test]
5467    fn test_if_in_for_uses_local_state() {
5468        let mut fragments = HashMap::new();
5469        fragments.insert(
5470            "index.html".to_string(),
5471            FragmentList {
5472                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5473                contains_boundary: false,
5474            },
5475        );
5476        fragments.insert(
5477            "item-tpl".to_string(),
5478            FragmentList {
5479                fragments: vec![WebUIFragment::if_cond(
5480                    ConditionExpr::identifier("item.visible"),
5481                    "visible-tpl",
5482                )],
5483                contains_boundary: false,
5484            },
5485        );
5486        fragments.insert(
5487            "visible-tpl".to_string(),
5488            FragmentList {
5489                fragments: vec![WebUIFragment::signal("item.name", false)],
5490                contains_boundary: false,
5491            },
5492        );
5493        let protocol = WebUIProtocol::new(fragments);
5494        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5495        let mut writer = TestWriter::new();
5496        handle(
5497            &protocol,
5498            &state,
5499            &RenderOptions::new("index.html", "/"),
5500            &mut writer,
5501        )
5502        .unwrap();
5503        assert_eq!(writer.get_content(), "Show");
5504    }
5505
5506    #[test]
5507    fn test_for_if_local_overrides_global() {
5508        let mut fragments = HashMap::new();
5509        fragments.insert(
5510            "index.html".to_string(),
5511            FragmentList {
5512                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5513                contains_boundary: false,
5514            },
5515        );
5516        fragments.insert(
5517            "item-tpl".to_string(),
5518            FragmentList {
5519                fragments: vec![WebUIFragment::if_cond(
5520                    ConditionExpr::identifier("item.flag"),
5521                    "show-tpl",
5522                )],
5523                contains_boundary: false,
5524            },
5525        );
5526        fragments.insert(
5527            "show-tpl".to_string(),
5528            FragmentList {
5529                fragments: vec![WebUIFragment::raw("yes")],
5530                contains_boundary: false,
5531            },
5532        );
5533        let protocol = WebUIProtocol::new(fragments);
5534        // Global flag is true, but local item.flag is false for second item
5535        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5536        let mut writer = TestWriter::new();
5537        handle(
5538            &protocol,
5539            &state,
5540            &RenderOptions::new("index.html", "/"),
5541            &mut writer,
5542        )
5543        .unwrap();
5544        assert_eq!(writer.get_content(), "yes");
5545    }
5546
5547    // ── Component attribute state tests ───────────────────────────────
5548
5549    #[test]
5550    fn test_component_attr_state_simple() {
5551        let mut fragments = HashMap::new();
5552        fragments.insert(
5553            "index.html".to_string(),
5554            FragmentList {
5555                fragments: vec![
5556                    WebUIFragment::raw("<my-comp"),
5557                    WebUIFragment {
5558                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5559                            WebUIFragmentAttribute {
5560                                name: "title".into(),
5561                                value: "Attribute Title".into(),
5562                                attr_start: true,
5563                                raw_value: true,
5564                                ..Default::default()
5565                            },
5566                        )),
5567                    },
5568                    WebUIFragment::raw(">"),
5569                    WebUIFragment::component("my-comp"),
5570                    WebUIFragment::raw("</my-comp>"),
5571                ],
5572
5573                contains_boundary: false,
5574            },
5575        );
5576        fragments.insert(
5577            "my-comp".to_string(),
5578            FragmentList {
5579                fragments: vec![
5580                    WebUIFragment::raw("<span>"),
5581                    WebUIFragment::signal("title", false),
5582                    WebUIFragment::raw("</span>"),
5583                ],
5584                contains_boundary: false,
5585            },
5586        );
5587        let protocol = WebUIProtocol::new(fragments);
5588        let state = test_json!({"title": "Global Title"});
5589        let mut writer = TestWriter::new();
5590        handle(
5591            &protocol,
5592            &state,
5593            &RenderOptions::new("index.html", "/"),
5594            &mut writer,
5595        )
5596        .unwrap();
5597        assert_eq!(
5598            writer.get_content(),
5599            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5600        );
5601    }
5602
5603    #[test]
5604    fn test_component_attr_state_template() {
5605        let mut fragments = HashMap::new();
5606        fragments.insert(
5607            "index.html".to_string(),
5608            FragmentList {
5609                fragments: vec![
5610                    WebUIFragment::raw("<my-comp"),
5611                    WebUIFragment {
5612                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5613                            WebUIFragmentAttribute {
5614                                name: "title".into(),
5615                                template: "title-attr".into(),
5616                                attr_start: true,
5617                                ..Default::default()
5618                            },
5619                        )),
5620                    },
5621                    WebUIFragment::raw(">"),
5622                    WebUIFragment::component("my-comp"),
5623                    WebUIFragment::raw("</my-comp>"),
5624                ],
5625
5626                contains_boundary: false,
5627            },
5628        );
5629        fragments.insert(
5630            "title-attr".to_string(),
5631            FragmentList {
5632                fragments: vec![
5633                    WebUIFragment::raw("hello "),
5634                    WebUIFragment::signal("item", false),
5635                ],
5636                contains_boundary: false,
5637            },
5638        );
5639        fragments.insert(
5640            "my-comp".to_string(),
5641            FragmentList {
5642                fragments: vec![
5643                    WebUIFragment::raw("<span>"),
5644                    WebUIFragment::signal("title", false),
5645                    WebUIFragment::raw("</span>"),
5646                ],
5647                contains_boundary: false,
5648            },
5649        );
5650        let protocol = WebUIProtocol::new(fragments);
5651        let state = test_json!({"item": "<world>"});
5652        let mut writer = TestWriter::new();
5653        handle(
5654            &protocol,
5655            &state,
5656            &RenderOptions::new("index.html", "/"),
5657            &mut writer,
5658        )
5659        .unwrap();
5660        assert_eq!(
5661            writer.get_content(),
5662            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
5663        );
5664    }
5665
5666    #[test]
5667    fn test_component_attr_camel_case() {
5668        let mut fragments = HashMap::new();
5669        fragments.insert(
5670            "index.html".to_string(),
5671            FragmentList {
5672                fragments: vec![
5673                    WebUIFragment::raw("<my-comp"),
5674                    WebUIFragment {
5675                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5676                            WebUIFragmentAttribute {
5677                                name: "data-title".into(),
5678                                template: "dt-attr".into(),
5679                                attr_start: true,
5680                                ..Default::default()
5681                            },
5682                        )),
5683                    },
5684                    WebUIFragment::raw(">"),
5685                    WebUIFragment::component("my-comp"),
5686                    WebUIFragment::raw("</my-comp>"),
5687                ],
5688
5689                contains_boundary: false,
5690            },
5691        );
5692        fragments.insert(
5693            "dt-attr".to_string(),
5694            FragmentList {
5695                fragments: vec![
5696                    WebUIFragment::raw("prefix "),
5697                    WebUIFragment::signal("item", false),
5698                ],
5699                contains_boundary: false,
5700            },
5701        );
5702        fragments.insert(
5703            "my-comp".to_string(),
5704            FragmentList {
5705                fragments: vec![
5706                    WebUIFragment::raw("<span>"),
5707                    WebUIFragment::signal("dataTitle", false),
5708                    WebUIFragment::raw("</span>"),
5709                ],
5710                contains_boundary: false,
5711            },
5712        );
5713        let protocol = WebUIProtocol::new(fragments);
5714        let state = test_json!({"item": "a&b"});
5715        let mut writer = TestWriter::new();
5716        handle(
5717            &protocol,
5718            &state,
5719            &RenderOptions::new("index.html", "/"),
5720            &mut writer,
5721        )
5722        .unwrap();
5723        assert_eq!(
5724            writer.get_content(),
5725            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
5726        );
5727    }
5728
5729    #[test]
5730    fn test_component_complex_attr() {
5731        let mut fragments = HashMap::new();
5732        fragments.insert(
5733            "index.html".to_string(),
5734            FragmentList {
5735                fragments: vec![
5736                    WebUIFragment::raw("<my-comp"),
5737                    WebUIFragment {
5738                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5739                            WebUIFragmentAttribute {
5740                                name: ":item".into(),
5741                                value: "complexItem".into(),
5742                                attr_start: true,
5743                                complex: true,
5744                                ..Default::default()
5745                            },
5746                        )),
5747                    },
5748                    WebUIFragment::raw(">"),
5749                    WebUIFragment::component("my-comp"),
5750                    WebUIFragment::raw("</my-comp>"),
5751                ],
5752
5753                contains_boundary: false,
5754            },
5755        );
5756        fragments.insert(
5757            "my-comp".to_string(),
5758            FragmentList {
5759                fragments: vec![
5760                    WebUIFragment::raw("<span>"),
5761                    WebUIFragment::signal("item.foo", false),
5762                    WebUIFragment::raw("</span><p>"),
5763                    WebUIFragment::signal("item.bar", false),
5764                    WebUIFragment::raw("</p>"),
5765                ],
5766                contains_boundary: false,
5767            },
5768        );
5769        let protocol = WebUIProtocol::new(fragments);
5770        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5771        let mut writer = TestWriter::new();
5772        handle(
5773            &protocol,
5774            &state,
5775            &RenderOptions::new("index.html", "/"),
5776            &mut writer,
5777        )
5778        .unwrap();
5779        assert_eq!(
5780            writer.get_content(),
5781            "<my-comp><span>1</span><p>true</p></my-comp>"
5782        );
5783    }
5784
5785    #[test]
5786    fn test_component_no_parent_pollution() {
5787        let mut fragments = HashMap::new();
5788        fragments.insert(
5789            "index.html".to_string(),
5790            FragmentList {
5791                fragments: vec![
5792                    WebUIFragment::raw("<parent"),
5793                    WebUIFragment {
5794                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5795                            WebUIFragmentAttribute {
5796                                name: "var".into(),
5797                                value: "var".into(),
5798                                attr_start: true,
5799                                ..Default::default()
5800                            },
5801                        )),
5802                    },
5803                    WebUIFragment::raw(">"),
5804                    WebUIFragment::component("parent"),
5805                    WebUIFragment::raw("</parent>"),
5806                ],
5807
5808                contains_boundary: false,
5809            },
5810        );
5811        fragments.insert(
5812            "parent".to_string(),
5813            FragmentList {
5814                fragments: vec![
5815                    WebUIFragment::raw("Before: "),
5816                    WebUIFragment::signal("var", false),
5817                    WebUIFragment::raw("<child foo"),
5818                    WebUIFragment {
5819                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5820                            WebUIFragmentAttribute {
5821                                name: "var".into(),
5822                                value: "replaced".into(),
5823                                raw_value: true,
5824                                ..Default::default()
5825                            },
5826                        )),
5827                    },
5828                    WebUIFragment::raw(">"),
5829                    WebUIFragment::component("child"),
5830                    WebUIFragment::raw("Label</child>After: "),
5831                    WebUIFragment::signal("var", false),
5832                ],
5833
5834                contains_boundary: false,
5835            },
5836        );
5837        fragments.insert(
5838            "child".to_string(),
5839            FragmentList {
5840                fragments: vec![],
5841                contains_boundary: false,
5842            },
5843        );
5844        let protocol = WebUIProtocol::new(fragments);
5845        let state = test_json!({"var": "original<&"});
5846        let mut writer = TestWriter::new();
5847        handle(
5848            &protocol,
5849            &state,
5850            &RenderOptions::new("index.html", "/"),
5851            &mut writer,
5852        )
5853        .unwrap();
5854        assert_eq!(
5855            writer.get_content(),
5856            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
5857        );
5858    }
5859
5860    #[test]
5861    fn test_component_boolean_attr_state() {
5862        let mut fragments = HashMap::new();
5863        fragments.insert(
5864            "index.html".to_string(),
5865            FragmentList {
5866                fragments: vec![
5867                    WebUIFragment::raw("<my-comp"),
5868                    WebUIFragment {
5869                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5870                            WebUIFragmentAttribute {
5871                                name: "disabled".into(),
5872                                attr_start: true,
5873                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5874                                ..Default::default()
5875                            },
5876                        )),
5877                    },
5878                    WebUIFragment::raw(">"),
5879                    WebUIFragment::component("my-comp"),
5880                    WebUIFragment::raw("</my-comp>"),
5881                ],
5882
5883                contains_boundary: false,
5884            },
5885        );
5886        fragments.insert(
5887            "my-comp".to_string(),
5888            FragmentList {
5889                fragments: vec![WebUIFragment::if_cond(
5890                    ConditionExpr::identifier("disabled"),
5891                    "show",
5892                )],
5893                contains_boundary: false,
5894            },
5895        );
5896        fragments.insert(
5897            "show".to_string(),
5898            FragmentList {
5899                fragments: vec![WebUIFragment::raw("disabled!")],
5900                contains_boundary: false,
5901            },
5902        );
5903        let protocol = WebUIProtocol::new(fragments);
5904        let state = test_json!({"isDisabled": true});
5905        let mut writer = TestWriter::new();
5906        handle(
5907            &protocol,
5908            &state,
5909            &RenderOptions::new("index.html", "/"),
5910            &mut writer,
5911        )
5912        .unwrap();
5913        assert_eq!(
5914            writer.get_content(),
5915            "<my-comp disabled>disabled!</my-comp>"
5916        );
5917    }
5918
5919    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
5920
5921    /// Helper: render a signal value through the handler and return the escaped output.
5922    fn render_signal(value: &str) -> String {
5923        let mut fragments = HashMap::new();
5924        fragments.insert(
5925            "index.html".to_string(),
5926            FragmentList {
5927                fragments: vec![WebUIFragment::signal("v", false)],
5928                contains_boundary: false,
5929            },
5930        );
5931        let protocol = WebUIProtocol::new(fragments);
5932        let state = test_json!({"v": value});
5933        let mut writer = TestWriter::new();
5934        handle(
5935            &protocol,
5936            &state,
5937            &RenderOptions::new("index.html", "/"),
5938            &mut writer,
5939        )
5940        .unwrap();
5941        writer.get_content()
5942    }
5943
5944    #[test]
5945    fn test_escape_ampersand() {
5946        assert_eq!(render_signal("&"), "&amp;");
5947    }
5948
5949    #[test]
5950    fn test_escape_less_than() {
5951        assert_eq!(render_signal("<"), "&lt;");
5952    }
5953
5954    #[test]
5955    fn test_escape_greater_than() {
5956        assert_eq!(render_signal(">"), "&gt;");
5957    }
5958
5959    #[test]
5960    fn test_escape_double_quote() {
5961        assert_eq!(render_signal("\""), "&quot;");
5962    }
5963
5964    #[test]
5965    fn test_escape_single_quote() {
5966        // encode_safe escapes ' as &#x27;
5967        let result = render_signal("'");
5968        assert_eq!(
5969            result, "&#x27;",
5970            "Expected &#x27; for single quote, got: {result}"
5971        );
5972    }
5973
5974    #[test]
5975    fn test_escape_multiple_special_chars() {
5976        let result = render_signal("<script>alert('xss');</script>");
5977        assert!(
5978            result.contains("&lt;") && result.contains("&gt;"),
5979            "Expected escaped HTML, got: {}",
5980            result
5981        );
5982        assert!(
5983            !result.contains("<script>"),
5984            "Should not contain raw <script> tag"
5985        );
5986    }
5987
5988    #[test]
5989    fn test_escape_no_special_chars() {
5990        assert_eq!(render_signal("Hello World"), "Hello World");
5991    }
5992
5993    #[test]
5994    fn test_escape_empty_string() {
5995        assert_eq!(render_signal(""), "");
5996    }
5997
5998    #[test]
5999    fn test_escape_special_at_beginning() {
6000        let result = render_signal("<Hello");
6001        assert!(
6002            result.starts_with("&lt;"),
6003            "Expected &lt; at start, got: {}",
6004            result
6005        );
6006    }
6007
6008    #[test]
6009    fn test_escape_special_at_end() {
6010        let result = render_signal("Hello>");
6011        assert!(
6012            result.ends_with("&gt;"),
6013            "Expected &gt; at end, got: {}",
6014            result
6015        );
6016    }
6017
6018    #[test]
6019    fn test_escape_special_in_middle() {
6020        let result = render_signal("Hel&lo");
6021        assert!(
6022            result.contains("&amp;"),
6023            "Expected &amp; in middle, got: {}",
6024            result
6025        );
6026    }
6027
6028    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6029
6030    #[test]
6031    fn test_boolean_attr_truthy_values() {
6032        // checked: 1
6033        {
6034            let mut fragments = HashMap::new();
6035            fragments.insert(
6036                "index.html".to_string(),
6037                FragmentList {
6038                    fragments: vec![
6039                        WebUIFragment::raw("<input"),
6040                        WebUIFragment::attribute_boolean(
6041                            "checked",
6042                            ConditionExpr::identifier("checked"),
6043                        ),
6044                        WebUIFragment::raw(">"),
6045                    ],
6046                    contains_boundary: false,
6047                },
6048            );
6049            let protocol = WebUIProtocol::new(fragments);
6050            let state = test_json!({"checked": 1});
6051            let mut writer = TestWriter::new();
6052            handle(
6053                &protocol,
6054                &state,
6055                &RenderOptions::new("index.html", "/"),
6056                &mut writer,
6057            )
6058            .unwrap();
6059            assert_eq!(writer.get_content(), "<input checked>");
6060        }
6061        // checked: "yes"
6062        {
6063            let mut fragments = HashMap::new();
6064            fragments.insert(
6065                "index.html".to_string(),
6066                FragmentList {
6067                    fragments: vec![
6068                        WebUIFragment::raw("<input"),
6069                        WebUIFragment::attribute_boolean(
6070                            "checked",
6071                            ConditionExpr::identifier("checked"),
6072                        ),
6073                        WebUIFragment::raw(">"),
6074                    ],
6075                    contains_boundary: false,
6076                },
6077            );
6078            let protocol = WebUIProtocol::new(fragments);
6079            let state = test_json!({"checked": "yes"});
6080            let mut writer = TestWriter::new();
6081            handle(
6082                &protocol,
6083                &state,
6084                &RenderOptions::new("index.html", "/"),
6085                &mut writer,
6086            )
6087            .unwrap();
6088            assert_eq!(writer.get_content(), "<input checked>");
6089        }
6090        // checked: {} (empty object is truthy)
6091        {
6092            let mut fragments = HashMap::new();
6093            fragments.insert(
6094                "index.html".to_string(),
6095                FragmentList {
6096                    fragments: vec![
6097                        WebUIFragment::raw("<input"),
6098                        WebUIFragment::attribute_boolean(
6099                            "checked",
6100                            ConditionExpr::identifier("checked"),
6101                        ),
6102                        WebUIFragment::raw(">"),
6103                    ],
6104                    contains_boundary: false,
6105                },
6106            );
6107            let protocol = WebUIProtocol::new(fragments);
6108            let state = test_json!({"checked": {}});
6109            let mut writer = TestWriter::new();
6110            handle(
6111                &protocol,
6112                &state,
6113                &RenderOptions::new("index.html", "/"),
6114                &mut writer,
6115            )
6116            .unwrap();
6117            // Empty object is falsy in this expression evaluator
6118            assert_eq!(writer.get_content(), "<input>");
6119        }
6120        // checked: "false" (string "false" is truthy)
6121        {
6122            let mut fragments = HashMap::new();
6123            fragments.insert(
6124                "index.html".to_string(),
6125                FragmentList {
6126                    fragments: vec![
6127                        WebUIFragment::raw("<input"),
6128                        WebUIFragment::attribute_boolean(
6129                            "checked",
6130                            ConditionExpr::identifier("checked"),
6131                        ),
6132                        WebUIFragment::raw(">"),
6133                    ],
6134                    contains_boundary: false,
6135                },
6136            );
6137            let protocol = WebUIProtocol::new(fragments);
6138            let state = test_json!({"checked": "false"});
6139            let mut writer = TestWriter::new();
6140            handle(
6141                &protocol,
6142                &state,
6143                &RenderOptions::new("index.html", "/"),
6144                &mut writer,
6145            )
6146            .unwrap();
6147            assert_eq!(writer.get_content(), "<input checked>");
6148        }
6149    }
6150
6151    #[test]
6152    fn test_boolean_attr_falsy_values() {
6153        // checked: 0
6154        {
6155            let mut fragments = HashMap::new();
6156            fragments.insert(
6157                "index.html".to_string(),
6158                FragmentList {
6159                    fragments: vec![
6160                        WebUIFragment::raw("<input"),
6161                        WebUIFragment::attribute_boolean(
6162                            "checked",
6163                            ConditionExpr::identifier("checked"),
6164                        ),
6165                        WebUIFragment::raw(">"),
6166                    ],
6167                    contains_boundary: false,
6168                },
6169            );
6170            let protocol = WebUIProtocol::new(fragments);
6171            let state = test_json!({"checked": 0});
6172            let mut writer = TestWriter::new();
6173            handle(
6174                &protocol,
6175                &state,
6176                &RenderOptions::new("index.html", "/"),
6177                &mut writer,
6178            )
6179            .unwrap();
6180            assert_eq!(writer.get_content(), "<input>");
6181        }
6182        // checked: ""
6183        {
6184            let mut fragments = HashMap::new();
6185            fragments.insert(
6186                "index.html".to_string(),
6187                FragmentList {
6188                    fragments: vec![
6189                        WebUIFragment::raw("<input"),
6190                        WebUIFragment::attribute_boolean(
6191                            "checked",
6192                            ConditionExpr::identifier("checked"),
6193                        ),
6194                        WebUIFragment::raw(">"),
6195                    ],
6196                    contains_boundary: false,
6197                },
6198            );
6199            let protocol = WebUIProtocol::new(fragments);
6200            let state = test_json!({"checked": ""});
6201            let mut writer = TestWriter::new();
6202            handle(
6203                &protocol,
6204                &state,
6205                &RenderOptions::new("index.html", "/"),
6206                &mut writer,
6207            )
6208            .unwrap();
6209            assert_eq!(writer.get_content(), "<input>");
6210        }
6211        // checked: false
6212        {
6213            let mut fragments = HashMap::new();
6214            fragments.insert(
6215                "index.html".to_string(),
6216                FragmentList {
6217                    fragments: vec![
6218                        WebUIFragment::raw("<input"),
6219                        WebUIFragment::attribute_boolean(
6220                            "checked",
6221                            ConditionExpr::identifier("checked"),
6222                        ),
6223                        WebUIFragment::raw(">"),
6224                    ],
6225                    contains_boundary: false,
6226                },
6227            );
6228            let protocol = WebUIProtocol::new(fragments);
6229            let state = test_json!({"checked": false});
6230            let mut writer = TestWriter::new();
6231            handle(
6232                &protocol,
6233                &state,
6234                &RenderOptions::new("index.html", "/"),
6235                &mut writer,
6236            )
6237            .unwrap();
6238            assert_eq!(writer.get_content(), "<input>");
6239        }
6240        // no checked key at all
6241        {
6242            let mut fragments = HashMap::new();
6243            fragments.insert(
6244                "index.html".to_string(),
6245                FragmentList {
6246                    fragments: vec![
6247                        WebUIFragment::raw("<input"),
6248                        WebUIFragment::attribute_boolean(
6249                            "checked",
6250                            ConditionExpr::identifier("checked"),
6251                        ),
6252                        WebUIFragment::raw(">"),
6253                    ],
6254                    contains_boundary: false,
6255                },
6256            );
6257            let protocol = WebUIProtocol::new(fragments);
6258            let state = test_json!({});
6259            let mut writer = TestWriter::new();
6260            handle(
6261                &protocol,
6262                &state,
6263                &RenderOptions::new("index.html", "/"),
6264                &mut writer,
6265            )
6266            .unwrap();
6267            assert_eq!(writer.get_content(), "<input>");
6268        }
6269    }
6270
6271    #[test]
6272    fn test_boolean_attr_expression_true() {
6273        let mut fragments = HashMap::new();
6274        fragments.insert(
6275            "index.html".to_string(),
6276            FragmentList {
6277                fragments: vec![
6278                    WebUIFragment::raw("<button"),
6279                    WebUIFragment::attribute_boolean(
6280                        "disabled",
6281                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6282                    ),
6283                    WebUIFragment::raw(">Click</button>"),
6284                ],
6285                contains_boundary: false,
6286            },
6287        );
6288        let protocol = WebUIProtocol::new(fragments);
6289        let state = test_json!({"itemCount": 5});
6290        let mut writer = TestWriter::new();
6291        handle(
6292            &protocol,
6293            &state,
6294            &RenderOptions::new("index.html", "/"),
6295            &mut writer,
6296        )
6297        .unwrap();
6298        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6299    }
6300
6301    #[test]
6302    fn test_boolean_attr_expression_false() {
6303        let mut fragments = HashMap::new();
6304        fragments.insert(
6305            "index.html".to_string(),
6306            FragmentList {
6307                fragments: vec![
6308                    WebUIFragment::raw("<button"),
6309                    WebUIFragment::attribute_boolean(
6310                        "disabled",
6311                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6312                    ),
6313                    WebUIFragment::raw(">Click</button>"),
6314                ],
6315                contains_boundary: false,
6316            },
6317        );
6318        let protocol = WebUIProtocol::new(fragments);
6319        let state = test_json!({"itemCount": 3});
6320        let mut writer = TestWriter::new();
6321        handle(
6322            &protocol,
6323            &state,
6324            &RenderOptions::new("index.html", "/"),
6325            &mut writer,
6326        )
6327        .unwrap();
6328        assert_eq!(writer.get_content(), "<button>Click</button>");
6329    }
6330
6331    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6332
6333    #[test]
6334    fn test_nested_component_attr_capture() {
6335        let mut fragments = HashMap::new();
6336        fragments.insert(
6337            "index.html".to_string(),
6338            FragmentList {
6339                fragments: vec![
6340                    WebUIFragment::raw("<parent-component"),
6341                    WebUIFragment {
6342                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6343                            WebUIFragmentAttribute {
6344                                name: "title".into(),
6345                                template: "parent-title".into(),
6346                                attr_start: true,
6347                                ..Default::default()
6348                            },
6349                        )),
6350                    },
6351                    WebUIFragment::raw(">"),
6352                    WebUIFragment::component("parent-component"),
6353                    WebUIFragment::raw("</parent-component>"),
6354                ],
6355
6356                contains_boundary: false,
6357            },
6358        );
6359        fragments.insert(
6360            "parent-title".to_string(),
6361            FragmentList {
6362                fragments: vec![
6363                    WebUIFragment::raw("Hello "),
6364                    WebUIFragment::signal("who", false),
6365                ],
6366                contains_boundary: false,
6367            },
6368        );
6369        fragments.insert(
6370            "parent-component".to_string(),
6371            FragmentList {
6372                fragments: vec![
6373                    WebUIFragment::raw("<child-component"),
6374                    WebUIFragment {
6375                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6376                            WebUIFragmentAttribute {
6377                                name: "title".into(),
6378                                template: "child-title".into(),
6379                                attr_start: true,
6380                                ..Default::default()
6381                            },
6382                        )),
6383                    },
6384                    WebUIFragment::raw(">"),
6385                    WebUIFragment::component("child-component"),
6386                    WebUIFragment::raw("</child-component>"),
6387                ],
6388
6389                contains_boundary: false,
6390            },
6391        );
6392        fragments.insert(
6393            "child-title".to_string(),
6394            FragmentList {
6395                fragments: vec![
6396                    WebUIFragment::raw("Child of "),
6397                    WebUIFragment::signal("title", false),
6398                ],
6399                contains_boundary: false,
6400            },
6401        );
6402        fragments.insert(
6403            "child-component".to_string(),
6404            FragmentList {
6405                fragments: vec![
6406                    WebUIFragment::raw("<span>"),
6407                    WebUIFragment::signal("title", false),
6408                    WebUIFragment::raw("</span>"),
6409                ],
6410                contains_boundary: false,
6411            },
6412        );
6413        let protocol = WebUIProtocol::new(fragments);
6414        let state = test_json!({"who": "<world>"});
6415        let mut writer = TestWriter::new();
6416        handle(
6417            &protocol,
6418            &state,
6419            &RenderOptions::new("index.html", "/"),
6420            &mut writer,
6421        )
6422        .unwrap();
6423        assert_eq!(
6424            writer.get_content(),
6425            "<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>"
6426        );
6427    }
6428
6429    #[test]
6430    fn test_grandchild_attr_propagation() {
6431        let mut fragments = HashMap::new();
6432        fragments.insert(
6433            "index.html".to_string(),
6434            FragmentList {
6435                fragments: vec![
6436                    WebUIFragment::raw("<parent-component"),
6437                    WebUIFragment {
6438                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6439                            WebUIFragmentAttribute {
6440                                name: "title".into(),
6441                                template: "p-title".into(),
6442                                attr_start: true,
6443                                ..Default::default()
6444                            },
6445                        )),
6446                    },
6447                    WebUIFragment::raw(">"),
6448                    WebUIFragment::component("parent-component"),
6449                    WebUIFragment::raw("</parent-component>"),
6450                ],
6451
6452                contains_boundary: false,
6453            },
6454        );
6455        fragments.insert(
6456            "p-title".to_string(),
6457            FragmentList {
6458                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6459                contains_boundary: false,
6460            },
6461        );
6462        fragments.insert(
6463            "parent-component".to_string(),
6464            FragmentList {
6465                fragments: vec![
6466                    WebUIFragment::raw("<child-component"),
6467                    WebUIFragment {
6468                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6469                            WebUIFragmentAttribute {
6470                                name: "title".into(),
6471                                template: "c-title".into(),
6472                                attr_start: true,
6473                                ..Default::default()
6474                            },
6475                        )),
6476                    },
6477                    WebUIFragment::raw(">"),
6478                    WebUIFragment::component("child-component"),
6479                    WebUIFragment::raw("</child-component>"),
6480                ],
6481
6482                contains_boundary: false,
6483            },
6484        );
6485        fragments.insert(
6486            "c-title".to_string(),
6487            FragmentList {
6488                fragments: vec![
6489                    WebUIFragment::raw("C("),
6490                    WebUIFragment::signal("title", false),
6491                    WebUIFragment::raw(")-"),
6492                    WebUIFragment::signal("cExtra", false),
6493                ],
6494                contains_boundary: false,
6495            },
6496        );
6497        fragments.insert(
6498            "child-component".to_string(),
6499            FragmentList {
6500                fragments: vec![
6501                    WebUIFragment::raw("<grandchild-component"),
6502                    WebUIFragment {
6503                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6504                            WebUIFragmentAttribute {
6505                                name: "title".into(),
6506                                value: "title".into(),
6507                                attr_start: true,
6508                                ..Default::default()
6509                            },
6510                        )),
6511                    },
6512                    WebUIFragment::raw(">"),
6513                    WebUIFragment::component("grandchild-component"),
6514                    WebUIFragment::raw("</grandchild-component>"),
6515                ],
6516
6517                contains_boundary: false,
6518            },
6519        );
6520        fragments.insert(
6521            "grandchild-component".to_string(),
6522            FragmentList {
6523                fragments: vec![
6524                    WebUIFragment::raw("<span>"),
6525                    WebUIFragment::signal("title", false),
6526                    WebUIFragment::raw("</span>"),
6527                ],
6528                contains_boundary: false,
6529            },
6530        );
6531        let protocol = WebUIProtocol::new(fragments);
6532        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6533        let mut writer = TestWriter::new();
6534        handle(
6535            &protocol,
6536            &state,
6537            &RenderOptions::new("index.html", "/"),
6538            &mut writer,
6539        )
6540        .unwrap();
6541        assert_eq!(
6542            writer.get_content(),
6543            "<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>"
6544        );
6545    }
6546
6547    #[test]
6548    fn test_for_loop_component_attr() {
6549        let mut fragments = HashMap::new();
6550        fragments.insert(
6551            "index.html".to_string(),
6552            FragmentList {
6553                fragments: vec![
6554                    WebUIFragment::raw("<parent-component"),
6555                    WebUIFragment {
6556                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6557                            WebUIFragmentAttribute {
6558                                name: "title".into(),
6559                                template: "parent-title-loop".into(),
6560                                attr_start: true,
6561                                ..Default::default()
6562                            },
6563                        )),
6564                    },
6565                    WebUIFragment::raw(">"),
6566                    WebUIFragment::component("parent-component"),
6567                    WebUIFragment::raw("</parent-component>"),
6568                ],
6569
6570                contains_boundary: false,
6571            },
6572        );
6573        fragments.insert(
6574            "parent-title-loop".to_string(),
6575            FragmentList {
6576                fragments: vec![
6577                    WebUIFragment::raw("Parent:"),
6578                    WebUIFragment::signal("who", false),
6579                ],
6580                contains_boundary: false,
6581            },
6582        );
6583        fragments.insert(
6584            "parent-component".to_string(),
6585            FragmentList {
6586                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6587                contains_boundary: false,
6588            },
6589        );
6590        fragments.insert(
6591            "child-loop".to_string(),
6592            FragmentList {
6593                fragments: vec![
6594                    WebUIFragment::raw("<child-component"),
6595                    WebUIFragment {
6596                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6597                            WebUIFragmentAttribute {
6598                                name: "title".into(),
6599                                template: "child-title-loop".into(),
6600                                attr_start: true,
6601                                ..Default::default()
6602                            },
6603                        )),
6604                    },
6605                    WebUIFragment::raw(">"),
6606                    WebUIFragment::component("child-component"),
6607                    WebUIFragment::raw("</child-component>"),
6608                ],
6609
6610                contains_boundary: false,
6611            },
6612        );
6613        fragments.insert(
6614            "child-title-loop".to_string(),
6615            FragmentList {
6616                fragments: vec![
6617                    WebUIFragment::raw("Hi "),
6618                    WebUIFragment::signal("item.name", false),
6619                    WebUIFragment::raw(" / "),
6620                    WebUIFragment::signal("title", false),
6621                ],
6622                contains_boundary: false,
6623            },
6624        );
6625        fragments.insert(
6626            "child-component".to_string(),
6627            FragmentList {
6628                fragments: vec![
6629                    WebUIFragment::raw("<span>"),
6630                    WebUIFragment::signal("title", false),
6631                    WebUIFragment::raw("</span>"),
6632                ],
6633                contains_boundary: false,
6634            },
6635        );
6636        let protocol = WebUIProtocol::new(fragments);
6637        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6638        let mut writer = TestWriter::new();
6639        handle(
6640            &protocol,
6641            &state,
6642            &RenderOptions::new("index.html", "/"),
6643            &mut writer,
6644        )
6645        .unwrap();
6646        assert_eq!(
6647            writer.get_content(),
6648            "<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>"
6649        );
6650    }
6651
6652    #[test]
6653    fn test_multiple_template_attrs() {
6654        let mut fragments = HashMap::new();
6655        fragments.insert(
6656            "index.html".to_string(),
6657            FragmentList {
6658                fragments: vec![
6659                    WebUIFragment::raw("<my-component"),
6660                    WebUIFragment {
6661                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6662                            WebUIFragmentAttribute {
6663                                name: "title".into(),
6664                                template: "attr-title".into(),
6665                                attr_start: true,
6666                                ..Default::default()
6667                            },
6668                        )),
6669                    },
6670                    WebUIFragment {
6671                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6672                            WebUIFragmentAttribute {
6673                                name: "data-title".into(),
6674                                template: "attr-data-title".into(),
6675                                attr_start: false,
6676                                ..Default::default()
6677                            },
6678                        )),
6679                    },
6680                    WebUIFragment {
6681                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6682                            WebUIFragmentAttribute {
6683                                name: "aria-label".into(),
6684                                template: "attr-aria-label".into(),
6685                                attr_start: false,
6686                                ..Default::default()
6687                            },
6688                        )),
6689                    },
6690                    WebUIFragment::raw(">"),
6691                    WebUIFragment::component("my-component"),
6692                    WebUIFragment::raw("</my-component>"),
6693                ],
6694
6695                contains_boundary: false,
6696            },
6697        );
6698        fragments.insert(
6699            "attr-title".to_string(),
6700            FragmentList {
6701                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6702                contains_boundary: false,
6703            },
6704        );
6705        fragments.insert(
6706            "attr-data-title".to_string(),
6707            FragmentList {
6708                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6709                contains_boundary: false,
6710            },
6711        );
6712        fragments.insert(
6713            "attr-aria-label".to_string(),
6714            FragmentList {
6715                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6716                contains_boundary: false,
6717            },
6718        );
6719        fragments.insert(
6720            "my-component".to_string(),
6721            FragmentList {
6722                fragments: vec![
6723                    WebUIFragment::raw("<span>"),
6724                    WebUIFragment::signal("title", false),
6725                    WebUIFragment::raw("|"),
6726                    WebUIFragment::signal("dataTitle", false),
6727                    WebUIFragment::raw("|"),
6728                    WebUIFragment::signal("ariaLabel", false),
6729                    WebUIFragment::raw("</span>"),
6730                ],
6731                contains_boundary: false,
6732            },
6733        );
6734        let protocol = WebUIProtocol::new(fragments);
6735        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6736        let mut writer = TestWriter::new();
6737        handle(
6738            &protocol,
6739            &state,
6740            &RenderOptions::new("index.html", "/"),
6741            &mut writer,
6742        )
6743        .unwrap();
6744        assert_eq!(
6745            writer.get_content(),
6746            "<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>"
6747        );
6748    }
6749
6750    #[test]
6751    fn test_attr_priority_over_global() {
6752        let mut fragments = HashMap::new();
6753        fragments.insert(
6754            "index.html".to_string(),
6755            FragmentList {
6756                fragments: vec![
6757                    WebUIFragment::raw("<my-component"),
6758                    WebUIFragment {
6759                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6760                            WebUIFragmentAttribute {
6761                                name: "title".into(),
6762                                value: "Attribute Title".into(),
6763                                raw_value: true,
6764                                attr_start: true,
6765                                ..Default::default()
6766                            },
6767                        )),
6768                    },
6769                    WebUIFragment::raw(">"),
6770                    WebUIFragment::component("my-component"),
6771                    WebUIFragment::raw("</my-component>"),
6772                ],
6773
6774                contains_boundary: false,
6775            },
6776        );
6777        fragments.insert(
6778            "my-component".to_string(),
6779            FragmentList {
6780                fragments: vec![
6781                    WebUIFragment::raw("<span>"),
6782                    WebUIFragment::signal("title", false),
6783                    WebUIFragment::raw("</span>"),
6784                ],
6785                contains_boundary: false,
6786            },
6787        );
6788        let protocol = WebUIProtocol::new(fragments);
6789        let state = test_json!({"title": "Global Title"});
6790        let mut writer = TestWriter::new();
6791        handle(
6792            &protocol,
6793            &state,
6794            &RenderOptions::new("index.html", "/"),
6795            &mut writer,
6796        )
6797        .unwrap();
6798        assert_eq!(
6799            writer.get_content(),
6800            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6801        );
6802    }
6803
6804    #[test]
6805    fn test_attr_priority_over_local_and_global() {
6806        let mut fragments = HashMap::new();
6807        fragments.insert(
6808            "index.html".to_string(),
6809            FragmentList {
6810                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6811                contains_boundary: false,
6812            },
6813        );
6814        fragments.insert(
6815            "loop".to_string(),
6816            FragmentList {
6817                fragments: vec![
6818                    WebUIFragment::raw("<my-component"),
6819                    WebUIFragment {
6820                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6821                            WebUIFragmentAttribute {
6822                                name: "title".into(),
6823                                value: "Attribute Title".into(),
6824                                raw_value: true,
6825                                attr_start: true,
6826                                ..Default::default()
6827                            },
6828                        )),
6829                    },
6830                    WebUIFragment::raw(">"),
6831                    WebUIFragment::component("my-component"),
6832                    WebUIFragment::raw("</my-component>"),
6833                ],
6834
6835                contains_boundary: false,
6836            },
6837        );
6838        fragments.insert(
6839            "my-component".to_string(),
6840            FragmentList {
6841                fragments: vec![
6842                    WebUIFragment::raw("<span>"),
6843                    WebUIFragment::signal("title", false),
6844                    WebUIFragment::raw("</span>"),
6845                ],
6846                contains_boundary: false,
6847            },
6848        );
6849        let protocol = WebUIProtocol::new(fragments);
6850        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6851        let mut writer = TestWriter::new();
6852        handle(
6853            &protocol,
6854            &state,
6855            &RenderOptions::new("index.html", "/"),
6856            &mut writer,
6857        )
6858        .unwrap();
6859        assert_eq!(
6860            writer.get_content(),
6861            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6862        );
6863    }
6864
6865    #[test]
6866    fn test_boolean_attr_first_component_attr() {
6867        let mut fragments = HashMap::new();
6868        fragments.insert(
6869            "index.html".to_string(),
6870            FragmentList {
6871                fragments: vec![
6872                    WebUIFragment::raw("<my-component"),
6873                    WebUIFragment {
6874                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6875                            WebUIFragmentAttribute {
6876                                name: "disabled".into(),
6877                                attr_start: true,
6878                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6879                                ..Default::default()
6880                            },
6881                        )),
6882                    },
6883                    WebUIFragment {
6884                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6885                            WebUIFragmentAttribute {
6886                                name: "label".into(),
6887                                value: "Component Label".into(),
6888                                raw_value: true,
6889                                attr_start: false,
6890                                ..Default::default()
6891                            },
6892                        )),
6893                    },
6894                    WebUIFragment::raw(">"),
6895                    WebUIFragment::component("my-component"),
6896                    WebUIFragment::raw("</my-component>"),
6897                ],
6898
6899                contains_boundary: false,
6900            },
6901        );
6902        fragments.insert(
6903            "my-component".to_string(),
6904            FragmentList {
6905                fragments: vec![
6906                    WebUIFragment::if_cond(
6907                        ConditionExpr::identifier("disabled"),
6908                        "disabledTemplate",
6909                    ),
6910                    WebUIFragment::raw("<span>"),
6911                    WebUIFragment::signal("label", false),
6912                    WebUIFragment::raw("</span>"),
6913                ],
6914                contains_boundary: false,
6915            },
6916        );
6917        fragments.insert(
6918            "disabledTemplate".to_string(),
6919            FragmentList {
6920                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6921                contains_boundary: false,
6922            },
6923        );
6924        let protocol = WebUIProtocol::new(fragments);
6925        let state = test_json!({"isDisabled": true});
6926        let mut writer = TestWriter::new();
6927        handle(
6928            &protocol,
6929            &state,
6930            &RenderOptions::new("index.html", "/"),
6931            &mut writer,
6932        )
6933        .unwrap();
6934        assert_eq!(
6935            writer.get_content(),
6936            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
6937        );
6938    }
6939
6940    #[test]
6941    fn test_hyphenated_attr_camelcase() {
6942        let mut fragments = HashMap::new();
6943        fragments.insert(
6944            "index.html".to_string(),
6945            FragmentList {
6946                fragments: vec![
6947                    WebUIFragment::raw("<my-component"),
6948                    WebUIFragment {
6949                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6950                            WebUIFragmentAttribute {
6951                                name: "key-hyphen".into(),
6952                                value: "Local Value".into(),
6953                                raw_value: true,
6954                                attr_start: true,
6955                                ..Default::default()
6956                            },
6957                        )),
6958                    },
6959                    WebUIFragment::raw(">"),
6960                    WebUIFragment::component("my-component"),
6961                    WebUIFragment::raw("</my-component>"),
6962                ],
6963
6964                contains_boundary: false,
6965            },
6966        );
6967        fragments.insert(
6968            "my-component".to_string(),
6969            FragmentList {
6970                fragments: vec![
6971                    WebUIFragment::raw("<span>"),
6972                    WebUIFragment::signal("keyHyphen", false),
6973                    WebUIFragment::raw("</span>"),
6974                ],
6975                contains_boundary: false,
6976            },
6977        );
6978        let protocol = WebUIProtocol::new(fragments);
6979        let state = test_json!({"keyHyphen": "Global Value"});
6980        let mut writer = TestWriter::new();
6981        handle(
6982            &protocol,
6983            &state,
6984            &RenderOptions::new("index.html", "/"),
6985            &mut writer,
6986        )
6987        .unwrap();
6988        assert_eq!(
6989            writer.get_content(),
6990            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
6991        );
6992    }
6993
6994    #[test]
6995    fn test_skipped_component_attrs() {
6996        // Skipped attributes: class, style, role, data-*, aria-*
6997        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
6998        // These render on the HTML element but are NOT passed into component attribute state.
6999        let mut fragments = HashMap::new();
7000        fragments.insert(
7001            "index.html".to_string(),
7002            FragmentList {
7003                fragments: vec![
7004                    WebUIFragment::raw("<test-component"),
7005                    // Skipped: class
7006                    WebUIFragment {
7007                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7008                            WebUIFragmentAttribute {
7009                                name: "class".into(),
7010                                value: "skippedClass".into(),
7011                                attr_start: true,
7012                                attr_skip: true,
7013                                ..Default::default()
7014                            },
7015                        )),
7016                    },
7017                    // Skipped: style
7018                    WebUIFragment {
7019                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7020                            WebUIFragmentAttribute {
7021                                name: "style".into(),
7022                                value: "skippedStyle".into(),
7023                                attr_skip: true,
7024                                ..Default::default()
7025                            },
7026                        )),
7027                    },
7028                    // Skipped: role
7029                    WebUIFragment {
7030                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7031                            WebUIFragmentAttribute {
7032                                name: "role".into(),
7033                                value: "skippedRole".into(),
7034                                attr_skip: true,
7035                                ..Default::default()
7036                            },
7037                        )),
7038                    },
7039                    // Skipped: data-testid (data-* prefix)
7040                    WebUIFragment {
7041                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7042                            WebUIFragmentAttribute {
7043                                name: "data-testid".into(),
7044                                value: "skippedDataTestid".into(),
7045                                attr_skip: true,
7046                                ..Default::default()
7047                            },
7048                        )),
7049                    },
7050                    // Skipped: aria-label (aria-* prefix)
7051                    WebUIFragment {
7052                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7053                            WebUIFragmentAttribute {
7054                                name: "aria-label".into(),
7055                                value: "skippedAriaLabel".into(),
7056                                attr_skip: true,
7057                                ..Default::default()
7058                            },
7059                        )),
7060                    },
7061                    // NOT skipped: title
7062                    WebUIFragment {
7063                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7064                            WebUIFragmentAttribute {
7065                                name: "title".into(),
7066                                value: "title".into(),
7067                                ..Default::default()
7068                            },
7069                        )),
7070                    },
7071                    WebUIFragment::raw(">"),
7072                    WebUIFragment::component("test-component"),
7073                    WebUIFragment::raw("</test-component>"),
7074                ],
7075
7076                contains_boundary: false,
7077            },
7078        );
7079        fragments.insert(
7080            "test-component".to_string(),
7081            FragmentList {
7082                fragments: vec![
7083                    WebUIFragment::raw("<span>"),
7084                    WebUIFragment::signal("title", false),
7085                    WebUIFragment::raw("-"),
7086                    WebUIFragment::signal("class", false),
7087                    WebUIFragment::raw("-"),
7088                    WebUIFragment::signal("style", false),
7089                    WebUIFragment::raw("-"),
7090                    WebUIFragment::signal("role", false),
7091                    WebUIFragment::raw("-"),
7092                    WebUIFragment::signal("dataTestid", false),
7093                    WebUIFragment::raw("-"),
7094                    WebUIFragment::signal("ariaLabel", false),
7095                    WebUIFragment::raw("</span>"),
7096                ],
7097                contains_boundary: false,
7098            },
7099        );
7100        let protocol = WebUIProtocol::new(fragments);
7101        let state = test_json!({
7102            "title": "Hello",
7103            "skippedClass": "my-class",
7104            "skippedStyle": "color:red",
7105            "skippedRole": "button",
7106            "skippedDataTestid": "test-id",
7107            "skippedAriaLabel": "label-text"
7108        });
7109        let mut writer = TestWriter::new();
7110        handle(
7111            &protocol,
7112            &state,
7113            &RenderOptions::new("index.html", "/"),
7114            &mut writer,
7115        )
7116        .unwrap();
7117        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7118        // The component's signals for skipped attrs resolve to empty strings.
7119        // Only "title" (non-skipped) is accessible.
7120        assert_eq!(
7121            writer.get_content(),
7122            "<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>"
7123        );
7124    }
7125
7126    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7127
7128    #[test]
7129    fn test_attr_inherit_parent_to_child() {
7130        let mut fragments = HashMap::new();
7131        fragments.insert(
7132            "index.html".to_string(),
7133            FragmentList {
7134                fragments: vec![
7135                    WebUIFragment::raw("<parent-component"),
7136                    WebUIFragment {
7137                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7138                            WebUIFragmentAttribute {
7139                                name: "title".into(),
7140                                value: "Parent Title".into(),
7141                                raw_value: true,
7142                                attr_start: true,
7143                                ..Default::default()
7144                            },
7145                        )),
7146                    },
7147                    WebUIFragment::raw(">"),
7148                    WebUIFragment::component("parent-component"),
7149                    WebUIFragment::raw("</parent-component>"),
7150                ],
7151
7152                contains_boundary: false,
7153            },
7154        );
7155        fragments.insert(
7156            "parent-component".to_string(),
7157            FragmentList {
7158                fragments: vec![
7159                    WebUIFragment::raw("<h1>"),
7160                    WebUIFragment::signal("title", false),
7161                    WebUIFragment::raw("</h1><child-component"),
7162                    WebUIFragment {
7163                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7164                            WebUIFragmentAttribute {
7165                                name: "title".into(),
7166                                value: "title".into(),
7167                                attr_start: true,
7168                                ..Default::default()
7169                            },
7170                        )),
7171                    },
7172                    WebUIFragment::raw(">"),
7173                    WebUIFragment::component("child-component"),
7174                    WebUIFragment::raw("</child-component>"),
7175                ],
7176
7177                contains_boundary: false,
7178            },
7179        );
7180        fragments.insert(
7181            "child-component".to_string(),
7182            FragmentList {
7183                fragments: vec![
7184                    WebUIFragment::raw("<h2>"),
7185                    WebUIFragment::signal("title", false),
7186                    WebUIFragment::raw("</h2>"),
7187                ],
7188                contains_boundary: false,
7189            },
7190        );
7191        let protocol = WebUIProtocol::new(fragments);
7192        let state = test_json!({});
7193        let mut writer = TestWriter::new();
7194        handle(
7195            &protocol,
7196            &state,
7197            &RenderOptions::new("index.html", "/"),
7198            &mut writer,
7199        )
7200        .unwrap();
7201        assert_eq!(
7202            writer.get_content(),
7203            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7204        );
7205    }
7206
7207    #[test]
7208    fn test_attr_inherit_deep() {
7209        let mut fragments = HashMap::new();
7210        fragments.insert(
7211            "index.html".to_string(),
7212            FragmentList {
7213                fragments: vec![
7214                    WebUIFragment::raw("<parent-component"),
7215                    WebUIFragment {
7216                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7217                            WebUIFragmentAttribute {
7218                                name: "title".into(),
7219                                value: "Parent Title".into(),
7220                                raw_value: true,
7221                                attr_start: true,
7222                                ..Default::default()
7223                            },
7224                        )),
7225                    },
7226                    WebUIFragment::raw(">"),
7227                    WebUIFragment::component("parent-component"),
7228                    WebUIFragment::raw("</parent-component>"),
7229                ],
7230
7231                contains_boundary: false,
7232            },
7233        );
7234        fragments.insert(
7235            "parent-component".to_string(),
7236            FragmentList {
7237                fragments: vec![
7238                    WebUIFragment::raw("<child-component"),
7239                    WebUIFragment {
7240                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7241                            WebUIFragmentAttribute {
7242                                name: "title".into(),
7243                                value: "Child Title".into(),
7244                                raw_value: true,
7245                                attr_start: true,
7246                                ..Default::default()
7247                            },
7248                        )),
7249                    },
7250                    WebUIFragment::raw(">"),
7251                    WebUIFragment::component("child-component"),
7252                    WebUIFragment::raw("</child-component>"),
7253                ],
7254
7255                contains_boundary: false,
7256            },
7257        );
7258        fragments.insert(
7259            "child-component".to_string(),
7260            FragmentList {
7261                fragments: vec![
7262                    WebUIFragment::raw("<grandchild-component"),
7263                    WebUIFragment {
7264                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7265                            WebUIFragmentAttribute {
7266                                name: "title".into(),
7267                                value: "title".into(),
7268                                attr_start: true,
7269                                ..Default::default()
7270                            },
7271                        )),
7272                    },
7273                    WebUIFragment::raw(">"),
7274                    WebUIFragment::component("grandchild-component"),
7275                    WebUIFragment::raw("</grandchild-component>"),
7276                ],
7277
7278                contains_boundary: false,
7279            },
7280        );
7281        fragments.insert(
7282            "grandchild-component".to_string(),
7283            FragmentList {
7284                fragments: vec![
7285                    WebUIFragment::raw("<h3>"),
7286                    WebUIFragment::signal("title", false),
7287                    WebUIFragment::raw("</h3>"),
7288                ],
7289                contains_boundary: false,
7290            },
7291        );
7292        let protocol = WebUIProtocol::new(fragments);
7293        let state = test_json!({});
7294        let mut writer = TestWriter::new();
7295        handle(
7296            &protocol,
7297            &state,
7298            &RenderOptions::new("index.html", "/"),
7299            &mut writer,
7300        )
7301        .unwrap();
7302        assert_eq!(
7303            writer.get_content(),
7304            "<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>"
7305        );
7306    }
7307
7308    #[test]
7309    fn test_complex_attr_access() {
7310        let mut fragments = HashMap::new();
7311        fragments.insert(
7312            "index.html".to_string(),
7313            FragmentList {
7314                fragments: vec![
7315                    WebUIFragment::raw("<my-component"),
7316                    WebUIFragment {
7317                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7318                            WebUIFragmentAttribute {
7319                                name: ":item".into(),
7320                                value: "complexItem".into(),
7321                                attr_start: true,
7322                                complex: true,
7323                                ..Default::default()
7324                            },
7325                        )),
7326                    },
7327                    WebUIFragment::raw(">"),
7328                    WebUIFragment::component("my-component"),
7329                    WebUIFragment::raw("</my-component>"),
7330                ],
7331
7332                contains_boundary: false,
7333            },
7334        );
7335        fragments.insert(
7336            "my-component".to_string(),
7337            FragmentList {
7338                fragments: vec![
7339                    WebUIFragment::raw("<span>"),
7340                    WebUIFragment::signal("item.foo", false),
7341                    WebUIFragment::raw("</span><p>"),
7342                    WebUIFragment::signal("item.bar", false),
7343                    WebUIFragment::raw("</p>"),
7344                ],
7345                contains_boundary: false,
7346            },
7347        );
7348        let protocol = WebUIProtocol::new(fragments);
7349        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7350        let mut writer = TestWriter::new();
7351        handle(
7352            &protocol,
7353            &state,
7354            &RenderOptions::new("index.html", "/"),
7355            &mut writer,
7356        )
7357        .unwrap();
7358        assert_eq!(
7359            writer.get_content(),
7360            "<my-component><span>1</span><p>true</p></my-component>"
7361        );
7362    }
7363
7364    #[test]
7365    fn test_complex_attr_for_loop() {
7366        let mut fragments = HashMap::new();
7367        fragments.insert(
7368            "index.html".to_string(),
7369            FragmentList {
7370                fragments: vec![WebUIFragment::for_loop(
7371                    "item",
7372                    "list.items",
7373                    "listTemplate",
7374                )],
7375                contains_boundary: false,
7376            },
7377        );
7378        fragments.insert(
7379            "listTemplate".to_string(),
7380            FragmentList {
7381                fragments: vec![
7382                    WebUIFragment {
7383                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7384                            WebUIFragmentAttribute {
7385                                name: ":item".into(),
7386                                value: "item".into(),
7387                                attr_start: true,
7388                                complex: true,
7389                                ..Default::default()
7390                            },
7391                        )),
7392                    },
7393                    WebUIFragment::component("item_component"),
7394                ],
7395
7396                contains_boundary: false,
7397            },
7398        );
7399        fragments.insert(
7400            "item_component".to_string(),
7401            FragmentList {
7402                fragments: vec![
7403                    WebUIFragment::raw("<span>"),
7404                    WebUIFragment::signal("item.name", false),
7405                    WebUIFragment::raw("</span>"),
7406                ],
7407                contains_boundary: false,
7408            },
7409        );
7410        let protocol = WebUIProtocol::new(fragments);
7411        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7412        let mut writer = TestWriter::new();
7413        handle(
7414            &protocol,
7415            &state,
7416            &RenderOptions::new("index.html", "/"),
7417            &mut writer,
7418        )
7419        .unwrap();
7420        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7421    }
7422
7423    #[test]
7424    fn test_complex_attr_nested_for() {
7425        let mut fragments = HashMap::new();
7426        fragments.insert(
7427            "index.html".to_string(),
7428            FragmentList {
7429                fragments: vec![WebUIFragment::for_loop(
7430                    "outer",
7431                    "data.outer",
7432                    "outerTemplate",
7433                )],
7434                contains_boundary: false,
7435            },
7436        );
7437        fragments.insert(
7438            "outerTemplate".to_string(),
7439            FragmentList {
7440                fragments: vec![WebUIFragment::for_loop(
7441                    "middle",
7442                    "outer.middle",
7443                    "middleTemplate",
7444                )],
7445                contains_boundary: false,
7446            },
7447        );
7448        fragments.insert(
7449            "middleTemplate".to_string(),
7450            FragmentList {
7451                fragments: vec![WebUIFragment::for_loop(
7452                    "inner",
7453                    "middle.inner",
7454                    "innerTemplate",
7455                )],
7456                contains_boundary: false,
7457            },
7458        );
7459        fragments.insert(
7460            "innerTemplate".to_string(),
7461            FragmentList {
7462                fragments: vec![
7463                    WebUIFragment::raw("<card"),
7464                    WebUIFragment {
7465                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7466                            WebUIFragmentAttribute {
7467                                name: ":outer".into(),
7468                                value: "outer".into(),
7469                                attr_start: true,
7470                                complex: true,
7471                                ..Default::default()
7472                            },
7473                        )),
7474                    },
7475                    WebUIFragment {
7476                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7477                            WebUIFragmentAttribute {
7478                                name: ":middle".into(),
7479                                value: "middle".into(),
7480                                attr_start: false,
7481                                complex: true,
7482                                ..Default::default()
7483                            },
7484                        )),
7485                    },
7486                    WebUIFragment {
7487                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7488                            WebUIFragmentAttribute {
7489                                name: ":inner".into(),
7490                                value: "inner".into(),
7491                                attr_start: false,
7492                                complex: true,
7493                                ..Default::default()
7494                            },
7495                        )),
7496                    },
7497                    WebUIFragment::raw(">"),
7498                    WebUIFragment::component("card_component"),
7499                    WebUIFragment::raw("</card>"),
7500                ],
7501
7502                contains_boundary: false,
7503            },
7504        );
7505        fragments.insert(
7506            "card_component".to_string(),
7507            FragmentList {
7508                fragments: vec![
7509                    WebUIFragment::raw("<p>"),
7510                    WebUIFragment::signal("outer.label", false),
7511                    WebUIFragment::raw(" / "),
7512                    WebUIFragment::signal("middle.label", false),
7513                    WebUIFragment::raw(" / "),
7514                    WebUIFragment::signal("inner.label", false),
7515                    WebUIFragment::raw("</p>"),
7516                ],
7517                contains_boundary: false,
7518            },
7519        );
7520        let protocol = WebUIProtocol::new(fragments);
7521        let state = test_json!({"data": {"outer": [
7522            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7523            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7524        ]}});
7525        let mut writer = TestWriter::new();
7526        handle(
7527            &protocol,
7528            &state,
7529            &RenderOptions::new("index.html", "/"),
7530            &mut writer,
7531        )
7532        .unwrap();
7533        assert_eq!(
7534            writer.get_content(),
7535            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7536        );
7537    }
7538
7539    // ── GROUP 8: Boolean Component State ──────────────────────────────
7540
7541    #[test]
7542    fn test_bool_component_state_true() {
7543        let mut fragments = HashMap::new();
7544        fragments.insert(
7545            "index.html".to_string(),
7546            FragmentList {
7547                fragments: vec![
7548                    WebUIFragment::raw("<my-component"),
7549                    WebUIFragment {
7550                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7551                            WebUIFragmentAttribute {
7552                                name: "disabled".into(),
7553                                attr_start: true,
7554                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7555                                ..Default::default()
7556                            },
7557                        )),
7558                    },
7559                    WebUIFragment::raw(">"),
7560                    WebUIFragment::component("my-component"),
7561                    WebUIFragment::raw("</my-component>"),
7562                ],
7563
7564                contains_boundary: false,
7565            },
7566        );
7567        fragments.insert(
7568            "my-component".to_string(),
7569            FragmentList {
7570                fragments: vec![
7571                    WebUIFragment::if_cond(
7572                        ConditionExpr::identifier("disabled"),
7573                        "disabledTemplate",
7574                    ),
7575                    WebUIFragment::if_cond(
7576                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7577                        "enabledTemplate",
7578                    ),
7579                ],
7580                contains_boundary: false,
7581            },
7582        );
7583        fragments.insert(
7584            "disabledTemplate".to_string(),
7585            FragmentList {
7586                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7587                contains_boundary: false,
7588            },
7589        );
7590        fragments.insert(
7591            "enabledTemplate".to_string(),
7592            FragmentList {
7593                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7594                contains_boundary: false,
7595            },
7596        );
7597        let protocol = WebUIProtocol::new(fragments);
7598        let state = test_json!({"isDisabled": true});
7599        let mut writer = TestWriter::new();
7600        handle(
7601            &protocol,
7602            &state,
7603            &RenderOptions::new("index.html", "/"),
7604            &mut writer,
7605        )
7606        .unwrap();
7607        assert_eq!(
7608            writer.get_content(),
7609            "<my-component disabled><span>Disabled</span></my-component>"
7610        );
7611    }
7612
7613    #[test]
7614    fn test_bool_component_state_false() {
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": false});
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><span>Enabled</span></my-component>"
7682        );
7683    }
7684
7685    #[test]
7686    fn test_bool_component_state_forward() {
7687        let mut fragments = HashMap::new();
7688        fragments.insert(
7689            "index.html".to_string(),
7690            FragmentList {
7691                fragments: vec![
7692                    WebUIFragment::raw("<parent-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("parent-component"),
7705                    WebUIFragment::raw("</parent-component>"),
7706                ],
7707
7708                contains_boundary: false,
7709            },
7710        );
7711        fragments.insert(
7712            "parent-component".to_string(),
7713            FragmentList {
7714                fragments: vec![
7715                    WebUIFragment::if_cond(
7716                        ConditionExpr::identifier("disabled"),
7717                        "parentDisabledTemplate",
7718                    ),
7719                    WebUIFragment::raw("<child-component"),
7720                    WebUIFragment {
7721                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7722                            WebUIFragmentAttribute {
7723                                name: "disabled".into(),
7724                                attr_start: true,
7725                                condition_tree: Some(ConditionExpr::identifier("disabled")),
7726                                ..Default::default()
7727                            },
7728                        )),
7729                    },
7730                    WebUIFragment::raw(">"),
7731                    WebUIFragment::component("child-component"),
7732                    WebUIFragment::raw("</child-component>"),
7733                ],
7734
7735                contains_boundary: false,
7736            },
7737        );
7738        fragments.insert(
7739            "parentDisabledTemplate".to_string(),
7740            FragmentList {
7741                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7742                contains_boundary: false,
7743            },
7744        );
7745        fragments.insert(
7746            "child-component".to_string(),
7747            FragmentList {
7748                fragments: vec![
7749                    WebUIFragment::if_cond(
7750                        ConditionExpr::identifier("disabled"),
7751                        "childDisabledTemplate",
7752                    ),
7753                    WebUIFragment::if_cond(
7754                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7755                        "childEnabledTemplate",
7756                    ),
7757                ],
7758                contains_boundary: false,
7759            },
7760        );
7761        fragments.insert(
7762            "childDisabledTemplate".to_string(),
7763            FragmentList {
7764                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7765                contains_boundary: false,
7766            },
7767        );
7768        fragments.insert(
7769            "childEnabledTemplate".to_string(),
7770            FragmentList {
7771                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7772                contains_boundary: false,
7773            },
7774        );
7775
7776        // Test case 1: isDisabled = true
7777        {
7778            let protocol = WebUIProtocol::new(fragments.clone());
7779            let state = test_json!({"isDisabled": true});
7780            let mut writer = TestWriter::new();
7781            handle(
7782                &protocol,
7783                &state,
7784                &RenderOptions::new("index.html", "/"),
7785                &mut writer,
7786            )
7787            .unwrap();
7788            assert_eq!(
7789                writer.get_content(),
7790                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7791            );
7792        }
7793
7794        // Test case 2: isDisabled = false
7795        {
7796            let protocol = WebUIProtocol::new(fragments.clone());
7797            let state = test_json!({"isDisabled": false});
7798            let mut writer = TestWriter::new();
7799            handle(
7800                &protocol,
7801                &state,
7802                &RenderOptions::new("index.html", "/"),
7803                &mut writer,
7804            )
7805            .unwrap();
7806            assert_eq!(
7807                writer.get_content(),
7808                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7809            );
7810        }
7811    }
7812
7813    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
7814
7815    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
7816
7817    // ── Component tests ──────────────────────────────────────────────
7818
7819    #[test]
7820    fn test_component_with_template() {
7821        let mut fragments = HashMap::new();
7822        fragments.insert(
7823            "index.html".to_string(),
7824            FragmentList {
7825                fragments: vec![
7826                    WebUIFragment::raw("<custom-element>"),
7827                    WebUIFragment::component("custom-element"),
7828                    WebUIFragment::raw("</custom-element>"),
7829                ],
7830                contains_boundary: false,
7831            },
7832        );
7833        fragments.insert(
7834            "custom-element".to_string(),
7835            FragmentList {
7836                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7837                contains_boundary: false,
7838            },
7839        );
7840        let protocol = WebUIProtocol::new(fragments);
7841        let state = test_json!({});
7842        let mut writer = TestWriter::new();
7843        handle(
7844            &protocol,
7845            &state,
7846            &RenderOptions::new("index.html", "/"),
7847            &mut writer,
7848        )
7849        .unwrap();
7850        assert_eq!(
7851            writer.get_content(),
7852            "<custom-element><div>Custom Element</div></custom-element>"
7853        );
7854        assert!(writer.is_ended());
7855    }
7856
7857    #[test]
7858    fn test_component_with_slots() {
7859        let mut fragments = HashMap::new();
7860        fragments.insert(
7861            "index.html".to_string(),
7862            FragmentList {
7863                fragments: vec![
7864                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7865                    WebUIFragment::component("custom-element"),
7866                    WebUIFragment::raw("Hello World</custom-element>"),
7867                ],
7868                contains_boundary: false,
7869            },
7870        );
7871        fragments.insert(
7872            "custom-element".to_string(),
7873            FragmentList {
7874                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7875                contains_boundary: false,
7876            },
7877        );
7878        let protocol = WebUIProtocol::new(fragments);
7879        let state = test_json!({});
7880        let mut writer = TestWriter::new();
7881        handle(
7882            &protocol,
7883            &state,
7884            &RenderOptions::new("index.html", "/"),
7885            &mut writer,
7886        )
7887        .unwrap();
7888        assert_eq!(
7889            writer.get_content(),
7890            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7891        );
7892        assert!(writer.is_ended());
7893    }
7894
7895    #[test]
7896    fn test_multiple_nested_components() {
7897        let mut fragments = HashMap::new();
7898        fragments.insert(
7899            "index.html".to_string(),
7900            FragmentList {
7901                fragments: vec![
7902                    WebUIFragment::raw("<div>"),
7903                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
7904                    WebUIFragment::raw("</div>"),
7905                ],
7906                contains_boundary: false,
7907            },
7908        );
7909        fragments.insert(
7910            "custom-button".to_string(),
7911            FragmentList {
7912                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7913                contains_boundary: false,
7914            },
7915        );
7916        fragments.insert(
7917            "custom-element".to_string(),
7918            FragmentList {
7919                fragments: vec![
7920                    WebUIFragment::raw("<custom-child>"),
7921                    WebUIFragment::component("custom-child"),
7922                    WebUIFragment::raw("</custom-child><slot></slot>"),
7923                ],
7924                contains_boundary: false,
7925            },
7926        );
7927        fragments.insert(
7928            "custom-child".to_string(),
7929            FragmentList {
7930                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
7931                contains_boundary: false,
7932            },
7933        );
7934        fragments.insert(
7935            "templateRepeat".to_string(),
7936            FragmentList {
7937                fragments: vec![
7938                    WebUIFragment::raw("<custom-element>"),
7939                    WebUIFragment::component("custom-element"),
7940                    WebUIFragment::raw("<custom-button>"),
7941                    WebUIFragment::component("custom-button"),
7942                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
7943                ],
7944                contains_boundary: false,
7945            },
7946        );
7947        let protocol = WebUIProtocol::new(fragments);
7948        let state = test_json!({"items": [{"name": "Item1"}]});
7949        let mut writer = TestWriter::new();
7950        handle(
7951            &protocol,
7952            &state,
7953            &RenderOptions::new("index.html", "/"),
7954            &mut writer,
7955        )
7956        .unwrap();
7957        assert_eq!(
7958            writer.get_content(),
7959            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
7960        );
7961        assert!(writer.is_ended());
7962    }
7963
7964    // ── Conditional tests ────────────────────────────────────────────
7965
7966    #[test]
7967    fn test_if_with_binary_expression() {
7968        let mut fragments = HashMap::new();
7969        fragments.insert(
7970            "index.html".to_string(),
7971            FragmentList {
7972                fragments: vec![
7973                    WebUIFragment::raw("<div>"),
7974                    WebUIFragment::if_cond(
7975                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
7976                        "if-1",
7977                    ),
7978                    WebUIFragment::raw("</div>"),
7979                ],
7980                contains_boundary: false,
7981            },
7982        );
7983        fragments.insert(
7984            "if-1".to_string(),
7985            FragmentList {
7986                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
7987                contains_boundary: false,
7988            },
7989        );
7990        let protocol = WebUIProtocol::new(fragments);
7991
7992        // True case: x = 10 > 5
7993        let state_true = test_json!({"x": 10});
7994        let mut writer_true = TestWriter::new();
7995        handle(
7996            &protocol,
7997            &state_true,
7998            &RenderOptions::new("index.html", "/"),
7999            &mut writer_true,
8000        )
8001        .unwrap();
8002        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8003
8004        // False case: x = 1 <= 5
8005        let state_false = test_json!({"x": 1});
8006        let mut writer_false = TestWriter::new();
8007        handle(
8008            &protocol,
8009            &state_false,
8010            &RenderOptions::new("index.html", "/"),
8011            &mut writer_false,
8012        )
8013        .unwrap();
8014        assert_eq!(writer_false.get_content(), "<div></div>");
8015    }
8016
8017    #[test]
8018    fn test_for_if_overlapping_local_state() {
8019        let mut fragments = HashMap::new();
8020        fragments.insert(
8021            "index.html".to_string(),
8022            FragmentList {
8023                fragments: vec![
8024                    WebUIFragment::raw("<div>"),
8025                    WebUIFragment::for_loop("item", "items", "template1"),
8026                    WebUIFragment::raw("</div>"),
8027                ],
8028                contains_boundary: false,
8029            },
8030        );
8031        fragments.insert(
8032            "template1".to_string(),
8033            FragmentList {
8034                fragments: vec![
8035                    WebUIFragment::raw("<div>"),
8036                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8037                    WebUIFragment::raw("</div>"),
8038                ],
8039                contains_boundary: false,
8040            },
8041        );
8042        fragments.insert(
8043            "ifBlock".to_string(),
8044            FragmentList {
8045                fragments: vec![
8046                    WebUIFragment::raw("<span>"),
8047                    WebUIFragment::signal("item.label", false),
8048                    WebUIFragment::raw("</span>"),
8049                ],
8050                contains_boundary: false,
8051            },
8052        );
8053        let protocol = WebUIProtocol::new(fragments);
8054        let state = test_json!({
8055            "flag": false,
8056            "items": [
8057                {"label": "A", "flag": true},
8058                {"label": "B", "flag": false},
8059                {"label": "C", "flag": true}
8060            ]
8061        });
8062        let mut writer = TestWriter::new();
8063        handle(
8064            &protocol,
8065            &state,
8066            &RenderOptions::new("index.html", "/"),
8067            &mut writer,
8068        )
8069        .unwrap();
8070        assert_eq!(
8071            writer.get_content(),
8072            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8073        );
8074    }
8075
8076    #[test]
8077    fn test_for_if_global_flag_no_effect() {
8078        let mut fragments = HashMap::new();
8079        fragments.insert(
8080            "index.html".to_string(),
8081            FragmentList {
8082                fragments: vec![
8083                    WebUIFragment::raw("<div>"),
8084                    WebUIFragment::for_loop("item", "items", "template1"),
8085                    WebUIFragment::raw("</div>"),
8086                ],
8087                contains_boundary: false,
8088            },
8089        );
8090        fragments.insert(
8091            "template1".to_string(),
8092            FragmentList {
8093                fragments: vec![
8094                    WebUIFragment::raw("<div>"),
8095                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8096                    WebUIFragment::raw("</div>"),
8097                ],
8098                contains_boundary: false,
8099            },
8100        );
8101        fragments.insert(
8102            "ifBlock".to_string(),
8103            FragmentList {
8104                fragments: vec![
8105                    WebUIFragment::raw("<span>"),
8106                    WebUIFragment::signal("item.label", false),
8107                    WebUIFragment::raw("</span>"),
8108                ],
8109                contains_boundary: false,
8110            },
8111        );
8112        let protocol = WebUIProtocol::new(fragments);
8113        let state = test_json!({
8114            "item": {"flag": true},
8115            "items": [
8116                {"label": "A", "flag": false},
8117                {"label": "B", "flag": true},
8118                {"label": "C", "flag": false}
8119            ]
8120        });
8121        let mut writer = TestWriter::new();
8122        handle(
8123            &protocol,
8124            &state,
8125            &RenderOptions::new("index.html", "/"),
8126            &mut writer,
8127        )
8128        .unwrap();
8129        assert_eq!(
8130            writer.get_content(),
8131            "<div><div></div><div><span>B</span></div><div></div></div>"
8132        );
8133    }
8134
8135    // ── Recursive template test ──────────────────────────────────────
8136
8137    #[test]
8138    fn test_recursive_template_refs() {
8139        let mut fragments = HashMap::new();
8140        fragments.insert(
8141            "index.html".to_string(),
8142            FragmentList {
8143                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8144                contains_boundary: false,
8145            },
8146        );
8147        fragments.insert(
8148            "static".to_string(),
8149            FragmentList {
8150                fragments: vec![
8151                    WebUIFragment::raw("<div expanded=\""),
8152                    WebUIFragment::signal("item.expanded", false),
8153                    WebUIFragment::raw("\" class=\""),
8154                    WebUIFragment::signal("testScenario", false),
8155                    WebUIFragment::raw("\"><span>"),
8156                    WebUIFragment::signal("item.name", false),
8157                    WebUIFragment::raw("</span>"),
8158                    WebUIFragment::for_loop("item", "item.children", "static"),
8159                    WebUIFragment::raw("</div>"),
8160                ],
8161                contains_boundary: false,
8162            },
8163        );
8164        let protocol = WebUIProtocol::new(fragments);
8165        let state = test_json!({
8166            "testScenario": "RecursiveTemplatesWithGlobalState",
8167            "items": [
8168                {"name": "A", "expanded": "false", "children": []},
8169                {"name": "B", "expanded": "true", "children": [
8170                    {"name": "C", "expanded": "false"},
8171                    {"name": "D", "expanded": "false"}
8172                ]},
8173                {"name": "E", "expanded": "false"}
8174            ]
8175        });
8176        let mut writer = TestWriter::new();
8177        handle(
8178            &protocol,
8179            &state,
8180            &RenderOptions::new("index.html", "/"),
8181            &mut writer,
8182        )
8183        .unwrap();
8184        assert_eq!(
8185            writer.get_content(),
8186            "<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>"
8187        );
8188    }
8189
8190    // ── Advanced state management tests ──────────────────────────────
8191
8192    #[test]
8193    fn test_component_in_for_no_local_access() {
8194        let mut fragments = HashMap::new();
8195        fragments.insert(
8196            "index.html".to_string(),
8197            FragmentList {
8198                fragments: vec![
8199                    WebUIFragment::raw("<div>"),
8200                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8201                    WebUIFragment::raw("</div>"),
8202                ],
8203                contains_boundary: false,
8204            },
8205        );
8206        fragments.insert(
8207            "templateComponent".to_string(),
8208            FragmentList {
8209                fragments: vec![
8210                    WebUIFragment::raw("<component-tag>"),
8211                    WebUIFragment::component("my-component"),
8212                    WebUIFragment::raw("</component-tag>"),
8213                ],
8214                contains_boundary: false,
8215            },
8216        );
8217        fragments.insert(
8218            "my-component".to_string(),
8219            FragmentList {
8220                fragments: vec![
8221                    WebUIFragment::raw("<span>"),
8222                    WebUIFragment::signal("name", false),
8223                    WebUIFragment::raw("</span>"),
8224                ],
8225                contains_boundary: false,
8226            },
8227        );
8228        let protocol = WebUIProtocol::new(fragments);
8229        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8230        let mut writer = TestWriter::new();
8231        handle(
8232            &protocol,
8233            &state,
8234            &RenderOptions::new("index.html", "/"),
8235            &mut writer,
8236        )
8237        .unwrap();
8238        assert_eq!(
8239            writer.get_content(),
8240            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8241        );
8242    }
8243
8244    #[test]
8245    fn test_nested_for_hierarchical_state() {
8246        let mut fragments = HashMap::new();
8247        fragments.insert(
8248            "index.html".to_string(),
8249            FragmentList {
8250                fragments: vec![
8251                    WebUIFragment::raw("<div>"),
8252                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8253                    WebUIFragment::raw("</div>"),
8254                ],
8255                contains_boundary: false,
8256            },
8257        );
8258        fragments.insert(
8259            "outerTemplate".to_string(),
8260            FragmentList {
8261                fragments: vec![
8262                    WebUIFragment::raw("<section>"),
8263                    WebUIFragment::signal("globalPrefix", false),
8264                    WebUIFragment::signal("outerItem.outerLabel", false),
8265                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8266                    WebUIFragment::raw("</section>"),
8267                ],
8268                contains_boundary: false,
8269            },
8270        );
8271        fragments.insert(
8272            "innerTemplate".to_string(),
8273            FragmentList {
8274                fragments: vec![
8275                    WebUIFragment::raw("<p>"),
8276                    WebUIFragment::signal("globalPrefix", false),
8277                    WebUIFragment::signal("outerItem.outerLabel", false),
8278                    WebUIFragment::raw(": "),
8279                    WebUIFragment::signal("innerItem.innerLabel", false),
8280                    WebUIFragment::raw("</p>"),
8281                ],
8282                contains_boundary: false,
8283            },
8284        );
8285        let protocol = WebUIProtocol::new(fragments);
8286        let state = test_json!({
8287            "globalPrefix": "Prefix: ",
8288            "outerItems": [
8289                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8290                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8291            ]
8292        });
8293        let mut writer = TestWriter::new();
8294        handle(
8295            &protocol,
8296            &state,
8297            &RenderOptions::new("index.html", "/"),
8298            &mut writer,
8299        )
8300        .unwrap();
8301        assert_eq!(
8302            writer.get_content(),
8303            "<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>"
8304        );
8305    }
8306
8307    #[test]
8308    fn test_component_in_for_global_only() {
8309        let mut fragments = HashMap::new();
8310        fragments.insert(
8311            "index.html".to_string(),
8312            FragmentList {
8313                fragments: vec![
8314                    WebUIFragment::raw("<div>"),
8315                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8316                    WebUIFragment::raw("</div>"),
8317                ],
8318                contains_boundary: false,
8319            },
8320        );
8321        fragments.insert(
8322            "templateComponent".to_string(),
8323            FragmentList {
8324                fragments: vec![
8325                    WebUIFragment::raw("<component-tag>"),
8326                    WebUIFragment::component("my-component"),
8327                    WebUIFragment::raw("</component-tag>"),
8328                ],
8329                contains_boundary: false,
8330            },
8331        );
8332        fragments.insert(
8333            "my-component".to_string(),
8334            FragmentList {
8335                fragments: vec![
8336                    WebUIFragment::raw("<span>"),
8337                    WebUIFragment::signal("name", false),
8338                    WebUIFragment::raw("-"),
8339                    WebUIFragment::signal("globalSuffix", false),
8340                    WebUIFragment::raw("</span>"),
8341                ],
8342                contains_boundary: false,
8343            },
8344        );
8345        let protocol = WebUIProtocol::new(fragments);
8346        let state =
8347            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8348        let mut writer = TestWriter::new();
8349        handle(
8350            &protocol,
8351            &state,
8352            &RenderOptions::new("index.html", "/"),
8353            &mut writer,
8354        )
8355        .unwrap();
8356        assert_eq!(
8357            writer.get_content(),
8358            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8359        );
8360    }
8361
8362    #[test]
8363    fn test_component_no_item_moniker() {
8364        let mut fragments = HashMap::new();
8365        fragments.insert(
8366            "index.html".to_string(),
8367            FragmentList {
8368                fragments: vec![
8369                    WebUIFragment::raw("<div>"),
8370                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8371                    WebUIFragment::raw("</div>"),
8372                ],
8373                contains_boundary: false,
8374            },
8375        );
8376        fragments.insert(
8377            "templateComponent".to_string(),
8378            FragmentList {
8379                fragments: vec![
8380                    WebUIFragment::raw("<component-tag>"),
8381                    WebUIFragment::component("my-component"),
8382                    WebUIFragment::raw("</component-tag>"),
8383                ],
8384                contains_boundary: false,
8385            },
8386        );
8387        fragments.insert(
8388            "my-component".to_string(),
8389            FragmentList {
8390                fragments: vec![
8391                    WebUIFragment::raw("<span>"),
8392                    WebUIFragment::signal("item.name", false),
8393                    WebUIFragment::raw("-"),
8394                    WebUIFragment::signal("globalSuffix", false),
8395                    WebUIFragment::raw("</span>"),
8396                ],
8397                contains_boundary: false,
8398            },
8399        );
8400        let protocol = WebUIProtocol::new(fragments);
8401        let state =
8402            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8403        let mut writer = TestWriter::new();
8404        handle(
8405            &protocol,
8406            &state,
8407            &RenderOptions::new("index.html", "/"),
8408            &mut writer,
8409        )
8410        .unwrap();
8411        assert_eq!(
8412            writer.get_content(),
8413            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8414        );
8415    }
8416
8417    #[test]
8418    fn test_for_nonqualified_uses_global() {
8419        let mut fragments = HashMap::new();
8420        fragments.insert(
8421            "index.html".to_string(),
8422            FragmentList {
8423                fragments: vec![
8424                    WebUIFragment::raw("<div>"),
8425                    WebUIFragment::for_loop("item", "items", "template1"),
8426                    WebUIFragment::raw("</div>"),
8427                ],
8428                contains_boundary: false,
8429            },
8430        );
8431        fragments.insert(
8432            "template1".to_string(),
8433            FragmentList {
8434                fragments: vec![
8435                    WebUIFragment::raw("<span>"),
8436                    WebUIFragment::signal("name", false),
8437                    WebUIFragment::raw("</span>"),
8438                ],
8439                contains_boundary: false,
8440            },
8441        );
8442        let protocol = WebUIProtocol::new(fragments);
8443        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8444        let mut writer = TestWriter::new();
8445        handle(
8446            &protocol,
8447            &state,
8448            &RenderOptions::new("index.html", "/"),
8449            &mut writer,
8450        )
8451        .unwrap();
8452        assert_eq!(
8453            writer.get_content(),
8454            "<div><span>GlobalName</span><span>GlobalName</span></div>"
8455        );
8456    }
8457
8458    #[test]
8459    fn test_nested_for_if_interleaved() {
8460        let mut fragments = HashMap::new();
8461        fragments.insert(
8462            "index.html".to_string(),
8463            FragmentList {
8464                fragments: vec![
8465                    WebUIFragment::raw("<div>"),
8466                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8467                    WebUIFragment::raw("</div>"),
8468                ],
8469                contains_boundary: false,
8470            },
8471        );
8472        fragments.insert(
8473            "outerTemplate".to_string(),
8474            FragmentList {
8475                fragments: vec![
8476                    WebUIFragment::raw("<section>"),
8477                    WebUIFragment::signal("globalPrefix", false),
8478                    WebUIFragment::signal("outerItem.outerLabel", false),
8479                    WebUIFragment::if_cond(
8480                        ConditionExpr::identifier("outerItem.include"),
8481                        "ifTemplate",
8482                    ),
8483                    WebUIFragment::raw("</section>"),
8484                ],
8485                contains_boundary: false,
8486            },
8487        );
8488        fragments.insert(
8489            "ifTemplate".to_string(),
8490            FragmentList {
8491                fragments: vec![
8492                    WebUIFragment::raw("<div>"),
8493                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8494                    WebUIFragment::raw("</div>"),
8495                ],
8496                contains_boundary: false,
8497            },
8498        );
8499        fragments.insert(
8500            "innerTemplate".to_string(),
8501            FragmentList {
8502                fragments: vec![
8503                    WebUIFragment::raw("<p>"),
8504                    WebUIFragment::signal("globalSuffix", false),
8505                    WebUIFragment::raw(": "),
8506                    WebUIFragment::signal("innerItem.innerLabel", false),
8507                    WebUIFragment::raw("</p>"),
8508                ],
8509                contains_boundary: false,
8510            },
8511        );
8512        let protocol = WebUIProtocol::new(fragments);
8513        let state = test_json!({
8514            "globalPrefix": "Prefix: ",
8515            "globalSuffix": "Suffix",
8516            "outerItems": [
8517                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8518                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8519                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8520            ]
8521        });
8522        let mut writer = TestWriter::new();
8523        handle(
8524            &protocol,
8525            &state,
8526            &RenderOptions::new("index.html", "/"),
8527            &mut writer,
8528        )
8529        .unwrap();
8530        assert_eq!(
8531            writer.get_content(),
8532            "<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>"
8533        );
8534    }
8535
8536    #[test]
8537    fn test_nested_for_if_outer_state() {
8538        let mut fragments = HashMap::new();
8539        fragments.insert(
8540            "index.html".to_string(),
8541            FragmentList {
8542                fragments: vec![
8543                    WebUIFragment::raw("<div>"),
8544                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8545                    WebUIFragment::raw("</div>"),
8546                ],
8547                contains_boundary: false,
8548            },
8549        );
8550        fragments.insert(
8551            "outerTemplate".to_string(),
8552            FragmentList {
8553                fragments: vec![
8554                    WebUIFragment::raw("<section>"),
8555                    WebUIFragment::signal("globalPrefix", false),
8556                    WebUIFragment::signal("outerItem.label", false),
8557                    WebUIFragment::for_loop(
8558                        "middleItem",
8559                        "outerItem.middleItems",
8560                        "middleTemplate",
8561                    ),
8562                    WebUIFragment::raw("</section>"),
8563                ],
8564                contains_boundary: false,
8565            },
8566        );
8567        fragments.insert(
8568            "middleTemplate".to_string(),
8569            FragmentList {
8570                fragments: vec![
8571                    WebUIFragment::raw("<div>"),
8572                    WebUIFragment::if_cond(
8573                        ConditionExpr::identifier("outerItem.active"),
8574                        "ifTemplate",
8575                    ),
8576                    WebUIFragment::raw("</div>"),
8577                ],
8578                contains_boundary: false,
8579            },
8580        );
8581        fragments.insert(
8582            "ifTemplate".to_string(),
8583            FragmentList {
8584                fragments: vec![
8585                    WebUIFragment::raw("<p>"),
8586                    WebUIFragment::signal("middleItem.value", false),
8587                    WebUIFragment::raw("</p>"),
8588                ],
8589                contains_boundary: false,
8590            },
8591        );
8592        let protocol = WebUIProtocol::new(fragments);
8593        let state = test_json!({
8594            "globalPrefix": "GP-",
8595            "outerItems": [
8596                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8597                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8598                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8599            ]
8600        });
8601        let mut writer = TestWriter::new();
8602        handle(
8603            &protocol,
8604            &state,
8605            &RenderOptions::new("index.html", "/"),
8606            &mut writer,
8607        )
8608        .unwrap();
8609        assert_eq!(
8610            writer.get_content(),
8611            "<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>"
8612        );
8613    }
8614
8615    #[test]
8616    fn test_nested_for_if_inner_state() {
8617        let mut fragments = HashMap::new();
8618        fragments.insert(
8619            "index.html".to_string(),
8620            FragmentList {
8621                fragments: vec![
8622                    WebUIFragment::raw("<div>"),
8623                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8624                    WebUIFragment::raw("</div>"),
8625                ],
8626                contains_boundary: false,
8627            },
8628        );
8629        fragments.insert(
8630            "outerTemplate".to_string(),
8631            FragmentList {
8632                fragments: vec![
8633                    WebUIFragment::raw("<section>"),
8634                    WebUIFragment::signal("outerItem.label", false),
8635                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8636                    WebUIFragment::raw("</section>"),
8637                ],
8638                contains_boundary: false,
8639            },
8640        );
8641        fragments.insert(
8642            "innerTemplate".to_string(),
8643            FragmentList {
8644                fragments: vec![
8645                    WebUIFragment::raw("<article>"),
8646                    WebUIFragment::if_cond(
8647                        ConditionExpr::identifier("innerItem.show"),
8648                        "ifTemplate",
8649                    ),
8650                    WebUIFragment::raw("</article>"),
8651                ],
8652                contains_boundary: false,
8653            },
8654        );
8655        fragments.insert(
8656            "ifTemplate".to_string(),
8657            FragmentList {
8658                fragments: vec![
8659                    WebUIFragment::raw("<p>"),
8660                    WebUIFragment::signal("innerItem.detail", false),
8661                    WebUIFragment::raw("</p>"),
8662                ],
8663                contains_boundary: false,
8664            },
8665        );
8666        let protocol = WebUIProtocol::new(fragments);
8667        let state = test_json!({
8668            "outerItems": [
8669                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8670                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
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>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8684        );
8685    }
8686
8687    #[test]
8688    fn test_for_merge_local_global_monikers() {
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("item", "items", "template1"),
8696                    WebUIFragment::raw("</div>"),
8697                ],
8698                contains_boundary: false,
8699            },
8700        );
8701        fragments.insert(
8702            "template1".to_string(),
8703            FragmentList {
8704                fragments: vec![
8705                    WebUIFragment::raw("<span>"),
8706                    WebUIFragment::signal("item.name", false),
8707                    WebUIFragment::raw("-"),
8708                    WebUIFragment::signal("item.globalValue", false),
8709                    WebUIFragment::raw("-"),
8710                    WebUIFragment::signal("item.localOnly", false),
8711                    WebUIFragment::raw("-"),
8712                    WebUIFragment::signal("item.otherVal", false),
8713                    WebUIFragment::raw("</span>"),
8714                ],
8715                contains_boundary: false,
8716            },
8717        );
8718        let protocol = WebUIProtocol::new(fragments);
8719        let state = test_json!({
8720            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8721            "items": [
8722                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8723                {"name": "Local2", "localOnly": "Only2"}
8724            ]
8725        });
8726        let mut writer = TestWriter::new();
8727        handle(
8728            &protocol,
8729            &state,
8730            &RenderOptions::new("index.html", "/"),
8731            &mut writer,
8732        )
8733        .unwrap();
8734        assert_eq!(
8735            writer.get_content(),
8736            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8737        );
8738    }
8739
8740    #[test]
8741    fn test_component_in_for_global_moniker_shadow() {
8742        let mut fragments = HashMap::new();
8743        fragments.insert(
8744            "index.html".to_string(),
8745            FragmentList {
8746                fragments: vec![
8747                    WebUIFragment::raw("<div>"),
8748                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8749                    WebUIFragment::raw("</div>"),
8750                ],
8751                contains_boundary: false,
8752            },
8753        );
8754        fragments.insert(
8755            "templateComponent".to_string(),
8756            FragmentList {
8757                fragments: vec![
8758                    WebUIFragment::raw("<component-tag>"),
8759                    WebUIFragment::component("my-component"),
8760                    WebUIFragment::raw("</component-tag>"),
8761                ],
8762                contains_boundary: false,
8763            },
8764        );
8765        fragments.insert(
8766            "my-component".to_string(),
8767            FragmentList {
8768                fragments: vec![
8769                    WebUIFragment::raw("<span>"),
8770                    WebUIFragment::signal("name", false),
8771                    WebUIFragment::raw("-"),
8772                    WebUIFragment::signal("item.globalValue", false),
8773                    WebUIFragment::raw("-"),
8774                    WebUIFragment::signal("localOnly", false),
8775                    WebUIFragment::raw("-"),
8776                    WebUIFragment::signal("item.otherVal", false),
8777                    WebUIFragment::raw("</span>"),
8778                ],
8779                contains_boundary: false,
8780            },
8781        );
8782        let protocol = WebUIProtocol::new(fragments);
8783        let state = test_json!({
8784            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8785            "items": [
8786                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8787                {"name": "Local2", "localOnly": "Only2"}
8788            ]
8789        });
8790        let mut writer = TestWriter::new();
8791        handle(
8792            &protocol,
8793            &state,
8794            &RenderOptions::new("index.html", "/"),
8795            &mut writer,
8796        )
8797        .unwrap();
8798        assert_eq!(
8799            writer.get_content(),
8800            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8801        );
8802    }
8803
8804    #[test]
8805    fn test_if_in_nested_for_local_flag() {
8806        let mut fragments = HashMap::new();
8807        fragments.insert(
8808            "index.html".to_string(),
8809            FragmentList {
8810                fragments: vec![
8811                    WebUIFragment::raw("<div>"),
8812                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8813                    WebUIFragment::raw("</div>"),
8814                ],
8815                contains_boundary: false,
8816            },
8817        );
8818        fragments.insert(
8819            "outerTemplate".to_string(),
8820            FragmentList {
8821                fragments: vec![
8822                    WebUIFragment::raw("<section>"),
8823                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8824                    WebUIFragment::raw("</section>"),
8825                ],
8826                contains_boundary: false,
8827            },
8828        );
8829        fragments.insert(
8830            "innerTemplate".to_string(),
8831            FragmentList {
8832                fragments: vec![WebUIFragment::if_cond(
8833                    ConditionExpr::identifier("inner_item.flag"),
8834                    "ifInner",
8835                )],
8836                contains_boundary: false,
8837            },
8838        );
8839        fragments.insert(
8840            "ifInner".to_string(),
8841            FragmentList {
8842                fragments: vec![
8843                    WebUIFragment::raw("<p>"),
8844                    WebUIFragment::signal("inner_item.value", false),
8845                    WebUIFragment::raw("</p>"),
8846                ],
8847                contains_boundary: false,
8848            },
8849        );
8850        let protocol = WebUIProtocol::new(fragments);
8851        let state = test_json!({
8852            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8853            "inner_item": {"flag": false}
8854        });
8855        let mut writer = TestWriter::new();
8856        handle(
8857            &protocol,
8858            &state,
8859            &RenderOptions::new("index.html", "/"),
8860            &mut writer,
8861        )
8862        .unwrap();
8863        assert_eq!(
8864            writer.get_content(),
8865            "<div><section><p>X</p></section></div>"
8866        );
8867    }
8868
8869    #[test]
8870    fn test_if_in_nested_for_global_fallback() {
8871        let mut fragments = HashMap::new();
8872        fragments.insert(
8873            "index.html".to_string(),
8874            FragmentList {
8875                fragments: vec![
8876                    WebUIFragment::raw("<div>"),
8877                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8878                    WebUIFragment::raw("</div>"),
8879                ],
8880                contains_boundary: false,
8881            },
8882        );
8883        fragments.insert(
8884            "outerTemplate".to_string(),
8885            FragmentList {
8886                fragments: vec![
8887                    WebUIFragment::raw("<section>"),
8888                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8889                    WebUIFragment::raw("</section>"),
8890                ],
8891                contains_boundary: false,
8892            },
8893        );
8894        fragments.insert(
8895            "innerTemplate".to_string(),
8896            FragmentList {
8897                fragments: vec![WebUIFragment::if_cond(
8898                    ConditionExpr::identifier("inner_item.flag"),
8899                    "ifInner",
8900                )],
8901                contains_boundary: false,
8902            },
8903        );
8904        fragments.insert(
8905            "ifInner".to_string(),
8906            FragmentList {
8907                fragments: vec![
8908                    WebUIFragment::raw("<p>"),
8909                    WebUIFragment::signal("inner_item.value", false),
8910                    WebUIFragment::raw("</p>"),
8911                ],
8912                contains_boundary: false,
8913            },
8914        );
8915        let protocol = WebUIProtocol::new(fragments);
8916        let state = test_json!({
8917            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8918            "inner_item": {"flag": true}
8919        });
8920        let mut writer = TestWriter::new();
8921        handle(
8922            &protocol,
8923            &state,
8924            &RenderOptions::new("index.html", "/"),
8925            &mut writer,
8926        )
8927        .unwrap();
8928        assert_eq!(
8929            writer.get_content(),
8930            "<div><section><p>X</p><p>Y</p></section></div>"
8931        );
8932    }
8933
8934    #[test]
8935    fn test_if_mixed_for_monikers() {
8936        let mut fragments = HashMap::new();
8937        fragments.insert(
8938            "index.html".to_string(),
8939            FragmentList {
8940                fragments: vec![
8941                    WebUIFragment::raw("<div>"),
8942                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
8943                    WebUIFragment::raw("</div>"),
8944                ],
8945                contains_boundary: false,
8946            },
8947        );
8948        fragments.insert(
8949            "outerTemplate".to_string(),
8950            FragmentList {
8951                fragments: vec![
8952                    WebUIFragment::raw("<section>"),
8953                    WebUIFragment::signal("outer.outerLabel", false),
8954                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
8955                    WebUIFragment::raw("</section>"),
8956                ],
8957                contains_boundary: false,
8958            },
8959        );
8960        fragments.insert(
8961            "innerTemplate".to_string(),
8962            FragmentList {
8963                fragments: vec![WebUIFragment::if_cond(
8964                    ConditionExpr::compound(
8965                        ConditionExpr::identifier("outer.active"),
8966                        LogicalOperator::And,
8967                        ConditionExpr::predicate(
8968                            "inner.value",
8969                            ComparisonOperator::GreaterThan,
8970                            "globalLimit",
8971                        ),
8972                    ),
8973                    "ifInner",
8974                )],
8975                contains_boundary: false,
8976            },
8977        );
8978        fragments.insert(
8979            "ifInner".to_string(),
8980            FragmentList {
8981                fragments: vec![
8982                    WebUIFragment::raw("<p>"),
8983                    WebUIFragment::signal("inner.value", false),
8984                    WebUIFragment::raw("</p>"),
8985                ],
8986                contains_boundary: false,
8987            },
8988        );
8989        let protocol = WebUIProtocol::new(fragments);
8990        let state = test_json!({
8991            "globalLimit": 10,
8992            "list": {"outerItems": [
8993                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
8994                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
8995                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
8996            ]}
8997        });
8998        let mut writer = TestWriter::new();
8999        handle(
9000            &protocol,
9001            &state,
9002            &RenderOptions::new("index.html", "/"),
9003            &mut writer,
9004        )
9005        .unwrap();
9006        assert_eq!(
9007            writer.get_content(),
9008            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9009        );
9010    }
9011
9012    // ── Route-aware rendering tests ─────────────────────────────────────
9013
9014    fn make_route_protocol() -> WebUIProtocol {
9015        use webui_protocol::WebUiFragmentRoute;
9016
9017        let mut fragments = HashMap::new();
9018
9019        // Entry page with two routes
9020        fragments.insert(
9021            "index.html".to_string(),
9022            FragmentList {
9023                fragments: vec![
9024                    WebUIFragment::raw("<h1>Shell</h1>"),
9025                    WebUIFragment::route_from(WebUiFragmentRoute {
9026                        path: "/".into(),
9027                        fragment_id: "dash-page".into(),
9028                        exact: true,
9029                        keep_alive: false,
9030                        ..Default::default()
9031                    }),
9032                    WebUIFragment::route_from(WebUiFragmentRoute {
9033                        path: "/contacts/:id".into(),
9034                        fragment_id: "detail-page".into(),
9035                        exact: true,
9036                        keep_alive: true,
9037                        ..Default::default()
9038                    }),
9039                ],
9040
9041                contains_boundary: false,
9042            },
9043        );
9044
9045        // Dashboard page component
9046        fragments.insert(
9047            "dash-page".to_string(),
9048            FragmentList {
9049                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9050                contains_boundary: false,
9051            },
9052        );
9053
9054        // Detail page component
9055        fragments.insert(
9056            "detail-page".to_string(),
9057            FragmentList {
9058                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9059                contains_boundary: false,
9060            },
9061        );
9062
9063        WebUIProtocol::new(fragments)
9064    }
9065
9066    fn make_nested_route_protocol() -> WebUIProtocol {
9067        use webui_protocol::WebUiFragmentRoute;
9068
9069        let mut fragments = HashMap::new();
9070
9071        fragments.insert(
9072            "index.html".to_string(),
9073            FragmentList {
9074                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9075                    path: "/".into(),
9076                    fragment_id: "app-shell".into(),
9077                    exact: false,
9078                    children: vec![WebUiFragmentRoute {
9079                        path: "sections/:id".into(),
9080                        fragment_id: "section-comp".into(),
9081                        exact: false,
9082                        children: vec![WebUiFragmentRoute {
9083                            path: "topics/:topicId".into(),
9084                            fragment_id: "topic-comp".into(),
9085                            exact: true,
9086                            children: vec![],
9087                            keep_alive: false,
9088                            ..Default::default()
9089                        }],
9090                        keep_alive: false,
9091                        ..Default::default()
9092                    }],
9093                    keep_alive: false,
9094                    ..Default::default()
9095                })],
9096
9097                contains_boundary: false,
9098            },
9099        );
9100
9101        fragments.insert(
9102            "app-shell".to_string(),
9103            FragmentList {
9104                fragments: vec![
9105                    WebUIFragment::raw("<h1>Shell</h1>"),
9106                    WebUIFragment::outlet(),
9107                ],
9108                contains_boundary: false,
9109            },
9110        );
9111
9112        fragments.insert(
9113            "section-comp".to_string(),
9114            FragmentList {
9115                fragments: vec![
9116                    WebUIFragment::raw("<h2>Section</h2>"),
9117                    WebUIFragment::outlet(),
9118                ],
9119                contains_boundary: false,
9120            },
9121        );
9122
9123        fragments.insert(
9124            "topic-comp".to_string(),
9125            FragmentList {
9126                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9127                contains_boundary: false,
9128            },
9129        );
9130
9131        WebUIProtocol::new(fragments)
9132    }
9133
9134    #[test]
9135    fn test_route_renders_shell_always() {
9136        let protocol = make_route_protocol();
9137        let state = test_json!({});
9138        let mut writer = TestWriter::new();
9139        handle(
9140            &protocol,
9141            &state,
9142            &RenderOptions::new("index.html", "/"),
9143            &mut writer,
9144        )
9145        .unwrap();
9146        let html = writer.get_content();
9147
9148        // Shell content always renders regardless of route matching
9149        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9150        // Dashboard matches "/" so it should be active
9151        assert!(html.contains(" active>"), "matched route should be active");
9152        // Detail should be hidden and empty
9153        assert!(
9154            html.contains("style=\"display:none\""),
9155            "non-matched routes should be hidden"
9156        );
9157    }
9158
9159    #[test]
9160    fn test_route_matched_renders_visible() {
9161        let protocol = make_route_protocol();
9162        let state = test_json!({});
9163        let mut writer = TestWriter::new();
9164        handle(
9165            &protocol,
9166            &state,
9167            &RenderOptions::new("index.html", "/"),
9168            &mut writer,
9169        )
9170        .unwrap();
9171        let html = writer.get_content();
9172
9173        // Dashboard route should be visible (active, no display:none)
9174        assert!(
9175            html.contains("<webui-route path=\"/\""),
9176            "dashboard route should exist"
9177        );
9178        assert!(
9179            html.contains("active>") && html.contains("<dash-page"),
9180            "matched route should be active with component tag: {html}"
9181        );
9182        assert!(
9183            html.contains("<p>Dashboard</p>"),
9184            "matched route should have content"
9185        );
9186    }
9187
9188    #[test]
9189    fn test_route_non_matched_renders_hidden_empty() {
9190        let protocol = make_route_protocol();
9191        let state = test_json!({});
9192        let mut writer = TestWriter::new();
9193        handle(
9194            &protocol,
9195            &state,
9196            &RenderOptions::new("index.html", "/"),
9197            &mut writer,
9198        )
9199        .unwrap();
9200        let html = writer.get_content();
9201
9202        // Detail route should be hidden and empty (no content rendered)
9203        assert!(
9204            html.contains("<webui-route path=\"/contacts/:id\""),
9205            "detail route element should exist"
9206        );
9207        // The non-matched route should have display:none and no inner content
9208        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9209        let after_detail = &html[detail_start..];
9210        assert!(
9211            after_detail.contains("style=\"display:none\">")
9212                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9213            "non-matched route should be hidden: {after_detail}"
9214        );
9215        // Should NOT contain the component's rendered content
9216        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9217        let detail_body = &after_detail[..detail_end];
9218        assert!(
9219            !detail_body.contains("<detail-page"),
9220            "non-matched route should not render component content: {detail_body}"
9221        );
9222    }
9223
9224    #[test]
9225    fn test_route_parameterized_match() {
9226        let protocol = make_route_protocol();
9227        let state = test_json!({});
9228        let mut writer = TestWriter::new();
9229        handle(
9230            &protocol,
9231            &state,
9232            &RenderOptions::new("index.html", "/contacts/42"),
9233            &mut writer,
9234        )
9235        .unwrap();
9236        let html = writer.get_content();
9237
9238        // Detail route matches /contacts/42
9239        assert!(
9240            html.contains("active>") && html.contains("<detail-page"),
9241            "detail route should be active: {html}"
9242        );
9243        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9244        // Dashboard should be hidden + empty
9245        let dash_start = html
9246            .find("component=\"dash-page\"")
9247            .expect("dashboard route");
9248        let after_dash = &html[dash_start..];
9249        assert!(
9250            after_dash.contains("style=\"display:none\">"),
9251            "dashboard should be hidden when detail matches: {after_dash}"
9252        );
9253        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9254        let dash_body = &after_dash[..dash_end];
9255        assert!(
9256            !dash_body.contains("<dash-page"),
9257            "dashboard should not render component content: {dash_body}"
9258        );
9259    }
9260
9261    #[test]
9262    fn test_route_no_match_all_hidden_empty() {
9263        let protocol = make_route_protocol();
9264        let state = test_json!({});
9265        let mut writer = TestWriter::new();
9266        handle(
9267            &protocol,
9268            &state,
9269            &RenderOptions::new("index.html", "/nonexistent"),
9270            &mut writer,
9271        )
9272        .unwrap();
9273        let html = writer.get_content();
9274
9275        // Shell content should still render
9276        assert!(html.contains("<h1>Shell</h1>"));
9277        // All routes should be hidden + empty (nothing matched)
9278        assert!(
9279            !html.contains("<p>Dashboard</p>"),
9280            "no route content when nothing matches"
9281        );
9282        assert!(
9283            !html.contains("<p>Detail</p>"),
9284            "no route content when nothing matches"
9285        );
9286    }
9287
9288    #[test]
9289    fn test_route_component_attr_emitted() {
9290        let protocol = make_route_protocol();
9291        let state = test_json!({});
9292        let mut writer = TestWriter::new();
9293        handle(
9294            &protocol,
9295            &state,
9296            &RenderOptions::new("index.html", "/"),
9297            &mut writer,
9298        )
9299        .unwrap();
9300        let html = writer.get_content();
9301        // component attribute should be emitted on webui-route
9302        assert!(
9303            html.contains("component=\"dash-page\""),
9304            "component attr should be on webui-route: {html}"
9305        );
9306        assert!(
9307            html.contains("component=\"detail-page\""),
9308            "component attr should be on webui-route: {html}"
9309        );
9310        assert!(
9311            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9312            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9313        );
9314    }
9315
9316    #[test]
9317    fn test_no_plugin_no_state_attributes() {
9318        let protocol = make_route_protocol();
9319        let state = test_json!({
9320            "title": "Fish & Chips",
9321            "cartOpen": true,
9322            "items": [{"name": "A&B"}]
9323        });
9324        let mut writer = TestWriter::new();
9325
9326        handle(
9327            &protocol,
9328            &state,
9329            &RenderOptions::new("index.html", "/"),
9330            &mut writer,
9331        )
9332        .unwrap();
9333
9334        let html = writer.get_content();
9335        // Without a plugin, no state attributes at all
9336        assert!(
9337            !html.contains("data-state"),
9338            "no data-state without plugin: {html}"
9339        );
9340        assert!(
9341            !html.contains(r#"title="Fish"#),
9342            "no scalar attrs without plugin: {html}"
9343        );
9344    }
9345
9346    #[test]
9347    fn test_nested_routes_render_webui_route_as_light_dom() {
9348        let protocol = make_nested_route_protocol();
9349        let state = test_json!({"title": "Test"});
9350        let handler = WebUIHandler::new();
9351        let mut writer = TestWriter::new();
9352
9353        handler
9354            .handle(
9355                &protocol,
9356                &state,
9357                &RenderOptions::new("index.html", "/sections/frontend"),
9358                &mut writer,
9359            )
9360            .expect("render failed");
9361
9362        let html = writer.get_content();
9363
9364        assert!(
9365            html.contains("component=\"app-shell\"") && html.contains("active>"),
9366            "root route should be active: {html}"
9367        );
9368        // webui-route should NOT have shadow DOM — it's a light DOM structural element
9369        assert!(
9370            !html.contains("<template shadowrootmode"),
9371            "webui-route should be light DOM (no shadow template): {html}"
9372        );
9373    }
9374
9375    #[test]
9376    fn test_nested_routes_render_outlet_as_light_dom() {
9377        let protocol = make_nested_route_protocol();
9378        let state = test_json!({"title": "Test"});
9379        let handler = WebUIHandler::new();
9380        let mut writer = TestWriter::new();
9381
9382        handler
9383            .handle(
9384                &protocol,
9385                &state,
9386                &RenderOptions::new("index.html", "/sections/frontend"),
9387                &mut writer,
9388            )
9389            .expect("render failed");
9390
9391        let html = writer.get_content();
9392
9393        // No <webui-outlet> wrapper — routes render directly at outlet position
9394        assert!(
9395            !html.contains("<webui-outlet>"),
9396            "should not contain webui-outlet wrapper: {html}"
9397        );
9398        // Route elements should be in the output directly
9399        assert!(
9400            html.contains("<webui-route"),
9401            "should contain webui-route elements: {html}"
9402        );
9403    }
9404
9405    #[test]
9406    fn test_nested_routes_match_child_at_outlet() {
9407        let protocol = make_nested_route_protocol();
9408        let state = test_json!({"title": "Test"});
9409        let handler = WebUIHandler::new();
9410        let mut writer = TestWriter::new();
9411
9412        handler
9413            .handle(
9414                &protocol,
9415                &state,
9416                &RenderOptions::new("index.html", "/sections/frontend"),
9417                &mut writer,
9418            )
9419            .expect("render failed");
9420
9421        let html = writer.get_content();
9422
9423        assert!(
9424            html.contains("component=\"section-comp\"") && html.contains("active>"),
9425            "section route should be active: {html}"
9426        );
9427        assert!(
9428            html.contains("<h2>Section</h2>"),
9429            "section content should be present: {html}"
9430        );
9431    }
9432
9433    #[test]
9434    fn test_nested_routes_three_levels_deep() {
9435        let protocol = make_nested_route_protocol();
9436        let state = test_json!({"title": "Test"});
9437        let handler = WebUIHandler::new();
9438        let mut writer = TestWriter::new();
9439
9440        handler
9441            .handle(
9442                &protocol,
9443                &state,
9444                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9445                &mut writer,
9446            )
9447            .expect("render failed");
9448
9449        let html = writer.get_content();
9450
9451        assert!(
9452            html.contains("component=\"app-shell\"") && html.contains("active>"),
9453            "root active: {html}"
9454        );
9455        assert!(
9456            html.contains("component=\"section-comp\"") && html.contains("active>"),
9457            "section active: {html}"
9458        );
9459        assert!(
9460            html.contains("component=\"topic-comp\"")
9461                && html.contains("exact")
9462                && html.contains("active>"),
9463            "topic active: {html}"
9464        );
9465        assert!(
9466            html.contains("<p>Topic content</p>"),
9467            "leaf content present: {html}"
9468        );
9469    }
9470
9471    #[test]
9472    fn test_nested_routes_nonmatched_siblings_hidden() {
9473        let protocol = make_nested_route_protocol();
9474        let state = test_json!({"title": "Test"});
9475        let handler = WebUIHandler::new();
9476        let mut writer = TestWriter::new();
9477
9478        handler
9479            .handle(
9480                &protocol,
9481                &state,
9482                &RenderOptions::new("index.html", "/sections/frontend"),
9483                &mut writer,
9484            )
9485            .expect("render failed");
9486
9487        let html = writer.get_content();
9488
9489        assert!(
9490            html.contains(r#"component="topic-comp" exact style="display:none">"#),
9491            "topic should be hidden: {html}"
9492        );
9493    }
9494
9495    #[test]
9496    fn test_nested_routes_root_only() {
9497        let protocol = make_nested_route_protocol();
9498        let state = test_json!({"title": "Test"});
9499        let handler = WebUIHandler::new();
9500        let mut writer = TestWriter::new();
9501
9502        handler
9503            .handle(
9504                &protocol,
9505                &state,
9506                &RenderOptions::new("index.html", "/"),
9507                &mut writer,
9508            )
9509            .expect("render failed");
9510
9511        let html = writer.get_content();
9512
9513        assert!(
9514            html.contains("component=\"app-shell\"") && html.contains("active>"),
9515            "root active at /: {html}"
9516        );
9517        assert!(
9518            html.contains("<h1>Shell</h1>"),
9519            "shell renders at /: {html}"
9520        );
9521        assert!(
9522            html.contains(r#"component="section-comp" style="display:none">"#),
9523            "section hidden at /: {html}"
9524        );
9525    }
9526
9527    // ── CSS Module dedup tests ───────────────────────────────────────
9528
9529    #[test]
9530    fn test_css_module_emitted_once_inline_in_component() {
9531        // CSS module definition emitted once in the component's light DOM
9532        // on first render, not in <head> and not on second instance.
9533        let template = r#"<p><slot></slot></p>"#;
9534
9535        let mut fragments = HashMap::new();
9536        fragments.insert(
9537            "index.html".to_string(),
9538            FragmentList {
9539                fragments: vec![
9540                    WebUIFragment::raw("<html><head>".to_string()),
9541                    structural_fragment("head_end"),
9542                    WebUIFragment::raw("</head><body><div>".to_string()),
9543                    WebUIFragment::component("my-card"),
9544                    WebUIFragment::raw("A".to_string()),
9545                    WebUIFragment::component("my-card"),
9546                    WebUIFragment::raw("B</div>".to_string()),
9547                    structural_fragment("body_end"),
9548                    WebUIFragment::raw("</body></html>".to_string()),
9549                ],
9550                contains_boundary: false,
9551            },
9552        );
9553        fragments.insert(
9554            "my-card".to_string(),
9555            FragmentList {
9556                fragments: vec![WebUIFragment::raw(template.to_string())],
9557                contains_boundary: false,
9558            },
9559        );
9560
9561        let mut protocol = WebUIProtocol::new(fragments);
9562        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9563        protocol
9564            .components
9565            .entry("my-card".to_string())
9566            .or_default()
9567            .css = "p{color:red}".to_string();
9568        protocol.populate_style_closures(&["index.html"]);
9569        let state = test_json!({});
9570        let mut writer = TestWriter::new();
9571
9572        handle(
9573            &protocol,
9574            &state,
9575            &RenderOptions::new("index.html", "/"),
9576            &mut writer,
9577        )
9578        .unwrap();
9579
9580        let html = writer.get_content();
9581
9582        // CSS module importmap should appear exactly once
9583        let count = html.matches(r#"<script type="importmap""#).count();
9584        assert_eq!(
9585            count, 1,
9586            "CSS module importmap should be emitted once, got {count} in: {html}"
9587        );
9588        assert!(
9589            html.contains(r#""my-card":"data:text/css,"#),
9590            "Importmap must register my-card under a data: URI: {html}"
9591        );
9592
9593        // Template content should appear twice (once per component instance)
9594        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9595        assert_eq!(
9596            tmpl_count, 2,
9597            "Template should render twice, got {tmpl_count} in: {html}"
9598        );
9599
9600        // CSS module should be in <body> (inline), not in <head>
9601        let css_pos = html
9602            .find(r#"<script type="importmap""#)
9603            .expect("CSS module importmap missing");
9604        let body_pos = html.find("<body>").expect("<body> missing");
9605        assert!(
9606            css_pos > body_pos,
9607            "CSS module should be inline in component, not in <head>: {html}"
9608        );
9609    }
9610
9611    #[test]
9612    fn test_component_without_css_renders_normally() {
9613        // Components without CSS module prefix pass through unchanged
9614        let mut fragments = HashMap::new();
9615        fragments.insert(
9616            "index.html".to_string(),
9617            FragmentList {
9618                fragments: vec![WebUIFragment::component("my-card")],
9619                contains_boundary: false,
9620            },
9621        );
9622        fragments.insert(
9623            "my-card".to_string(),
9624            FragmentList {
9625                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9626                contains_boundary: false,
9627            },
9628        );
9629
9630        let protocol = WebUIProtocol::new(fragments);
9631        let state = test_json!({});
9632        let mut writer = TestWriter::new();
9633
9634        handle(
9635            &protocol,
9636            &state,
9637            &RenderOptions::new("index.html", "/"),
9638            &mut writer,
9639        )
9640        .unwrap();
9641
9642        let html = writer.get_content();
9643        assert!(
9644            html.contains("<p>hello</p>"),
9645            "Non-module component should render normally: {html}"
9646        );
9647    }
9648
9649    #[test]
9650    fn test_non_module_strategy_no_css_in_head() {
9651        // When component_css is empty (Link/Style strategies), no
9652        // CSS module importmap tags should appear in <head>.
9653        let template = r#"<p>hello</p>"#;
9654
9655        let mut fragments = HashMap::new();
9656        fragments.insert(
9657            "index.html".to_string(),
9658            FragmentList {
9659                fragments: vec![
9660                    WebUIFragment::raw("<html><head>".to_string()),
9661                    structural_fragment("head_end"),
9662                    WebUIFragment::raw("</head><body>".to_string()),
9663                    WebUIFragment::component("my-card"),
9664                    WebUIFragment::raw("</body></html>".to_string()),
9665                ],
9666                contains_boundary: false,
9667            },
9668        );
9669        fragments.insert(
9670            "my-card".to_string(),
9671            FragmentList {
9672                fragments: vec![WebUIFragment::raw(template.to_string())],
9673                contains_boundary: false,
9674            },
9675        );
9676
9677        // No component css populated — simulates Link/Style strategy
9678        let protocol = WebUIProtocol::new(fragments);
9679        let state = test_json!({});
9680        let mut writer = TestWriter::new();
9681
9682        handle(
9683            &protocol,
9684            &state,
9685            &RenderOptions::new("index.html", "/"),
9686            &mut writer,
9687        )
9688        .unwrap();
9689
9690        let html = writer.get_content();
9691
9692        assert!(
9693            !html.contains(r#"<style type="module""#),
9694            "Non-module strategy should not emit CSS module tags in <head>: {html}"
9695        );
9696        assert!(
9697            !html.contains(r#"<script type="importmap""#),
9698            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9699        );
9700        assert!(
9701            html.contains("<p>hello</p>"),
9702            "Component should still render: {html}"
9703        );
9704    }
9705
9706    #[test]
9707    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9708        let mut fragments = HashMap::new();
9709        fragments.insert(
9710            "index.html".to_string(),
9711            FragmentList {
9712                fragments: vec![
9713                    WebUIFragment::raw("<html><head>".to_string()),
9714                    structural_fragment("head_end"),
9715                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9716                    WebUIFragment::component("my-card"),
9717                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9718                ],
9719                contains_boundary: false,
9720            },
9721        );
9722        fragments.insert(
9723            "my-card".to_string(),
9724            FragmentList {
9725                fragments: vec![WebUIFragment::raw(
9726                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9727                        .to_string(),
9728                )],
9729                        contains_boundary: false,
9730},
9731        );
9732
9733        let protocol = WebUIProtocol::new(fragments);
9734        let state = test_json!({});
9735        let mut writer = TestWriter::new();
9736
9737        handle(
9738            &protocol,
9739            &state,
9740            &RenderOptions::new("index.html", "/"),
9741            &mut writer,
9742        )
9743        .unwrap();
9744
9745        let html = writer.get_content();
9746
9747        assert!(
9748            html.contains("<style>.card{color:red}</style>"),
9749            "Style strategy should embed inline CSS in shadow template: {html}"
9750        );
9751        assert!(
9752            !html.contains(r#"<style type="module""#),
9753            "Style strategy should not emit legacy module CSS in <head>: {html}"
9754        );
9755        assert!(
9756            !html.contains(r#"<script type="importmap""#),
9757            "Style strategy should not emit CSS module importmaps in <head>: {html}"
9758        );
9759    }
9760
9761    #[test]
9762    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9763        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
9764        // in <head>. No preload tag — the stylesheet itself fetches.
9765        let mut fragments = HashMap::new();
9766        fragments.insert(
9767            "index.html".to_string(),
9768            FragmentList {
9769                fragments: vec![
9770                    WebUIFragment::raw("<html><head>".to_string()),
9771                    structural_fragment("head_end"),
9772                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9773                    WebUIFragment::component("my-card"),
9774                    WebUIFragment::raw("</my-card>".to_string()),
9775                    structural_fragment("body_end"),
9776                    WebUIFragment::raw("</body></html>".to_string()),
9777                ],
9778                contains_boundary: false,
9779            },
9780        );
9781        fragments.insert(
9782            "my-card".to_string(),
9783            FragmentList {
9784                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9785                contains_boundary: false,
9786            },
9787        );
9788
9789        let mut protocol = WebUIProtocol::new(fragments);
9790        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9791
9792        let comp = protocol
9793            .components
9794            .entry("my-card".to_string())
9795            .or_default();
9796        comp.css_href = "my-card.css".to_string();
9797        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9798        protocol.populate_style_closures(&["index.html"]);
9799
9800        let state = test_json!({});
9801        let mut writer = TestWriter::new();
9802
9803        handle(
9804            &protocol,
9805            &state,
9806            &RenderOptions::new("index.html", "/"),
9807            &mut writer,
9808        )
9809        .unwrap();
9810
9811        let html = writer.get_content();
9812
9813        let head_end = html.find("</head>").expect("</head> missing");
9814        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9815        assert!(
9816            link_pos.is_some_and(|p| p < head_end),
9817            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9818        );
9819        assert!(
9820            !html.contains(r#"<link rel="preload""#),
9821            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9822        );
9823        assert!(
9824            !html.contains(r#"<style type="module""#),
9825            "Link strategy should not emit legacy module CSS: {html}"
9826        );
9827        assert!(
9828            !html.contains(r#"<script type="importmap""#),
9829            "Link strategy should not emit CSS module importmaps: {html}"
9830        );
9831    }
9832
9833    #[test]
9834    fn link_strategy_preloads_static_shadow_css_in_head() {
9835        let mut fragments = HashMap::new();
9836        fragments.insert(
9837            "index.html".to_string(),
9838            FragmentList {
9839                fragments: vec![
9840                    WebUIFragment::raw("<html><head>".to_string()),
9841                    structural_fragment("head_end"),
9842                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9843                    WebUIFragment::component("my-card"),
9844                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9845                ],
9846                contains_boundary: false,
9847            },
9848        );
9849        fragments.insert(
9850            "o-loading-state".to_string(),
9851            FragmentList {
9852                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9853                contains_boundary: false,
9854            },
9855        );
9856        fragments.insert(
9857            "my-card".to_string(),
9858            FragmentList {
9859                fragments: vec![
9860                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9861                    structural_fragment("shadow_styles:my-card"),
9862                    WebUIFragment::raw("<div>card</div></template>".to_string()),
9863                ],
9864                contains_boundary: false,
9865            },
9866        );
9867
9868        let mut protocol = WebUIProtocol::new(fragments);
9869        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9870        let component = protocol
9871            .components
9872            .entry("my-card".to_string())
9873            .or_default();
9874        component.css_href = "my-card.css".to_string();
9875        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9876        component.uses_shadow_dom = true;
9877        protocol.populate_style_closures(&["index.html"]);
9878
9879        let mut writer = TestWriter::new();
9880        handle(
9881            &protocol,
9882            &test_json!({}),
9883            &RenderOptions::new("index.html", "/"),
9884            &mut writer,
9885        )
9886        .unwrap();
9887
9888        let html = writer.get_content();
9889        let head_end = html.find("</head>").expect("</head> missing");
9890        let preload = html
9891            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9892            .expect("Shadow preload missing");
9893        let stylesheet = html
9894            .find(r#"<link rel="stylesheet" href="my-card.css""#)
9895            .expect("Shadow stylesheet missing");
9896        assert!(preload < head_end);
9897        assert!(stylesheet > head_end);
9898        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9899    }
9900
9901    #[test]
9902    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9903        let fragments = HashMap::from([
9904            (
9905                "index.html".to_string(),
9906                FragmentList {
9907                    fragments: vec![
9908                        WebUIFragment::raw("<html><head>".to_string()),
9909                        structural_fragment("head_end"),
9910                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
9911                        WebUIFragment::component("outer-box"),
9912                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
9913                    ],
9914                    contains_boundary: false,
9915                },
9916            ),
9917            (
9918                "outer-box".to_string(),
9919                FragmentList {
9920                    fragments: vec![
9921                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9922                        structural_fragment("shadow_styles:outer-box"),
9923                        WebUIFragment::raw("<light-card>".to_string()),
9924                        WebUIFragment::component("light-card"),
9925                        WebUIFragment::raw("</light-card><light-card>".to_string()),
9926                        WebUIFragment::component("light-card"),
9927                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
9928                        WebUIFragment::component("inner-box"),
9929                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
9930                        WebUIFragment::component("light-card"),
9931                        WebUIFragment::raw("</light-card></template>".to_string()),
9932                    ],
9933                    contains_boundary: false,
9934                },
9935            ),
9936            (
9937                "inner-box".to_string(),
9938                FragmentList {
9939                    fragments: vec![
9940                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9941                        structural_fragment("shadow_styles:inner-box"),
9942                        WebUIFragment::raw("<light-card>".to_string()),
9943                        WebUIFragment::component("light-card"),
9944                        WebUIFragment::raw("</light-card></template>".to_string()),
9945                    ],
9946                    contains_boundary: false,
9947                },
9948            ),
9949            (
9950                "light-card".to_string(),
9951                FragmentList {
9952                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
9953                    contains_boundary: false,
9954                },
9955            ),
9956        ]);
9957        let mut protocol = WebUIProtocol::new(fragments);
9958        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
9959        for (tag, css, uses_shadow_dom) in [
9960            ("outer-box", ".outer{}", true),
9961            ("inner-box", ".inner{}", true),
9962            ("light-card", ".card{}", false),
9963        ] {
9964            let component = protocol.components.entry(tag.to_string()).or_default();
9965            component.css = css.to_string();
9966            component.uses_shadow_dom = uses_shadow_dom;
9967        }
9968        protocol.populate_style_closures(&["index.html"]);
9969
9970        let mut writer = TestWriter::new();
9971        handle(
9972            &protocol,
9973            &test_json!({}),
9974            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
9975            &mut writer,
9976        )
9977        .unwrap();
9978        let html = writer.get_content();
9979
9980        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
9981        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
9982        assert_eq!(
9983            html.matches("data-webui-resource=\"light-card\"").count(),
9984            2,
9985            "the Light resource installs once in each Shadow tree: {html}"
9986        );
9987        assert_eq!(
9988            html.matches("data-webui-resource=").count(),
9989            4,
9990            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
9991        );
9992        assert_eq!(
9993            html.matches("nonce=\"css-nonce\"").count(),
9994            4,
9995            "each inline style resource carries the nonce"
9996        );
9997        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
9998        let inner_end = html.find("</inner-box>").expect("inner host close");
9999        let caller_continuation = &html[inner_end..];
10000        let trailing_card = caller_continuation
10001            .find("<p>card</p>")
10002            .expect("caller continues after nested Shadow root");
10003        assert!(trailing_card > 0);
10004        assert!(
10005            !caller_continuation.contains("data-webui-resource=\"light-card\""),
10006            "returning from the nested Shadow root must restore the outer frame's delivered set"
10007        );
10008    }
10009
10010    #[test]
10011    fn routed_document_styles_are_hoisted_for_the_active_chain() {
10012        let route = WebUiFragmentRoute {
10013            path: "/".to_string(),
10014            fragment_id: "app-shell".to_string(),
10015            children: vec![
10016                WebUiFragmentRoute {
10017                    path: String::new(),
10018                    fragment_id: "dashboard-page".to_string(),
10019                    exact: true,
10020                    ..Default::default()
10021                },
10022                WebUiFragmentRoute {
10023                    path: "details".to_string(),
10024                    fragment_id: "detail-page".to_string(),
10025                    exact: true,
10026                    ..Default::default()
10027                },
10028            ],
10029            ..Default::default()
10030        };
10031        let fragments = HashMap::from([
10032            (
10033                "index.html".to_string(),
10034                FragmentList {
10035                    fragments: vec![
10036                        WebUIFragment::raw("<html><head>"),
10037                        structural_fragment("head_start"),
10038                        structural_fragment("head_end"),
10039                        WebUIFragment::raw("</head><body>"),
10040                        structural_fragment("body_start"),
10041                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10042                        WebUIFragment::route_from(route),
10043                        WebUIFragment::boundary_end(0),
10044                        structural_fragment("body_end"),
10045                        WebUIFragment::raw("</body></html>"),
10046                    ],
10047                    contains_boundary: true,
10048                },
10049            ),
10050            (
10051                "app-shell".to_string(),
10052                FragmentList {
10053                    fragments: vec![
10054                        WebUIFragment::raw("<header>App</header>"),
10055                        WebUIFragment::outlet(),
10056                    ],
10057                    contains_boundary: false,
10058                },
10059            ),
10060            (
10061                "dashboard-page".to_string(),
10062                FragmentList {
10063                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10064                    contains_boundary: false,
10065                },
10066            ),
10067            (
10068                "detail-page".to_string(),
10069                FragmentList {
10070                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10071                    contains_boundary: false,
10072                },
10073            ),
10074        ]);
10075        let mut protocol = WebUIProtocol::new(fragments);
10076        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10077        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10078            let component = protocol.components.entry(tag.to_string()).or_default();
10079            component.css_href = format!("/{tag}.css");
10080            component.template_json = r#"{"h":""}"#.to_string();
10081        }
10082        protocol.populate_style_closures(&["index.html"]);
10083
10084        let mut writer = TestWriter::new();
10085        handle(
10086            &protocol,
10087            &test_json!({}),
10088            &RenderOptions::new("index.html", "/"),
10089            &mut writer,
10090        )
10091        .unwrap();
10092        let html = writer.get_content();
10093
10094        assert_eq!(
10095            html.matches(r#"data-webui-resource="app-shell""#).count(),
10096            1,
10097            "the matched route shell must install once: {html}"
10098        );
10099        assert_eq!(
10100            html.matches(r#"data-webui-resource="dashboard-page""#)
10101                .count(),
10102            1,
10103            "the matched child route must install once: {html}"
10104        );
10105        assert!(
10106            !html.contains(r#"data-webui-resource="detail-page""#),
10107            "inactive route CSS must not be delivered: {html}"
10108        );
10109        let head_end = html.find("</head>").expect("head close");
10110        let head = &html[..head_end];
10111        for (resource, href) in [
10112            ("app-shell", "/app-shell.css"),
10113            ("dashboard-page", "/dashboard-page.css"),
10114        ] {
10115            let encoded_href = crate::html_encode::encode_safe(href);
10116            assert!(
10117                head.contains(&format!(
10118                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10119                )),
10120                "Document-targeted route CSS must be render-blocking in head: {html}"
10121            );
10122            assert!(
10123                !head.contains(&format!(
10124                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10125                )),
10126                "a hoisted stylesheet must not also be preloaded: {html}"
10127            );
10128        }
10129        assert!(
10130            !head.contains("detail-page.css"),
10131            "inactive route CSS must not be preloaded: {html}"
10132        );
10133        let app_style = html
10134            .find(r#"data-webui-resource="app-shell""#)
10135            .expect("app style");
10136        let dashboard_style = html
10137            .find(r#"data-webui-resource="dashboard-page""#)
10138            .expect("dashboard style");
10139        let app_host = html.find("<app-shell").expect("app route host");
10140        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10141        assert!(
10142            app_style < dashboard_style
10143                && dashboard_style < head_end
10144                && head_end < app_host
10145                && app_host < dashboard_host,
10146            "matched Document route closures must be hoisted in chain order: {html}"
10147        );
10148
10149        let protocol = Protocol::new(protocol);
10150        let mut streamed = FlushTestWriter::default();
10151        WebUIHandler::new()
10152            .render_streaming(
10153                &protocol,
10154                &test_json!({}),
10155                &RenderOptions::new("index.html", "/"),
10156                &mut streamed,
10157            )
10158            .unwrap();
10159        let streamed_head_end = streamed
10160            .output
10161            .find("</head>")
10162            .expect("streamed head close");
10163        for resource in ["app-shell", "dashboard-page"] {
10164            let marker = format!(r#"data-webui-resource="{resource}""#);
10165            let position = streamed.output.find(&marker).expect("streamed route style");
10166            assert!(
10167                position < streamed_head_end,
10168                "streaming must hoist {resource} into head: {}",
10169                streamed.output
10170            );
10171            assert_eq!(
10172                streamed.output.matches(&marker).count(),
10173                1,
10174                "streaming must not emit {resource} twice: {}",
10175                streamed.output
10176            );
10177        }
10178    }
10179
10180    #[test]
10181    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10182        let route = WebUiFragmentRoute {
10183            path: "/".to_string(),
10184            fragment_id: "dashboard-page".to_string(),
10185            exact: true,
10186            ..Default::default()
10187        };
10188        let mut protocol = WebUIProtocol::new(HashMap::from([
10189            (
10190                "index.html".to_string(),
10191                FragmentList {
10192                    fragments: vec![
10193                        WebUIFragment::raw("<html><head>"),
10194                        structural_fragment("head_start"),
10195                        structural_fragment("head_end"),
10196                        WebUIFragment::raw("</head><body>"),
10197                        WebUIFragment::route_from(route),
10198                        structural_fragment("body_end"),
10199                        WebUIFragment::raw("</body></html>"),
10200                    ],
10201                    contains_boundary: false,
10202                },
10203            ),
10204            (
10205                "dashboard-page".to_string(),
10206                FragmentList {
10207                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10208                    contains_boundary: false,
10209                },
10210            ),
10211        ]));
10212        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10213        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10214            protocol
10215                .components
10216                .entry(tag.to_string())
10217                .or_default()
10218                .css_href = format!("/{tag}.css");
10219        }
10220        protocol.style_closures.insert(
10221            "index.html".to_string(),
10222            webui_protocol::ComponentStyleClosure::default(),
10223        );
10224        protocol.style_closures.insert(
10225            "dashboard-page".to_string(),
10226            webui_protocol::ComponentStyleClosure {
10227                component_tags: vec![
10228                    "dashboard-page".to_string(),
10229                    "summary-card".to_string(),
10230                    "activity-list".to_string(),
10231                ],
10232                style_chunks: vec![0],
10233            },
10234        );
10235        protocol.style_chunks.push(webui_protocol::StyleChunk {
10236            name: "_chunk-dashboard-page-3".to_string(),
10237            css: String::new(),
10238            css_href: "/_chunk-dashboard-page-3.css".to_string(),
10239            component_tags: vec![
10240                "dashboard-page".to_string(),
10241                "summary-card".to_string(),
10242                "activity-list".to_string(),
10243            ],
10244        });
10245
10246        let mut writer = TestWriter::new();
10247        handle(
10248            &protocol,
10249            &test_json!({}),
10250            &RenderOptions::new("index.html", "/"),
10251            &mut writer,
10252        )
10253        .unwrap();
10254        let html = writer.get_content();
10255        let head_end = html.find("</head>").expect("head close");
10256        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10257        let style = html.find(marker).expect("bundled route stylesheet");
10258
10259        assert!(
10260            style < head_end,
10261            "bundled route CSS must be in head: {html}"
10262        );
10263        assert_eq!(
10264            html.matches(marker).count(),
10265            1,
10266            "the route host must not emit the hoisted chunk again: {html}"
10267        );
10268        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10269        assert!(
10270            !html.contains(&format!(
10271                r#"<link rel="preload" as="style" href="{bundled_href}">"#
10272            )),
10273            "the applied bundled stylesheet must not also be preloaded: {html}"
10274        );
10275    }
10276
10277    #[test]
10278    fn routed_document_inline_styles_are_hoisted() {
10279        for strategy in [
10280            webui_protocol::CssStrategy::Style,
10281            webui_protocol::CssStrategy::Module,
10282        ] {
10283            let route = WebUiFragmentRoute {
10284                path: "/".to_string(),
10285                fragment_id: "dashboard-page".to_string(),
10286                exact: true,
10287                ..Default::default()
10288            };
10289            let mut protocol = WebUIProtocol::new(HashMap::from([
10290                (
10291                    "index.html".to_string(),
10292                    FragmentList {
10293                        fragments: vec![
10294                            WebUIFragment::raw("<html><head>"),
10295                            structural_fragment("head_start"),
10296                            structural_fragment("head_end"),
10297                            WebUIFragment::raw("</head><body>"),
10298                            WebUIFragment::route_from(route),
10299                            structural_fragment("body_end"),
10300                            WebUIFragment::raw("</body></html>"),
10301                        ],
10302                        contains_boundary: false,
10303                    },
10304                ),
10305                (
10306                    "dashboard-page".to_string(),
10307                    FragmentList {
10308                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10309                        contains_boundary: false,
10310                    },
10311                ),
10312            ]));
10313            protocol.set_css_strategy(strategy);
10314            protocol
10315                .components
10316                .entry("dashboard-page".to_string())
10317                .or_default()
10318                .css = ".dashboard{display:grid}".to_string();
10319            protocol.populate_style_closures(&["index.html"]);
10320
10321            let mut writer = TestWriter::new();
10322            handle(
10323                &protocol,
10324                &test_json!({}),
10325                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10326                &mut writer,
10327            )
10328            .unwrap();
10329            let html = writer.get_content();
10330            let head_end = html.find("</head>").expect("head close");
10331            let marker = r#"data-webui-resource="dashboard-page""#;
10332            let style = html.find(marker).expect("route style fallback");
10333            let host = html.find("<dashboard-page").expect("dashboard route host");
10334            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10335                "module"
10336            } else {
10337                "style"
10338            };
10339            let style_marker = format!(
10340                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10341            );
10342
10343            assert!(
10344                style < head_end && head_end < host,
10345                "{strategy:?} route CSS must be applied in head before its host: {html}"
10346            );
10347            assert_eq!(
10348                html.matches(&style_marker).count(),
10349                1,
10350                "{strategy:?} route CSS must not be emitted twice: {html}"
10351            );
10352        }
10353    }
10354
10355    #[test]
10356    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10357        let route = WebUiFragmentRoute {
10358            path: "/".to_string(),
10359            fragment_id: "dashboard-page".to_string(),
10360            exact: true,
10361            ..Default::default()
10362        };
10363        let mut protocol = WebUIProtocol::new(HashMap::from([
10364            (
10365                "index.html".to_string(),
10366                FragmentList {
10367                    fragments: vec![
10368                        WebUIFragment::raw("<html><head>"),
10369                        structural_fragment("head_start"),
10370                        structural_fragment("head_end"),
10371                        WebUIFragment::raw("</head><body><outer-box>"),
10372                        WebUIFragment::component("outer-box"),
10373                        WebUIFragment::raw("</outer-box></body></html>"),
10374                    ],
10375                    contains_boundary: false,
10376                },
10377            ),
10378            (
10379                "outer-box".to_string(),
10380                FragmentList {
10381                    fragments: vec![
10382                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10383                        structural_fragment("shadow_styles:outer-box"),
10384                        WebUIFragment::route_from(route),
10385                        WebUIFragment::raw("</template>"),
10386                    ],
10387                    contains_boundary: false,
10388                },
10389            ),
10390            (
10391                "dashboard-page".to_string(),
10392                FragmentList {
10393                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10394                    contains_boundary: false,
10395                },
10396            ),
10397        ]));
10398        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10399        for tag in ["outer-box", "dashboard-page"] {
10400            protocol
10401                .components
10402                .entry(tag.to_string())
10403                .or_default()
10404                .css_href = format!("/{tag}.css");
10405        }
10406        protocol
10407            .components
10408            .get_mut("outer-box")
10409            .expect("outer-box component")
10410            .uses_shadow_dom = true;
10411        protocol.populate_style_closures(&["index.html"]);
10412
10413        let mut writer = TestWriter::new();
10414        handle(
10415            &protocol,
10416            &test_json!({}),
10417            &RenderOptions::new("index.html", "/"),
10418            &mut writer,
10419        )
10420        .unwrap();
10421        let html = writer.get_content();
10422        let head_end = html.find("</head>").expect("head close");
10423        let head = &html[..head_end];
10424        let dashboard_style = html
10425            .find(r#"data-webui-resource="dashboard-page""#)
10426            .expect("tree-local dashboard stylesheet");
10427        let shadow_start = html
10428            .find("<template shadowrootmode=\"open\">")
10429            .expect("Shadow template");
10430        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10431        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10432
10433        assert!(
10434            head.contains(&format!(
10435                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10436            )),
10437            "Shadow-targeted route CSS must start loading from head: {html}"
10438        );
10439        assert!(
10440            !head.contains(r#"data-webui-resource="dashboard-page""#),
10441            "Shadow-targeted route CSS must not be applied to Document: {html}"
10442        );
10443        assert!(
10444            shadow_start < dashboard_style && dashboard_style < dashboard_host,
10445            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10446        );
10447    }
10448
10449    #[test]
10450    fn isolated_shadow_entry_does_not_emit_document_preloads() {
10451        let route = WebUiFragmentRoute {
10452            path: "/".to_string(),
10453            fragment_id: "dashboard-page".to_string(),
10454            exact: true,
10455            ..Default::default()
10456        };
10457        let mut protocol = WebUIProtocol::new(HashMap::from([
10458            (
10459                "outer-box".to_string(),
10460                FragmentList {
10461                    fragments: vec![
10462                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10463                        structural_fragment("shadow_styles:outer-box"),
10464                        WebUIFragment::route_from(route),
10465                        WebUIFragment::raw("</template>"),
10466                    ],
10467                    contains_boundary: false,
10468                },
10469            ),
10470            (
10471                "dashboard-page".to_string(),
10472                FragmentList {
10473                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10474                    contains_boundary: false,
10475                },
10476            ),
10477        ]));
10478        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10479        for tag in ["outer-box", "dashboard-page"] {
10480            protocol
10481                .components
10482                .entry(tag.to_string())
10483                .or_default()
10484                .css_href = format!("/{tag}.css");
10485        }
10486        protocol
10487            .components
10488            .get_mut("outer-box")
10489            .expect("outer-box component")
10490            .uses_shadow_dom = true;
10491        protocol.populate_style_closures(&["outer-box"]);
10492
10493        let mut writer = TestWriter::new();
10494        handle(
10495            &protocol,
10496            &test_json!({}),
10497            &RenderOptions::new("outer-box", "/"),
10498            &mut writer,
10499        )
10500        .unwrap();
10501        let html = writer.get_content();
10502
10503        assert!(
10504            html.starts_with("<template shadowrootmode=\"open\">"),
10505            "isolated component output must not be prefixed with document metadata: {html}"
10506        );
10507        assert!(
10508            !html.contains(r#"<link rel="preload""#),
10509            "an isolated Shadow entry has no Document head for preloads: {html}"
10510        );
10511        assert!(
10512            html.contains(r#"data-webui-resource="dashboard-page""#),
10513            "the route stylesheet must still install inside the ShadowRoot: {html}"
10514        );
10515    }
10516
10517    #[test]
10518    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10519        let route = WebUiFragmentRoute {
10520            path: "/".to_string(),
10521            fragment_id: "app-shell".to_string(),
10522            children: vec![
10523                WebUiFragmentRoute {
10524                    path: String::new(),
10525                    fragment_id: "dashboard-page".to_string(),
10526                    exact: true,
10527                    ..Default::default()
10528                },
10529                WebUiFragmentRoute {
10530                    path: "details".to_string(),
10531                    fragment_id: "detail-page".to_string(),
10532                    exact: true,
10533                    ..Default::default()
10534                },
10535            ],
10536            ..Default::default()
10537        };
10538        let fragments = HashMap::from([
10539            (
10540                "index.html".to_string(),
10541                FragmentList {
10542                    fragments: vec![
10543                        WebUIFragment::raw("<html><head>"),
10544                        structural_fragment("head_start"),
10545                        structural_fragment("head_end"),
10546                        WebUIFragment::raw("</head><body>"),
10547                        structural_fragment("body_start"),
10548                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10549                        WebUIFragment::route_from(route),
10550                        WebUIFragment::boundary_end(0),
10551                        structural_fragment("body_end"),
10552                        WebUIFragment::raw("</body></html>"),
10553                    ],
10554                    contains_boundary: true,
10555                },
10556            ),
10557            (
10558                "app-shell".to_string(),
10559                FragmentList {
10560                    fragments: vec![
10561                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10562                        structural_fragment("shadow_styles:app-shell"),
10563                        WebUIFragment::raw("<shared-card"),
10564                        structural_fragment("streaming_root:shared-card"),
10565                        WebUIFragment::raw(">"),
10566                        WebUIFragment::component("shared-card"),
10567                        WebUIFragment::raw("</shared-card>"),
10568                        WebUIFragment::outlet(),
10569                        WebUIFragment::raw("</template>"),
10570                    ],
10571                    contains_boundary: false,
10572                },
10573            ),
10574            (
10575                "dashboard-page".to_string(),
10576                FragmentList {
10577                    fragments: vec![
10578                        WebUIFragment::raw("<shared-card"),
10579                        structural_fragment("streaming_root:shared-card"),
10580                        WebUIFragment::raw(">"),
10581                        WebUIFragment::component("shared-card"),
10582                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10583                    ],
10584                    contains_boundary: false,
10585                },
10586            ),
10587            (
10588                "detail-page".to_string(),
10589                FragmentList {
10590                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10591                    contains_boundary: false,
10592                },
10593            ),
10594            (
10595                "shared-card".to_string(),
10596                FragmentList {
10597                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10598                    contains_boundary: false,
10599                },
10600            ),
10601        ]);
10602        let mut protocol = WebUIProtocol::new(fragments);
10603        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10604        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10605            let component = protocol.components.entry(tag.to_string()).or_default();
10606            component.css = format!(".{tag}{{display:block}}");
10607            component.template_json = r#"{"h":""}"#.to_string();
10608        }
10609        protocol
10610            .components
10611            .get_mut("app-shell")
10612            .expect("app shell component")
10613            .uses_shadow_dom = true;
10614        protocol.populate_style_closures(&["index.html"]);
10615
10616        let mut writer = TestWriter::new();
10617        handle(
10618            &protocol,
10619            &test_json!({}),
10620            &RenderOptions::new("index.html", "/"),
10621            &mut writer,
10622        )
10623        .unwrap();
10624        let html = writer.get_content();
10625
10626        for resource in ["app-shell", "shared-card", "dashboard-page"] {
10627            assert_eq!(
10628                html.matches(&format!(r#"data-webui-resource="{resource}""#))
10629                    .count(),
10630                1,
10631                "{resource} must install once in the active ShadowRoot: {html}"
10632            );
10633        }
10634        assert!(
10635            !html.contains(r#"data-webui-resource="detail-page""#),
10636            "inactive route CSS must not enter the ShadowRoot: {html}"
10637        );
10638        let shadow_start = html
10639            .find("<template shadowrootmode=\"open\">")
10640            .expect("Shadow template");
10641        let dashboard_style = html
10642            .find(r#"data-webui-resource="dashboard-page""#)
10643            .expect("dashboard style");
10644        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10645        let shadow_end = html.find("</template>").expect("Shadow template close");
10646        assert!(
10647            shadow_start < dashboard_style
10648                && dashboard_style < dashboard_host
10649                && dashboard_host < shadow_end,
10650            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10651        );
10652
10653        let protocol = Protocol::new(protocol);
10654        let mut streamed = FlushTestWriter::default();
10655        WebUIHandler::new()
10656            .render_streaming(
10657                &protocol,
10658                &test_json!({}),
10659                &RenderOptions::new("index.html", "/"),
10660                &mut streamed,
10661            )
10662            .unwrap();
10663        for resource in ["app-shell", "shared-card"] {
10664            assert_eq!(
10665                streamed
10666                    .output
10667                    .matches(&format!(r#"data-webui-resource="{resource}""#))
10668                    .count(),
10669                1,
10670                "streaming must preserve tree-local delivery for {resource}: {}",
10671                streamed.output
10672            );
10673        }
10674        assert!(
10675            !streamed
10676                .output
10677                .contains(r#"data-webui-resource="dashboard-page""#),
10678            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10679            streamed.output
10680        );
10681        assert!(
10682            streamed
10683                .output
10684                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10685                && streamed
10686                    .output
10687                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10688            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10689            streamed.output
10690        );
10691        assert!(
10692            !streamed
10693                .output
10694                .contains(r#"data-webui-resource="detail-page""#),
10695            "streaming must omit inactive route CSS: {}",
10696            streamed.output
10697        );
10698    }
10699
10700    #[test]
10701    fn document_styles_follow_doctype_before_headless_document_content() {
10702        for entry_fragments in [
10703            vec![
10704                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10705                structural_fragment("body_start"),
10706                WebUIFragment::raw("<my-card>"),
10707                WebUIFragment::component("my-card"),
10708                WebUIFragment::raw("</my-card></body></html>"),
10709            ],
10710            vec![
10711                WebUIFragment::raw("<!doctype html><html><my-card>"),
10712                WebUIFragment::component("my-card"),
10713                WebUIFragment::raw("</my-card></html>"),
10714            ],
10715        ] {
10716            let mut protocol = WebUIProtocol::new(HashMap::from([
10717                (
10718                    "index.html".to_string(),
10719                    FragmentList {
10720                        fragments: entry_fragments,
10721                        contains_boundary: false,
10722                    },
10723                ),
10724                (
10725                    "my-card".to_string(),
10726                    FragmentList {
10727                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
10728                        contains_boundary: false,
10729                    },
10730                ),
10731            ]));
10732            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10733            let component = protocol
10734                .components
10735                .entry("my-card".to_string())
10736                .or_default();
10737            component.css = ".card{color:red}".to_string();
10738            component.uses_shadow_dom = false;
10739            protocol.populate_style_closures(&["index.html"]);
10740
10741            let mut writer = TestWriter::new();
10742            handle(
10743                &protocol,
10744                &test_json!({}),
10745                &RenderOptions::new("index.html", "/"),
10746                &mut writer,
10747            )
10748            .expect("render");
10749            let html = writer.get_content();
10750
10751            assert!(
10752                html.to_ascii_lowercase().starts_with("<!doctype html>"),
10753                "the doctype must remain the first document token: {html}"
10754            );
10755            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10756            let document = html.find("<html").expect("document root");
10757            let component = html.find("<my-card>").expect("component host");
10758            assert!(
10759                style < document && style < component,
10760                "headless document styles must precede document content: {html}"
10761            );
10762        }
10763    }
10764
10765    #[test]
10766    fn authored_shadow_component_can_render_as_entry_fragment() {
10767        let mut protocol = WebUIProtocol::new(HashMap::from([(
10768            "my-card".to_string(),
10769            FragmentList {
10770                fragments: vec![
10771                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10772                    structural_fragment("shadow_styles:my-card"),
10773                    WebUIFragment::raw("<p>card</p></template>"),
10774                ],
10775                contains_boundary: false,
10776            },
10777        )]));
10778        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10779        let component = protocol
10780            .components
10781            .entry("my-card".to_string())
10782            .or_default();
10783        component.css = ".card{color:red}".to_string();
10784        component.uses_shadow_dom = true;
10785        protocol.populate_style_closures(&["my-card"]);
10786
10787        let mut writer = TestWriter::new();
10788        handle(
10789            &protocol,
10790            &test_json!({}),
10791            &RenderOptions::new("my-card", "/"),
10792            &mut writer,
10793        )
10794        .expect("render");
10795        let html = writer.get_content();
10796
10797        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10798        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10799        assert!(html.ends_with("<p>card</p></template>"));
10800    }
10801
10802    #[test]
10803    fn component_style_closures_escape_mixed_case_style_end_tags() {
10804        for strategy in [
10805            webui_protocol::CssStrategy::Style,
10806            webui_protocol::CssStrategy::Module,
10807        ] {
10808            let mut protocol = WebUIProtocol::new(HashMap::from([
10809                (
10810                    "index.html".to_string(),
10811                    FragmentList {
10812                        fragments: vec![
10813                            WebUIFragment::raw("<html><head>"),
10814                            structural_fragment("head_end"),
10815                            WebUIFragment::raw("</head><body><safe-card>"),
10816                            WebUIFragment::component("safe-card"),
10817                            WebUIFragment::raw("</safe-card></body></html>"),
10818                        ],
10819                        contains_boundary: false,
10820                    },
10821                ),
10822                (
10823                    "safe-card".to_string(),
10824                    FragmentList {
10825                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10826                        contains_boundary: false,
10827                    },
10828                ),
10829            ]));
10830            protocol.set_css_strategy(strategy);
10831            protocol.components.insert(
10832                "safe-card".to_string(),
10833                webui_protocol::ComponentData {
10834                    css: ".safe{content:'</StYlE>'}".to_string(),
10835                    ..Default::default()
10836                },
10837            );
10838            protocol.populate_style_closures(&["index.html"]);
10839            let mut writer = TestWriter::new();
10840
10841            handle(
10842                &protocol,
10843                &test_json!({}),
10844                &RenderOptions::new("index.html", "/"),
10845                &mut writer,
10846            )
10847            .unwrap();
10848
10849            let html = writer.get_content();
10850            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10851            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10852        }
10853    }
10854
10855    #[test]
10856    fn shadow_closure_supports_link_and_module_ssr_paths() {
10857        for strategy in [
10858            webui_protocol::CssStrategy::Link,
10859            webui_protocol::CssStrategy::Module,
10860        ] {
10861            let mut protocol = WebUIProtocol::new(HashMap::from([
10862                (
10863                    "index.html".to_string(),
10864                    FragmentList {
10865                        fragments: vec![
10866                            WebUIFragment::raw("<html><head>".to_string()),
10867                            structural_fragment("head_end"),
10868                            WebUIFragment::raw("</head><body><my-card>".to_string()),
10869                            WebUIFragment::component("my-card"),
10870                            WebUIFragment::raw("</my-card></body></html>".to_string()),
10871                        ],
10872                        contains_boundary: false,
10873                    },
10874                ),
10875                (
10876                    "my-card".to_string(),
10877                    FragmentList {
10878                        fragments: vec![
10879                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10880                            structural_fragment("shadow_styles:my-card"),
10881                            WebUIFragment::raw("<p>card</p></template>".to_string()),
10882                        ],
10883                        contains_boundary: false,
10884                    },
10885                ),
10886            ]));
10887            protocol.set_css_strategy(strategy);
10888            let component = protocol
10889                .components
10890                .entry("my-card".to_string())
10891                .or_default();
10892            component.css = ".card{color:red}".to_string();
10893            component.css_href = "/my-card.css".to_string();
10894            component.uses_shadow_dom = true;
10895            protocol.populate_style_closures(&["index.html"]);
10896
10897            let mut writer = TestWriter::new();
10898            handle(
10899                &protocol,
10900                &test_json!({}),
10901                &RenderOptions::new("index.html", "/"),
10902                &mut writer,
10903            )
10904            .unwrap();
10905            let html = writer.get_content();
10906            match strategy {
10907                webui_protocol::CssStrategy::Link => {
10908                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10909                    assert!(html.contains(&format!(
10910                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10911                    )));
10912                }
10913                webui_protocol::CssStrategy::Module => {
10914                    assert!(html.contains(
10915                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10916                    ));
10917                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10918                }
10919                webui_protocol::CssStrategy::Style => unreachable!(),
10920            }
10921        }
10922    }
10923
10924    #[test]
10925    fn test_module_preloads_emit_in_head_in_compiler_order() {
10926        // The whole value of these hints is ordering: preloads are issued in
10927        // document order over one connection, so the largest chunk must go
10928        // first. The compiler sorts; the handler must not reorder or dedupe.
10929        let mut fragments = HashMap::new();
10930        fragments.insert(
10931            "index.html".to_string(),
10932            FragmentList {
10933                fragments: vec![
10934                    WebUIFragment::raw(
10935                        r#"<html><head><script type="module" async src="/index.js"></script>"#
10936                            .to_string(),
10937                    ),
10938                    structural_fragment("head_end"),
10939                    WebUIFragment::raw("</head><body>".to_string()),
10940                    structural_fragment("body_end"),
10941                    WebUIFragment::raw("</body></html>".to_string()),
10942                ],
10943                contains_boundary: false,
10944            },
10945        );
10946
10947        let mut protocol = WebUIProtocol::new(fragments);
10948        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
10949
10950        let state = test_json!({});
10951        let mut writer = TestWriter::new();
10952        handle(
10953            &protocol,
10954            &state,
10955            &RenderOptions::new("index.html", "/"),
10956            &mut writer,
10957        )
10958        .unwrap();
10959
10960        let html = writer.get_content();
10961        let head_end = html.find("</head>").expect("</head> missing");
10962        let head = &html[..head_end];
10963        assert!(
10964            head.contains(
10965                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
10966            ),
10967            "hints must appear in <head> in the compiler's order: {html}"
10968        );
10969    }
10970
10971    #[test]
10972    fn test_no_module_preloads_emits_nothing() {
10973        let mut fragments = HashMap::new();
10974        fragments.insert(
10975            "index.html".to_string(),
10976            FragmentList {
10977                fragments: vec![
10978                    WebUIFragment::raw("<html><head>".to_string()),
10979                    structural_fragment("head_end"),
10980                    WebUIFragment::raw("</head><body>".to_string()),
10981                    structural_fragment("body_end"),
10982                    WebUIFragment::raw("</body></html>".to_string()),
10983                ],
10984                contains_boundary: false,
10985            },
10986        );
10987        let protocol = WebUIProtocol::new(fragments);
10988
10989        let state = test_json!({});
10990        let mut writer = TestWriter::new();
10991        handle(
10992            &protocol,
10993            &state,
10994            &RenderOptions::new("index.html", "/"),
10995            &mut writer,
10996        )
10997        .unwrap();
10998
10999        assert!(
11000            !writer.get_content().contains("modulepreload"),
11001            "a build without hints must be byte-identical to before"
11002        );
11003    }
11004
11005    #[test]
11006    fn test_link_strategy_head_links_follow_document_order() {
11007        // Regression for #381: Link-strategy <head> CSS <link> tags must be
11008        // emitted in document/traversal order, not alphabetical tag order.
11009        // Document order here is <z-widget> then <a-widget>; an alphabetical
11010        // sort would (incorrectly) place a-widget first.
11011        let mut fragments = HashMap::new();
11012        fragments.insert(
11013            "index.html".to_string(),
11014            FragmentList {
11015                fragments: vec![
11016                    WebUIFragment::raw("<html><head>".to_string()),
11017                    structural_fragment("head_end"),
11018                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
11019                    WebUIFragment::component("z-widget"),
11020                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11021                    WebUIFragment::component("a-widget"),
11022                    WebUIFragment::raw("</a-widget>".to_string()),
11023                    structural_fragment("body_end"),
11024                    WebUIFragment::raw("</body></html>".to_string()),
11025                ],
11026                contains_boundary: false,
11027            },
11028        );
11029        fragments.insert(
11030            "z-widget".to_string(),
11031            FragmentList {
11032                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11033                contains_boundary: false,
11034            },
11035        );
11036        fragments.insert(
11037            "a-widget".to_string(),
11038            FragmentList {
11039                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11040                contains_boundary: false,
11041            },
11042        );
11043
11044        let mut protocol = WebUIProtocol::new(fragments);
11045        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11046
11047        let z = protocol
11048            .components
11049            .entry("z-widget".to_string())
11050            .or_default();
11051        z.css_href = "z-widget.css".to_string();
11052        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11053
11054        let a = protocol
11055            .components
11056            .entry("a-widget".to_string())
11057            .or_default();
11058        a.css_href = "a-widget.css".to_string();
11059        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11060        protocol.populate_style_closures(&["index.html"]);
11061
11062        let state = test_json!({});
11063        let mut writer = TestWriter::new();
11064
11065        handle(
11066            &protocol,
11067            &state,
11068            &RenderOptions::new("index.html", "/"),
11069            &mut writer,
11070        )
11071        .unwrap();
11072
11073        let html = writer.get_content();
11074        let head_end = html.find("</head>").expect("</head> missing");
11075        let head_section = &html[..head_end];
11076
11077        let z_pos = head_section
11078            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11079            .expect("z-widget stylesheet link missing from <head>");
11080        let a_pos = head_section
11081            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11082            .expect("a-widget stylesheet link missing from <head>");
11083
11084        assert!(
11085            z_pos < a_pos,
11086            "CSS <link> tags must follow document order (z-widget before \
11087             a-widget), not alphabetical order: {html}"
11088        );
11089    }
11090
11091    #[test]
11092    fn test_css_module_emitted_in_component_light_dom() {
11093        // CSS module <style> tags are emitted inline in the component's light DOM,
11094        // not in <head>. This keeps SSR output lean — only rendered components
11095        // get their style definitions.
11096        let mut fragments = HashMap::new();
11097        fragments.insert(
11098            "index.html".to_string(),
11099            FragmentList {
11100                fragments: vec![
11101                    WebUIFragment::raw("<html><head>".to_string()),
11102                    structural_fragment("head_end"),
11103                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11104                    WebUIFragment::component("my-card"),
11105                    WebUIFragment::raw("</my-card>".to_string()),
11106                    WebUIFragment::raw("</body></html>".to_string()),
11107                ],
11108                contains_boundary: false,
11109            },
11110        );
11111        fragments.insert(
11112            "my-card".to_string(),
11113            FragmentList {
11114                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11115                contains_boundary: false,
11116            },
11117        );
11118
11119        let mut protocol = WebUIProtocol::new(fragments);
11120        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11121        protocol
11122            .components
11123            .entry("my-card".to_string())
11124            .or_default()
11125            .css = "p{color:red}".to_string();
11126        protocol.populate_style_closures(&["index.html"]);
11127        let state = test_json!({});
11128        let mut writer = TestWriter::new();
11129
11130        handle(
11131            &protocol,
11132            &state,
11133            &RenderOptions::new("index.html", "/"),
11134            &mut writer,
11135        )
11136        .unwrap();
11137
11138        let html = writer.get_content();
11139
11140        // CSS module importmap must be INSIDE the component tag (light DOM)
11141        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11142        let css_pos = html
11143            .find(r#"<script type="importmap""#)
11144            .expect("CSS module importmap missing");
11145        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11146        assert!(
11147            css_pos > tag_open && css_pos < tag_close,
11148            "CSS module should be inside component light DOM: {html}"
11149        );
11150
11151        // <head> should NOT contain module styles
11152        let head_end = html.find("</head>").expect("</head> missing");
11153        assert!(
11154            css_pos > head_end,
11155            "CSS module should not be in <head>: {html}"
11156        );
11157    }
11158
11159    #[test]
11160    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11161        let mut protocol = WebUIProtocol::default();
11162        protocol.fragments = HashMap::from([
11163            (
11164                "index.html".to_string(),
11165                FragmentList {
11166                    fragments: vec![
11167                        WebUIFragment::raw("<legacy-card>"),
11168                        WebUIFragment::component("legacy-card"),
11169                        WebUIFragment::raw("</legacy-card>"),
11170                    ],
11171                    contains_boundary: false,
11172                },
11173            ),
11174            (
11175                "legacy-card".to_string(),
11176                FragmentList {
11177                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11178                    contains_boundary: false,
11179                },
11180            ),
11181        ]);
11182        protocol.components.insert(
11183            "legacy-card".to_string(),
11184            webui_protocol::ComponentData {
11185                css: ".legacy{color:red}".to_string(),
11186                ..Default::default()
11187            },
11188        );
11189        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11190        let mut writer = TestWriter::new();
11191
11192        let error = handle(
11193            &protocol,
11194            &test_json!({}),
11195            &RenderOptions::new("index.html", "/"),
11196            &mut writer,
11197        )
11198        .expect_err("styled protocols require closure metadata");
11199        assert!(error
11200            .to_string()
11201            .contains("component style closure metadata is required"));
11202    }
11203
11204    #[test]
11205    fn test_css_module_emitted_for_route_components() {
11206        // Route components get CSS modules emitted inline in their light DOM.
11207        let template = r#"<h1>Dashboard</h1>"#;
11208
11209        let mut fragments = HashMap::new();
11210        fragments.insert(
11211            "index.html".to_string(),
11212            FragmentList {
11213                fragments: vec![
11214                    WebUIFragment::raw("<html><head>".to_string()),
11215                    structural_fragment("head_end"),
11216                    WebUIFragment::raw("</head><body>".to_string()),
11217                    WebUIFragment::route("/", "dash-page"),
11218                    structural_fragment("body_end"),
11219                    WebUIFragment::raw("</body></html>".to_string()),
11220                ],
11221                contains_boundary: false,
11222            },
11223        );
11224        fragments.insert(
11225            "dash-page".to_string(),
11226            FragmentList {
11227                fragments: vec![WebUIFragment::raw(template.to_string())],
11228                contains_boundary: false,
11229            },
11230        );
11231
11232        let mut protocol = WebUIProtocol::new(fragments);
11233        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11234        let comp = protocol
11235            .components
11236            .entry("dash-page".to_string())
11237            .or_default();
11238        comp.css = "h1{font-size:2rem}".to_string();
11239        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11240        protocol.populate_style_closures(&["index.html"]);
11241        let state = test_json!({});
11242        let mut writer = TestWriter::new();
11243
11244        handle(
11245            &protocol,
11246            &state,
11247            &RenderOptions::new("index.html", "/"),
11248            &mut writer,
11249        )
11250        .unwrap();
11251
11252        let html = writer.get_content();
11253
11254        assert!(
11255            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11256            "Route component should have CSS module importmap with data: URI: {html}"
11257        );
11258        assert!(
11259            html.contains("<h1>Dashboard</h1>"),
11260            "Route component should render content: {html}"
11261        );
11262        let fallback = html
11263            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11264            .expect("route Module fallback");
11265        let host_open = html.find("<dash-page>").expect("route host");
11266        let content = html.find("<h1>Dashboard</h1>").expect("route content");
11267        assert!(
11268            fallback < host_open && host_open < content,
11269            "the Module fallback must precede the generated Light route host: {html}"
11270        );
11271        // The importmap belongs to the component's own Light DOM, never to the
11272        // route element. The router only treats `<link>`/`<style>` markers as
11273        // route-owned styles, so an importmap emitted as a direct route child
11274        // would be cleared on navigation and mistaken for the mounted
11275        // component. Keep it inside the host so that can never happen.
11276        let importmap = html
11277            .find(r#"<script type="importmap""#)
11278            .expect("route CSS module importmap");
11279        assert!(
11280            host_open < importmap,
11281            "the CSS module importmap must live inside the route host, not as a route child: {html}"
11282        );
11283    }
11284
11285    #[test]
11286    fn test_head_css_link_skipped_for_components_without_css() {
11287        // Regression: components without CSS files must not get <link> tags
11288        // in <head>, otherwise the browser requests a 404.
11289        let mut fragments = HashMap::new();
11290        fragments.insert(
11291            "index.html".to_string(),
11292            FragmentList {
11293                fragments: vec![
11294                    WebUIFragment::raw("<html><head>".to_string()),
11295                    structural_fragment("head_end"),
11296                    WebUIFragment::raw("</head><body>".to_string()),
11297                    WebUIFragment::component("has-css"),
11298                    WebUIFragment::component("no-css"),
11299                    structural_fragment("body_end"),
11300                    WebUIFragment::raw("</body></html>".to_string()),
11301                ],
11302                contains_boundary: false,
11303            },
11304        );
11305        fragments.insert(
11306            "has-css".to_string(),
11307            FragmentList {
11308                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11309                contains_boundary: false,
11310            },
11311        );
11312        fragments.insert(
11313            "no-css".to_string(),
11314            FragmentList {
11315                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11316                contains_boundary: false,
11317            },
11318        );
11319
11320        let mut protocol = WebUIProtocol::new(fragments);
11321        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11322
11323        // Only has-css has an external stylesheet (Link strategy)
11324        protocol
11325            .components
11326            .entry("has-css".to_string())
11327            .or_default()
11328            .css_href = "has-css.css".to_string();
11329        protocol
11330            .components
11331            .entry("no-css".to_string())
11332            .or_default()
11333            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11334        protocol.populate_style_closures(&["index.html"]);
11335
11336        let state = test_json!({});
11337        let mut writer = TestWriter::new();
11338
11339        let handler = WebUIHandler::with_plugin(|| {
11340            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11341        });
11342        handler
11343            .handle(
11344                &protocol,
11345                &state,
11346                &RenderOptions::new("index.html", "/"),
11347                &mut writer,
11348            )
11349            .unwrap();
11350
11351        let html = writer.get_content();
11352        assert!(
11353            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11354            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11355        );
11356        assert!(
11357            !html.contains("no-css.css"),
11358            "Component without CSS must NOT get a <link> in <head>: {html}"
11359        );
11360    }
11361
11362    #[test]
11363    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11364        // Simulates a page where app-shell renders cart-panel, but cart-panel
11365        // contains an <if> block with product-card inside. When the condition
11366        // is false (empty cart), product-card is NOT rendered — but it IS
11367        // reachable from the fragment graph. Its template metadata and CSS module
11368        // definition must be in the output so the client can mount it when
11369        // the <if> flips true. However, its bit must NOT be set in the
11370        // inventory — the inventory tracks what was actually rendered.
11371        let mut fragments = HashMap::new();
11372        fragments.insert(
11373            "index.html".to_string(),
11374            FragmentList {
11375                fragments: vec![
11376                    WebUIFragment::raw("<html><head>".to_string()),
11377                    structural_fragment("head_end"),
11378                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11379                    WebUIFragment::component("app-shell"),
11380                    WebUIFragment::raw("</app-shell>".to_string()),
11381                    structural_fragment("body_end"),
11382                    WebUIFragment::raw("</body></html>".to_string()),
11383                ],
11384                contains_boundary: false,
11385            },
11386        );
11387        // app-shell contains a cart panel
11388        fragments.insert(
11389            "app-shell".to_string(),
11390            FragmentList {
11391                fragments: vec![
11392                    WebUIFragment::raw("<div>Shell</div>".to_string()),
11393                    WebUIFragment::component("cart-panel"),
11394                ],
11395                contains_boundary: false,
11396            },
11397        );
11398        // cart-panel has an <if> block containing product-card
11399        fragments.insert(
11400            "cart-panel".to_string(),
11401            FragmentList {
11402                fragments: vec![
11403                    WebUIFragment::raw("<aside>".to_string()),
11404                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11405                    WebUIFragment::raw("</aside>".to_string()),
11406                ],
11407                contains_boundary: false,
11408            },
11409        );
11410        // cart-items (if block body) contains product-card
11411        fragments.insert(
11412            "cart-items".to_string(),
11413            FragmentList {
11414                fragments: vec![WebUIFragment::component("product-card")],
11415                contains_boundary: false,
11416            },
11417        );
11418        fragments.insert(
11419            "product-card".to_string(),
11420            FragmentList {
11421                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11422                contains_boundary: false,
11423            },
11424        );
11425
11426        let mut protocol = WebUIProtocol::new(fragments);
11427        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11428        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11429        for name in ["app-shell", "cart-panel", "product-card"] {
11430            let comp = protocol.components.entry(name.to_string()).or_default();
11431            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11432            comp.css = format!(".{name}{{display:block}}");
11433            if name == "cart-panel" {
11434                comp.hydration_mode = StateProjectionMode::Keys as i32;
11435                comp.hydration_keys = vec!["hasItems".to_string()];
11436            }
11437            if name == "product-card" {
11438                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11439            }
11440        }
11441        protocol.populate_style_closures(&["index.html"]);
11442
11443        // Render with hasItems=false — product-card should NOT be rendered
11444        let state = test_json!({ "hasItems": false });
11445        let mut writer = TestWriter::new();
11446
11447        let handler = WebUIHandler::with_plugin(|| {
11448            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11449        });
11450        handler
11451            .handle(
11452                &protocol,
11453                &state,
11454                &RenderOptions::new("index.html", "/"),
11455                &mut writer,
11456            )
11457            .unwrap();
11458
11459        let html = writer.get_content();
11460
11461        assert!(
11462            html.contains(r#"<script type="application/json" id="webui-data">"#),
11463            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11464        );
11465        assert!(
11466            html.contains(r#""state":{"hasItems":false}"#),
11467            "SSR state should live in the JSON data block: {html}"
11468        );
11469        assert!(
11470            html.contains(r#""inventory":"#),
11471            "SSR inventory should live in the JSON data block: {html}"
11472        );
11473        assert!(
11474            !html.contains("window.__webui={\""),
11475            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11476        );
11477        assert!(
11478            !html.contains(r#"document.getElementById("webui-data")"#),
11479            "SSR must not parse webui-data; client packages own that lazy load: {html}"
11480        );
11481        assert!(
11482            !html.contains("window.__webui=w;"),
11483            "executable bootstrap must not replace existing window.__webui registrations: {html}"
11484        );
11485        assert!(
11486            !html.contains("w.templateFns={\""),
11487            "template function emission must not replace existing templateFns registrations: {html}"
11488        );
11489        assert!(
11490            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11491            "template functions should merge into the flat templateFns registry: {html}"
11492        );
11493
11494        // product-card template IS in the output — it's a known component
11495        // whose template must be available for client-side <if> activation.
11496        assert!(
11497            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11498            "product-card template should be emitted even when unrendered: {html}"
11499        );
11500
11501        // product-card CSS module IS in the output — reachable components need
11502        // their stylesheet definitions for client-side <if> activation.
11503        assert!(
11504            html.contains(r#""product-card":"data:text/css,"#),
11505            "reachable product-card CSS module importmap should be emitted: {html}"
11506        );
11507
11508        // app-shell and cart-panel SHOULD be in the output (they were rendered)
11509        assert!(
11510            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11511            "rendered app-shell template should be emitted: {html}"
11512        );
11513        assert!(
11514            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11515            "rendered cart-panel template should be emitted: {html}"
11516        );
11517    }
11518
11519    #[test]
11520    fn component_asset_manifest_falls_back_to_body_start() {
11521        let fragments = HashMap::from([(
11522            "index.html".to_string(),
11523            FragmentList {
11524                fragments: vec![
11525                    WebUIFragment::raw("<body>".to_string()),
11526                    structural_fragment("body_start"),
11527                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11528                ],
11529                contains_boundary: false,
11530            },
11531        )]);
11532        let mut protocol = WebUIProtocol::new(fragments);
11533        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11534            root: "lazy-panel".to_string(),
11535            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11536        }];
11537        let mut writer = TestWriter::new();
11538
11539        handle(
11540            &protocol,
11541            &Value::Null,
11542            &RenderOptions::new("index.html", "/"),
11543            &mut writer,
11544        )
11545        .unwrap();
11546
11547        assert_eq!(
11548            writer.get_content(),
11549            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11550        );
11551    }
11552
11553    #[test]
11554    fn component_asset_styles_emit_once_when_body_precedes_head() {
11555        let fragments = HashMap::from([(
11556            "index.html".to_string(),
11557            FragmentList {
11558                fragments: vec![
11559                    WebUIFragment::raw("<body>".to_string()),
11560                    structural_fragment("body_start"),
11561                    WebUIFragment::raw("</body><head>".to_string()),
11562                    structural_fragment("head_end"),
11563                    WebUIFragment::raw("</head>".to_string()),
11564                ],
11565                contains_boundary: false,
11566            },
11567        )]);
11568        let mut protocol = WebUIProtocol::new(fragments);
11569        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11570            root: "lazy-panel".to_string(),
11571            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11572        }];
11573        let mut writer = TestWriter::new();
11574
11575        handle(
11576            &protocol,
11577            &Value::Null,
11578            &RenderOptions::new("index.html", "/"),
11579            &mut writer,
11580        )
11581        .unwrap();
11582
11583        assert_eq!(
11584            writer
11585                .get_content()
11586                .matches(r#"id="webui-component-assets""#)
11587                .count(),
11588            1
11589        );
11590    }
11591
11592    #[test]
11593    fn light_component_asset_styles_apply_at_head_end() {
11594        let fragments = HashMap::from([(
11595            "index.html".to_string(),
11596            FragmentList {
11597                fragments: vec![
11598                    WebUIFragment::raw("<html><head>".to_string()),
11599                    structural_fragment("head_end"),
11600                    WebUIFragment::raw("</head><body></body></html>".to_string()),
11601                ],
11602                contains_boundary: false,
11603            },
11604        )]);
11605        let mut protocol = WebUIProtocol::new(fragments);
11606        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11607        for root in ["lazy-panel", "secondary-panel"] {
11608            protocol.components.insert(
11609                root.to_string(),
11610                webui_protocol::ComponentData {
11611                    uses_shadow_dom: false,
11612                    ..Default::default()
11613                },
11614            );
11615        }
11616        protocol.component_asset_style_preloads = vec![
11617            ComponentAssetStylePreload {
11618                root: "lazy-panel".to_string(),
11619                style_hrefs: vec![
11620                    "/assets/lazy-panel.css".to_string(),
11621                    "/assets/shared.css".to_string(),
11622                ],
11623            },
11624            ComponentAssetStylePreload {
11625                root: "secondary-panel".to_string(),
11626                style_hrefs: vec![
11627                    "/assets/secondary-panel.css".to_string(),
11628                    "/assets/shared.css".to_string(),
11629                ],
11630            },
11631        ];
11632        let mut writer = TestWriter::new();
11633
11634        handle(
11635            &protocol,
11636            &Value::Null,
11637            &RenderOptions::new("index.html", "/"),
11638            &mut writer,
11639        )
11640        .unwrap();
11641
11642        let html = writer.get_content();
11643        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11644        assert_eq!(html.matches("/assets/shared.css").count(), 1);
11645        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11646        let links_end = html
11647            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11648            .expect("asset stylesheet must be emitted");
11649        let head_end = html.find("</head>").expect("head must close");
11650        assert!(links_end < head_end);
11651    }
11652
11653    // ── CSP nonce on CSS module importmap ───────────────────────────
11654    //
11655    // When `RenderOptions::with_nonce(...)` is set, every inline
11656    // `<script type="importmap">` definition emitted during SSR for a
11657    // component CSS module must include `nonce="VALUE"` so strict CSP
11658    // `script-src 'nonce-...'` policies allow it. The without-nonce case
11659    // is already covered by other CSS module tests (e.g.
11660    // `test_css_module_emitted_for_route_components`).
11661
11662    #[test]
11663    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11664        // Per-component first-render path (`emit_css_module`).
11665        let template = r#"<h1>Dashboard</h1>"#;
11666
11667        let mut fragments = HashMap::new();
11668        fragments.insert(
11669            "index.html".to_string(),
11670            FragmentList {
11671                fragments: vec![
11672                    WebUIFragment::raw("<html><head>".to_string()),
11673                    structural_fragment("head_end"),
11674                    WebUIFragment::raw("</head><body>".to_string()),
11675                    WebUIFragment::route("/", "dash-page"),
11676                    structural_fragment("body_end"),
11677                    WebUIFragment::raw("</body></html>".to_string()),
11678                ],
11679                contains_boundary: false,
11680            },
11681        );
11682        fragments.insert(
11683            "dash-page".to_string(),
11684            FragmentList {
11685                fragments: vec![WebUIFragment::raw(template.to_string())],
11686                contains_boundary: false,
11687            },
11688        );
11689
11690        let mut protocol = WebUIProtocol::new(fragments);
11691        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11692        let comp = protocol
11693            .components
11694            .entry("dash-page".to_string())
11695            .or_default();
11696        comp.css = "h1{font-size:2rem}".to_string();
11697        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11698        protocol.populate_style_closures(&["index.html"]);
11699        let state = test_json!({});
11700        let mut writer = TestWriter::new();
11701
11702        handle(
11703            &protocol,
11704            &state,
11705            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11706            &mut writer,
11707        )
11708        .unwrap();
11709
11710        let html = writer.get_content();
11711
11712        assert!(
11713            html.contains(
11714                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11715            ),
11716            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11717        );
11718    }
11719
11720    #[test]
11721    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11722        // Body-end emission path for reachable-but-unrendered components
11723        // (the second site touched by the patch). Triggered via a false
11724        // `<if>` block under hydration; requires the WebUI plugin so the
11725        // body_end hook executes.
11726        let mut fragments = HashMap::new();
11727        fragments.insert(
11728            "index.html".to_string(),
11729            FragmentList {
11730                fragments: vec![
11731                    WebUIFragment::raw("<html><head>".to_string()),
11732                    structural_fragment("head_end"),
11733                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11734                    WebUIFragment::component("app-shell"),
11735                    WebUIFragment::raw("</app-shell>".to_string()),
11736                    structural_fragment("body_end"),
11737                    WebUIFragment::raw("</body></html>".to_string()),
11738                ],
11739                contains_boundary: false,
11740            },
11741        );
11742        fragments.insert(
11743            "app-shell".to_string(),
11744            FragmentList {
11745                fragments: vec![WebUIFragment::if_cond(
11746                    ConditionExpr::identifier("hasItems"),
11747                    "cart-items",
11748                )],
11749                contains_boundary: false,
11750            },
11751        );
11752        fragments.insert(
11753            "cart-items".to_string(),
11754            FragmentList {
11755                fragments: vec![WebUIFragment::component("product-card")],
11756                contains_boundary: false,
11757            },
11758        );
11759        fragments.insert(
11760            "product-card".to_string(),
11761            FragmentList {
11762                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11763                contains_boundary: false,
11764            },
11765        );
11766
11767        let mut protocol = WebUIProtocol::new(fragments);
11768        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11769        for name in ["app-shell", "product-card"] {
11770            let comp = protocol.components.entry(name.to_string()).or_default();
11771            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11772            comp.css = format!(".{name}{{display:block}}");
11773        }
11774        protocol.populate_style_closures(&["index.html"]);
11775
11776        // Render with hasItems=false so product-card is reachable but not
11777        // rendered, forcing its CSS module emission through the body_end path.
11778        let state = test_json!({ "hasItems": false });
11779        let mut writer = TestWriter::new();
11780
11781        let handler = WebUIHandler::with_plugin(|| {
11782            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11783        });
11784        handler
11785            .handle(
11786                &protocol,
11787                &state,
11788                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11789                &mut writer,
11790            )
11791            .unwrap();
11792
11793        let html = writer.get_content();
11794
11795        assert!(
11796            html.contains(
11797                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11798            ),
11799            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11800        );
11801    }
11802
11803    #[test]
11804    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11805        let mut fragments = HashMap::new();
11806        fragments.insert(
11807            "index.html".to_string(),
11808            FragmentList {
11809                fragments: vec![
11810                    WebUIFragment::raw("<html><body><style>".to_string()),
11811                    WebUIFragment::signal("tokens.light", true),
11812                    WebUIFragment::raw("</style>".to_string()),
11813                    structural_fragment("body_end"),
11814                    WebUIFragment::raw("</body></html>".to_string()),
11815                ],
11816                contains_boundary: false,
11817            },
11818        );
11819        fragments.insert(
11820            "app-shell".to_string(),
11821            FragmentList {
11822                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
11823                contains_boundary: false,
11824            },
11825        );
11826        let index_fragments = fragments
11827            .get_mut("index.html")
11828            .expect("index fixture should exist");
11829        index_fragments
11830            .fragments
11831            .insert(1, WebUIFragment::component("app-shell"));
11832        let mut protocol = WebUIProtocol::new(fragments);
11833        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11834        // Only `name` is a hydration key. `tokens` is a server-only field
11835        // (used above to resolve SSR CSS variables) and is NOT in the component
11836        // hydration keys,
11837        // so projection MUST keep it out of the client state block.
11838        protocol.components.insert(
11839            "app-shell".to_string(),
11840            webui_protocol::ComponentData {
11841                hydration_mode: StateProjectionMode::Keys as i32,
11842                hydration_keys: vec!["name".to_string()],
11843                ..Default::default()
11844            },
11845        );
11846        let state = test_json!({
11847            "name": "Alice",
11848            "tokens": {
11849                "light": "--color-brand: red;"
11850            }
11851        });
11852        let mut writer = TestWriter::new();
11853        let handler = WebUIHandler::with_plugin(|| {
11854            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11855        });
11856        handler.handle(
11857            &protocol,
11858            &state,
11859            &RenderOptions::new("index.html", "/"),
11860            &mut writer,
11861        )?;
11862        let output = writer.get_content();
11863
11864        // SSR still reads `tokens` to resolve the inline <style>...
11865        assert!(output.contains("--color-brand: red;"));
11866        // ...but only the hydration key reaches the client state.
11867        assert!(output.contains(r#""name":"Alice""#));
11868        assert!(!output.contains(r#""tokens""#));
11869        Ok(())
11870    }
11871
11872    #[test]
11873    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11874        let mut fragments = HashMap::new();
11875        fragments.insert(
11876            "index.html".to_string(),
11877            FragmentList {
11878                fragments: vec![
11879                    WebUIFragment::raw("<html><body>"),
11880                    structural_fragment("body_end"),
11881                    WebUIFragment::raw("</body></html>"),
11882                ],
11883                contains_boundary: false,
11884            },
11885        );
11886        let protocol = WebUIProtocol::new(fragments);
11887        let state = test_json!({
11888            "client": "visible",
11889            "serverOnly": "also preserved",
11890        });
11891        let handler = WebUIHandler::with_plugin(|| {
11892            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11893        });
11894        let mut writer = TestWriter::new();
11895        handler.handle(
11896            &protocol,
11897            &state,
11898            &RenderOptions::new("index.html", "/"),
11899            &mut writer,
11900        )?;
11901        let output = writer.get_content();
11902        assert!(output.contains(r#""client":"visible""#));
11903        assert!(output.contains(r#""serverOnly":"also preserved""#));
11904        Ok(())
11905    }
11906
11907    #[test]
11908    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11909        let mut fragments = HashMap::new();
11910        fragments.insert(
11911            "index.html".to_string(),
11912            FragmentList {
11913                fragments: vec![
11914                    WebUIFragment::raw("<html><body>"),
11915                    WebUIFragment::component("app-shell"),
11916                    structural_fragment("body_end"),
11917                    WebUIFragment::raw("</body></html>"),
11918                ],
11919                contains_boundary: false,
11920            },
11921        );
11922        fragments.insert(
11923            "app-shell".to_string(),
11924            FragmentList {
11925                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11926                contains_boundary: false,
11927            },
11928        );
11929        let mut protocol = WebUIProtocol::new(fragments);
11930        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11931        protocol.components.insert(
11932            "app-shell".to_string(),
11933            webui_protocol::ComponentData {
11934                hydration_mode: StateProjectionMode::All as i32,
11935                ..Default::default()
11936            },
11937        );
11938        let state = test_json!({
11939            "known": "value",
11940            "possiblyInherited": "must not be dropped",
11941        });
11942        let handler = WebUIHandler::with_plugin(|| {
11943            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11944        });
11945        let mut writer = TestWriter::new();
11946        handler.handle(
11947            &protocol,
11948            &state,
11949            &RenderOptions::new("index.html", "/"),
11950            &mut writer,
11951        )?;
11952        let output = writer.get_content();
11953        assert!(output.contains(r#""known":"value""#));
11954        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
11955        Ok(())
11956    }
11957
11958    #[test]
11959    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
11960        let mut fragments = HashMap::new();
11961        fragments.insert(
11962            "index.html".to_string(),
11963            FragmentList {
11964                fragments: vec![
11965                    WebUIFragment::raw("<html><body>"),
11966                    WebUIFragment::component("app-shell"),
11967                    structural_fragment("body_end"),
11968                    WebUIFragment::raw("</body></html>"),
11969                ],
11970                contains_boundary: false,
11971            },
11972        );
11973        fragments.insert(
11974            "app-shell".to_string(),
11975            FragmentList {
11976                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11977                contains_boundary: false,
11978            },
11979        );
11980        let mut protocol = WebUIProtocol::new(fragments);
11981        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11982        let state = test_json!({
11983            "known": "value",
11984            "serverOnly": "must not be dropped",
11985        });
11986        let handler = WebUIHandler::with_plugin(|| {
11987            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11988        });
11989        let mut writer = TestWriter::new();
11990        handler.handle(
11991            &protocol,
11992            &state,
11993            &RenderOptions::new("index.html", "/"),
11994            &mut writer,
11995        )?;
11996        let output = writer.get_content();
11997        assert!(output.contains(r#""known":"value""#));
11998        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11999        Ok(())
12000    }
12001
12002    #[test]
12003    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12004        let mut fragments = HashMap::new();
12005        fragments.insert(
12006            "index.html".to_string(),
12007            FragmentList {
12008                fragments: vec![
12009                    WebUIFragment::raw("<html><body>"),
12010                    WebUIFragment::component("app-shell"),
12011                    structural_fragment("body_end"),
12012                    WebUIFragment::raw("</body></html>"),
12013                ],
12014                contains_boundary: false,
12015            },
12016        );
12017        fragments.insert(
12018            "app-shell".to_string(),
12019            FragmentList {
12020                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12021                contains_boundary: false,
12022            },
12023        );
12024        let mut protocol = WebUIProtocol::new(fragments);
12025        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12026        protocol.components.insert(
12027            "app-shell".to_string(),
12028            webui_protocol::ComponentData {
12029                hydration_mode: i32::MAX,
12030                ..Default::default()
12031            },
12032        );
12033        let state = test_json!({
12034            "known": "value",
12035            "serverOnly": "must not be dropped",
12036        });
12037        let handler = WebUIHandler::with_plugin(|| {
12038            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12039        });
12040        let mut writer = TestWriter::new();
12041        handler.handle(
12042            &protocol,
12043            &state,
12044            &RenderOptions::new("index.html", "/"),
12045            &mut writer,
12046        )?;
12047        let output = writer.get_content();
12048        assert!(output.contains(r#""known":"value""#));
12049        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12050        Ok(())
12051    }
12052
12053    #[test]
12054    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12055        let mut protocol = WebUIProtocol::new(HashMap::new());
12056        protocol.components.insert(
12057            "app-shell".to_string(),
12058            webui_protocol::ComponentData {
12059                navigation_keys: vec!["selected".to_string()],
12060                ..Default::default()
12061            },
12062        );
12063
12064        match collect_navigation_state(&protocol, ["app-shell"]) {
12065            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12066            StateSelection::Full | StateSelection::KeyIds(_) => {
12067                panic!("legacy navigation keys should remain owned and projected")
12068            }
12069        }
12070    }
12071
12072    #[test]
12073    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12074        let mut protocol = WebUIProtocol::new(HashMap::new());
12075        protocol.components.insert(
12076            "app-shell".to_string(),
12077            webui_protocol::ComponentData::default(),
12078        );
12079
12080        assert!(matches!(
12081            collect_navigation_state(&protocol, ["app-shell"]),
12082            StateSelection::Full
12083        ));
12084    }
12085
12086    #[test]
12087    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12088        let mut fragments = HashMap::new();
12089        fragments.insert(
12090            "index.html".to_string(),
12091            FragmentList {
12092                fragments: vec![
12093                    WebUIFragment::raw("<html><body>".to_string()),
12094                    structural_fragment("body_end"),
12095                    WebUIFragment::raw("</body></html>".to_string()),
12096                ],
12097                contains_boundary: false,
12098            },
12099        );
12100        let mut protocol = WebUIProtocol::new(fragments);
12101        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12102        let state = test_json!({
12103            "title": "Legacy state",
12104            "serverOnly": "preserved",
12105        });
12106        let handler = WebUIHandler::with_plugin(|| {
12107            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12108        });
12109
12110        let mut writer = TestWriter::new();
12111        handler.handle(
12112            &protocol,
12113            &state,
12114            &RenderOptions::new("index.html", "/"),
12115            &mut writer,
12116        )?;
12117        assert!(writer.get_content().contains(r#""state":{}"#));
12118        assert!(!writer.get_content().contains("Legacy state"));
12119        assert!(!writer.get_content().contains("preserved"));
12120        Ok(())
12121    }
12122
12123    #[test]
12124    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12125        let mut fragments = HashMap::new();
12126        fragments.insert(
12127            "index.html".to_string(),
12128            FragmentList {
12129                fragments: vec![
12130                    WebUIFragment::raw("<html><body>"),
12131                    WebUIFragment::component("items-page"),
12132                    structural_fragment("body_end"),
12133                    WebUIFragment::raw("</body></html>"),
12134                ],
12135                contains_boundary: false,
12136            },
12137        );
12138        fragments.insert(
12139            "items-page".to_string(),
12140            FragmentList {
12141                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12142                contains_boundary: false,
12143            },
12144        );
12145        let mut protocol = WebUIProtocol::new(fragments);
12146        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12147        protocol.components.insert(
12148            "items-page".to_string(),
12149            webui_protocol::ComponentData {
12150                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12151                navigation_mode: Some(StateProjectionMode::Keys as i32),
12152                navigation_keys: vec!["items".into()],
12153                ..Default::default()
12154            },
12155        );
12156        let state = test_json!({
12157            "items": ["STATE_SENTINEL"],
12158            "serverOnly": "SECRET_SENTINEL",
12159        });
12160        let handler = WebUIHandler::with_plugin(|| {
12161            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12162        });
12163        let mut writer = TestWriter::new();
12164
12165        handler.handle(
12166            &protocol,
12167            &state,
12168            &RenderOptions::new("index.html", "/"),
12169            &mut writer,
12170        )?;
12171
12172        let output = writer.get_content();
12173        assert!(output.contains(r#""state":{}"#));
12174        assert!(!output.contains("STATE_SENTINEL"));
12175        assert!(!output.contains("SECRET_SENTINEL"));
12176        Ok(())
12177    }
12178
12179    #[test]
12180    fn write_selected_state_projects_and_escapes() {
12181        // `keep` is in the sorted key set and its value contains a `</` that
12182        // must be escaped; `drop` is absent and must be projected out.
12183        let state = test_json!({
12184            "drop": "secret",
12185            "keep": "</script><b>"
12186        });
12187        let keys = ["keep"];
12188        let selection = StateSelection::Keys(keys.to_vec());
12189        let mut sink = TestWriter::new();
12190        let mut scratch = Vec::new();
12191        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12192        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12193    }
12194
12195    #[test]
12196    fn write_selected_state_non_object_projection_emits_empty_object() {
12197        let state = test_json!("scalar state has nothing hydratable");
12198        let keys: [&str; 0] = [];
12199        let selection = StateSelection::Keys(keys.to_vec());
12200        let mut sink = TestWriter::new();
12201        let mut scratch = Vec::new();
12202        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12203        assert_eq!(sink.get_content(), "{}");
12204    }
12205
12206    #[test]
12207    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12208        let state = test_json!({
12209            "keptA": 1,
12210            "keptB": 2,
12211            "serverOnlyA": 3,
12212            "serverOnlyB": 4,
12213        });
12214        let keys = ["keptA", "keptA", "keptB", "missing"];
12215        let selection = StateSelection::Keys(keys.to_vec());
12216        let mut sink = TestWriter::new();
12217        let mut scratch = Vec::new();
12218        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12219        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12220    }
12221
12222    #[test]
12223    fn write_selected_state_map_first_matches_schema_first_output() {
12224        let state = test_json!({
12225            "keptA": 1,
12226            "keptB": 2,
12227        });
12228        let keys = ["keptA", "keptB", "missingA", "missingB"];
12229        let selection = StateSelection::Keys(keys.to_vec());
12230        let mut sink = TestWriter::new();
12231        let mut scratch = Vec::new();
12232        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12233        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12234    }
12235
12236    #[test]
12237    fn write_selected_state_full_preserves_and_escapes_state() {
12238        let state = test_json!({
12239            "serverOnly": "</script><b>",
12240            "value": 42,
12241        });
12242        let mut sink = TestWriter::new();
12243        let mut scratch = Vec::new();
12244        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12245        assert_eq!(
12246            sink.get_content(),
12247            r#"{"serverOnly":"<\/script><b>","value":42}"#
12248        );
12249    }
12250
12251    #[test]
12252    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12253        let preloads = vec![ComponentAssetStylePreload {
12254            root: "lazy-panel".to_string(),
12255            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12256        }];
12257
12258        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12259
12260        assert_eq!(
12261            manifest,
12262            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12263        );
12264    }
12265
12266    #[test]
12267    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12268        // Simulates several checkpoint bootstraps serialized in one render: a
12269        // single request-local buffer must grow once for the largest payload and
12270        // then be reused for subsequent payloads without reallocating. This is
12271        // the deterministic seam behind the streaming path's allocation gate —
12272        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
12273        let mut sink = TestWriter::new();
12274        let mut scratch: Vec<u8> = Vec::new();
12275        // No allocation until serialization actually needs it.
12276        assert_eq!(scratch.capacity(), 0);
12277
12278        // The largest payload first establishes the high-water capacity.
12279        let mut large = serde_json::Map::new();
12280        for index in 0..64 {
12281            large.insert(
12282                format!("component_{index:04}"),
12283                Value::String("x".repeat(48)),
12284            );
12285        }
12286        let large = Value::Object(large);
12287        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12288        let high_water = scratch.capacity();
12289        assert!(high_water > 0);
12290
12291        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
12292        // never grows (no per-checkpoint allocation) and never shrinks.
12293        for seq in 0..8 {
12294            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12295            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12296            assert_eq!(
12297                scratch.capacity(),
12298                high_water,
12299                "scratch reallocated on checkpoint {seq}"
12300            );
12301        }
12302    }
12303
12304    #[test]
12305    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12306        let mut fragments = HashMap::new();
12307        fragments.insert(
12308            "index.html".to_string(),
12309            FragmentList {
12310                fragments: vec![
12311                    WebUIFragment::raw("<html><body>"),
12312                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12313                        path: "/".to_string(),
12314                        fragment_id: "home-page".to_string(),
12315                        exact: true,
12316                        ..Default::default()
12317                    }),
12318                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12319                        path: "/admin".to_string(),
12320                        fragment_id: "admin-page".to_string(),
12321                        exact: true,
12322                        ..Default::default()
12323                    }),
12324                    structural_fragment("body_end"),
12325                    WebUIFragment::raw("</body></html>"),
12326                ],
12327
12328                contains_boundary: false,
12329            },
12330        );
12331        fragments.insert(
12332            "home-page".to_string(),
12333            FragmentList {
12334                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12335                contains_boundary: false,
12336            },
12337        );
12338        fragments.insert(
12339            "admin-page".to_string(),
12340            FragmentList {
12341                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12342                contains_boundary: false,
12343            },
12344        );
12345
12346        let mut protocol = WebUIProtocol::new(fragments);
12347        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12348        protocol.components.insert(
12349            "home-page".to_string(),
12350            webui_protocol::ComponentData {
12351                template_json: "{}".to_string(),
12352                hydration_mode: StateProjectionMode::Keys as i32,
12353                hydration_keys: vec!["homeTitle".to_string()],
12354                ..Default::default()
12355            },
12356        );
12357        protocol.components.insert(
12358            "admin-page".to_string(),
12359            webui_protocol::ComponentData {
12360                template_json: "{}".to_string(),
12361                hydration_mode: StateProjectionMode::Keys as i32,
12362                hydration_keys: vec!["adminToken".to_string()],
12363                ..Default::default()
12364            },
12365        );
12366        let state = test_json!({
12367            "homeTitle": "Welcome",
12368            "adminToken": "TOP_SECRET_SENTINEL",
12369        });
12370        let handler = WebUIHandler::with_plugin(|| {
12371            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12372        });
12373        let mut writer = TestWriter::new();
12374        handler.handle(
12375            &protocol,
12376            &state,
12377            &RenderOptions::new("index.html", "/"),
12378            &mut writer,
12379        )?;
12380        let output = writer.get_content();
12381        assert!(output.contains(r#""homeTitle":"Welcome""#));
12382        assert!(!output.contains("TOP_SECRET_SENTINEL"));
12383        assert!(!output.contains(r#""adminToken""#));
12384        Ok(())
12385    }
12386
12387    #[test]
12388    fn test_component_attr_name_aria() {
12389        // component_attr_name correctly maps ARIA attributes via the shared table
12390        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12391        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12392        assert_eq!(
12393            component_attr_name("aria-activedescendant"),
12394            "ariaActiveDescendant"
12395        );
12396        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12397        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12398    }
12399
12400    #[test]
12401    fn test_component_attr_name_html_global() {
12402        assert_eq!(component_attr_name("readonly"), "readOnly");
12403        assert_eq!(component_attr_name("tabindex"), "tabIndex");
12404        assert_eq!(component_attr_name("accesskey"), "accessKey");
12405        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12406        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12407        assert_eq!(component_attr_name("inputmode"), "inputMode");
12408        assert_eq!(component_attr_name("maxlength"), "maxLength");
12409        assert_eq!(component_attr_name("minlength"), "minLength");
12410        assert_eq!(component_attr_name("novalidate"), "noValidate");
12411        assert_eq!(component_attr_name("formaction"), "formAction");
12412        assert_eq!(component_attr_name("ismap"), "isMap");
12413        assert_eq!(component_attr_name("usemap"), "useMap");
12414    }
12415
12416    #[test]
12417    fn test_component_attr_name_strips_colon() {
12418        assert_eq!(component_attr_name(":readonly"), "readOnly");
12419        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12420        assert_eq!(component_attr_name(":data-title"), "dataTitle");
12421    }
12422
12423    #[test]
12424    fn test_component_attr_name_regular() {
12425        assert_eq!(component_attr_name("data-title"), "dataTitle");
12426        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12427        assert_eq!(component_attr_name("simple"), "simple");
12428    }
12429
12430    // ── allowed_query SSR emission tests ─────────────────────────────
12431
12432    fn make_query_route_protocol() -> WebUIProtocol {
12433        use webui_protocol::WebUiFragmentRoute;
12434
12435        let mut fragments = HashMap::new();
12436
12437        fragments.insert(
12438            "index.html".to_string(),
12439            FragmentList {
12440                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12441                    path: "/".into(),
12442                    fragment_id: "app-shell".into(),
12443                    exact: false,
12444                    children: vec![
12445                        WebUiFragmentRoute {
12446                            path: "compose".into(),
12447                            fragment_id: "compose-page".into(),
12448                            exact: true,
12449                            allowed_query: "action,to,subject".into(),
12450                            keep_alive: false,
12451                            ..Default::default()
12452                        },
12453                        WebUiFragmentRoute {
12454                            path: "settings".into(),
12455                            fragment_id: "settings-page".into(),
12456                            exact: true,
12457                            keep_alive: false,
12458                            ..Default::default()
12459                        },
12460                    ],
12461                    keep_alive: false,
12462                    ..Default::default()
12463                })],
12464
12465                contains_boundary: false,
12466            },
12467        );
12468
12469        fragments.insert(
12470            "app-shell".to_string(),
12471            FragmentList {
12472                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12473                contains_boundary: false,
12474            },
12475        );
12476        fragments.insert(
12477            "compose-page".to_string(),
12478            FragmentList {
12479                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12480                contains_boundary: false,
12481            },
12482        );
12483        fragments.insert(
12484            "settings-page".to_string(),
12485            FragmentList {
12486                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12487                contains_boundary: false,
12488            },
12489        );
12490
12491        WebUIProtocol::new(fragments)
12492    }
12493
12494    #[test]
12495    fn test_matched_route_omits_query_attr_from_dom() {
12496        let protocol = make_query_route_protocol();
12497        let state = test_json!({});
12498        let handler = WebUIHandler::new();
12499        let mut writer = TestWriter::new();
12500
12501        handler
12502            .handle(
12503                &protocol,
12504                &state,
12505                &RenderOptions::new("index.html", "/compose"),
12506                &mut writer,
12507            )
12508            .expect("render failed");
12509
12510        let html = writer.get_content();
12511        // query attr is no longer in DOM — it's in the SSR chain JSON instead
12512        assert!(
12513            !html.contains(r#"query="action,to,subject""#),
12514            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12515        );
12516    }
12517
12518    #[test]
12519    fn test_nonmatched_route_omits_query_attr_from_dom() {
12520        let protocol = make_query_route_protocol();
12521        let state = test_json!({});
12522        let handler = WebUIHandler::new();
12523        let mut writer = TestWriter::new();
12524
12525        handler
12526            .handle(
12527                &protocol,
12528                &state,
12529                &RenderOptions::new("index.html", "/settings"),
12530                &mut writer,
12531            )
12532            .expect("render failed");
12533
12534        let html = writer.get_content();
12535        // query attr should not be on hidden siblings either
12536        assert!(
12537            !html.contains(r#"query="#),
12538            "hidden route should not have query attr: {html}"
12539        );
12540    }
12541
12542    #[test]
12543    fn test_route_without_query_has_no_query_attr() {
12544        let protocol = make_query_route_protocol();
12545        let state = test_json!({});
12546        let handler = WebUIHandler::new();
12547        let mut writer = TestWriter::new();
12548
12549        handler
12550            .handle(
12551                &protocol,
12552                &state,
12553                &RenderOptions::new("index.html", "/settings"),
12554                &mut writer,
12555            )
12556            .expect("render failed");
12557
12558        let html = writer.get_content();
12559        // Find the settings route element and verify it has no query attr
12560        let settings_idx = html
12561            .find(r#"component="settings-page""#)
12562            .expect("settings route should exist");
12563        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12564        assert!(
12565            !settings_tag.contains("query="),
12566            "route without allowed_query should not emit query attr: {settings_tag}"
12567        );
12568    }
12569
12570    // ── Per-render head_inject / body_inject (replaces the byte-scanner
12571    //    InjectingStreamingWriter approach with structural signal-based
12572    //    injection) ───────────────────────────────────────────────────
12573
12574    fn build_head_body_protocol() -> WebUIProtocol {
12575        let mut fragments = HashMap::new();
12576        fragments.insert(
12577            "index.html".to_string(),
12578            FragmentList {
12579                fragments: vec![
12580                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12581                    structural_fragment("head_end"),
12582                    WebUIFragment::raw("</head><body>hello".to_string()),
12583                    structural_fragment("body_end"),
12584                    WebUIFragment::raw("</body></html>".to_string()),
12585                ],
12586                contains_boundary: false,
12587            },
12588        );
12589        WebUIProtocol::new(fragments)
12590    }
12591
12592    #[test]
12593    fn head_inject_emits_at_head_end_boundary() {
12594        let protocol = build_head_body_protocol();
12595        let state = test_json!({});
12596        let mut writer = TestWriter::new();
12597        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12598        handle(&protocol, &state, &opts, &mut writer).unwrap();
12599        let html = writer.get_content();
12600        // The inject must appear immediately before `</head>`.
12601        let inject_idx = html
12602            .find("<link rel=preload>")
12603            .expect("inject HTML missing");
12604        let head_close_idx = html.find("</head>").expect("</head> missing");
12605        assert!(
12606            inject_idx < head_close_idx,
12607            "head_inject must appear before </head>: {html}"
12608        );
12609        // No duplicate.
12610        assert_eq!(html.matches("<link rel=preload>").count(), 1);
12611    }
12612
12613    #[test]
12614    fn component_render_policy_css_emits_once_in_head_with_nonce() {
12615        let mut protocol = build_head_body_protocol();
12616        protocol.component_render_css = concat!(
12617            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12618            "contain-intrinsic-block-size:auto 18rem;}"
12619        )
12620        .to_string();
12621        let state = test_json!({});
12622        let mut writer = TestWriter::new();
12623        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12624        handle(&protocol, &state, &opts, &mut writer).unwrap();
12625        let html = writer.get_content();
12626
12627        let style = concat!(
12628            r#"<style data-webui-render-policy nonce="test-nonce">"#,
12629            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12630            "contain-intrinsic-block-size:auto 18rem;}</style>"
12631        );
12632        let style_index = html.find(style).expect("render policy style missing");
12633        let head_close = html.find("</head>").expect("</head> missing");
12634        assert!(style_index < head_close);
12635        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12636    }
12637
12638    #[test]
12639    fn body_inject_emits_at_body_end_boundary() {
12640        let protocol = build_head_body_protocol();
12641        let state = test_json!({});
12642        let mut writer = TestWriter::new();
12643        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12644        handle(&protocol, &state, &opts, &mut writer).unwrap();
12645        let html = writer.get_content();
12646        let inject_idx = html
12647            .find("<script>lr</script>")
12648            .expect("inject HTML missing");
12649        let body_close_idx = html.find("</body>").expect("</body> missing");
12650        assert!(
12651            inject_idx < body_close_idx,
12652            "body_inject must appear before </body>: {html}"
12653        );
12654        assert_eq!(html.matches("<script>lr</script>").count(), 1);
12655    }
12656
12657    // ── Reserved `$webui` state inject namespace ──────────────────────
12658
12659    /// Protocol carrying all three structural boundaries, so `body_start`
12660    /// placement can be asserted alongside `head_end` / `body_end`.
12661    fn build_all_boundaries_protocol() -> WebUIProtocol {
12662        let mut fragments = HashMap::new();
12663        fragments.insert(
12664            "index.html".to_string(),
12665            FragmentList {
12666                fragments: vec![
12667                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12668                    structural_fragment("head_end"),
12669                    WebUIFragment::raw("</head><body>".to_string()),
12670                    structural_fragment("body_start"),
12671                    WebUIFragment::raw("hello".to_string()),
12672                    structural_fragment("body_end"),
12673                    WebUIFragment::raw("</body></html>".to_string()),
12674                ],
12675                contains_boundary: false,
12676            },
12677        );
12678        WebUIProtocol::new(fragments)
12679    }
12680
12681    fn state_inject_options<'a>() -> RenderOptions<'a> {
12682        RenderOptions::new("index.html", "/")
12683    }
12684
12685    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12686        let mut writer = TestWriter::new();
12687        handle(protocol, state, options, &mut writer).unwrap();
12688        writer.get_content().to_string()
12689    }
12690
12691    #[test]
12692    fn state_inject_emits_at_every_structural_boundary() {
12693        let protocol = build_all_boundaries_protocol();
12694        let state = test_json!({
12695            "$webui": {
12696                "headEnd": "<meta name=he>",
12697                "bodyStart": "<span id=bs></span>",
12698                "bodyEnd": "<script>be</script>",
12699            }
12700        });
12701        let html = render_with(&protocol, &state, &state_inject_options());
12702
12703        let head_end = html.find("<meta name=he>").expect("headEnd missing");
12704        let head_close = html.find("</head>").expect("</head> missing");
12705        let body_open = html.find("<body>").expect("<body> missing");
12706        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12707        let hello = html.find("hello").expect("body content missing");
12708        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12709        let body_close = html.find("</body>").expect("</body> missing");
12710
12711        assert!(
12712            head_end < head_close,
12713            "headEnd must precede </head>: {html}"
12714        );
12715        assert!(
12716            body_open < body_start && body_start < hello,
12717            "bodyStart must sit immediately after <body>: {html}"
12718        );
12719        assert!(
12720            hello < body_end && body_end < body_close,
12721            "bodyEnd must precede </body>: {html}"
12722        );
12723
12724        for needle in [
12725            "<meta name=he>",
12726            "<span id=bs></span>",
12727            "<script>be</script>",
12728        ] {
12729            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12730        }
12731    }
12732
12733    #[test]
12734    fn state_inject_follows_render_options_inject() {
12735        let protocol = build_all_boundaries_protocol();
12736        let state = test_json!({
12737            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12738        });
12739        let options = RenderOptions::new("index.html", "/")
12740            .with_head_inject("<!--opt-he-->")
12741            .with_body_inject("<!--opt-be-->");
12742        let html = render_with(&protocol, &state, &options);
12743
12744        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12745        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12746        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12747        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12748
12749        assert!(
12750            opt_he < state_he,
12751            "RenderOptions head_inject must precede the state-supplied value: {html}"
12752        );
12753        assert!(
12754            opt_be < state_be,
12755            "RenderOptions body_inject must precede the state-supplied value: {html}"
12756        );
12757    }
12758
12759    #[test]
12760    fn malformed_state_inject_values_are_inert() {
12761        let protocol = build_all_boundaries_protocol();
12762        // Absent key, wrong container type, and per-member null / empty /
12763        // non-string values must all render without output and without error.
12764        for state in [
12765            test_json!({}),
12766            test_json!({ "$webui": "not-an-object" }),
12767            test_json!({ "$webui": [] }),
12768            test_json!({ "$webui": null }),
12769            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12770            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12771        ] {
12772            let html = render_with(&protocol, &state, &state_inject_options());
12773            assert!(
12774                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12775                "malformed reserved state must render the document unchanged: {html}"
12776            );
12777            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12778            assert!(!html.contains("42"), "non-string member leaked: {html}");
12779        }
12780    }
12781
12782    #[test]
12783    fn state_inject_never_reaches_the_hydration_payload() {
12784        let protocol = build_all_boundaries_protocol();
12785        let state = test_json!({
12786            "visible": "keep",
12787            "$webui": { "bodyEnd": "<script>secret</script>" }
12788        });
12789        let handler = WebUIHandler::with_plugin(|| {
12790            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12791        });
12792        let runtime = Protocol::new(protocol.clone());
12793        let mut writer = TestWriter::new();
12794        handler
12795            .render(&runtime, &state, &state_inject_options(), &mut writer)
12796            .unwrap();
12797        let html = writer.get_content();
12798
12799        let data_start = html
12800            .find(r#"<script type="application/json" id="webui-data""#)
12801            .expect("hydration block missing");
12802        let data_end = html[data_start..]
12803            .find("</script>")
12804            .map(|offset| data_start + offset)
12805            .expect("hydration block never closes");
12806        let payload = &html[data_start..data_end];
12807        assert!(
12808            !payload.contains("$webui"),
12809            "reserved key must be stripped from the hydration payload: {payload}"
12810        );
12811        assert!(
12812            payload.contains("visible"),
12813            "ordinary state must survive the filter: {payload}"
12814        );
12815        // Per the documented precedence the injected HTML is emitted after
12816        // the built-in hydration block, and still before `</body>`.
12817        let inject = html
12818            .find("<script>secret</script>")
12819            .expect("inject missing");
12820        let body_close = html.find("</body>").expect("</body> missing");
12821        assert!(
12822            data_end < inject && inject < body_close,
12823            "state bodyEnd must follow the hydration block and precede </body>: {html}"
12824        );
12825    }
12826
12827    #[test]
12828    fn projected_hydration_strips_reserved_state_inject_key() {
12829        let mut fragments = HashMap::new();
12830        fragments.insert(
12831            "index.html".to_string(),
12832            FragmentList {
12833                fragments: vec![
12834                    WebUIFragment::raw("<html><body>"),
12835                    WebUIFragment::component("app-shell"),
12836                    structural_fragment("body_end"),
12837                    WebUIFragment::raw("</body></html>"),
12838                ],
12839                contains_boundary: false,
12840            },
12841        );
12842        fragments.insert(
12843            "app-shell".to_string(),
12844            FragmentList {
12845                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12846                contains_boundary: false,
12847            },
12848        );
12849        let mut document = WebUIProtocol::new(fragments);
12850        document.initial_state_strategy = InitialStateStrategy::Components as i32;
12851        document.components.insert(
12852            "app-shell".to_string(),
12853            webui_protocol::ComponentData {
12854                hydration_mode: StateProjectionMode::Keys as i32,
12855                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12856                ..Default::default()
12857            },
12858        );
12859        let state = test_json!({
12860            "$webui": { "bodyEnd": "<script>secret</script>" },
12861            "serverOnly": "drop",
12862            "visible": "keep",
12863        });
12864        let handler = WebUIHandler::with_plugin(|| {
12865            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12866        });
12867        let mut writer = TestWriter::new();
12868
12869        handler
12870            .render(
12871                &Protocol::new(document),
12872                &state,
12873                &RenderOptions::new("index.html", "/"),
12874                &mut writer,
12875            )
12876            .unwrap();
12877        let html = writer.get_content();
12878        let data_start = html
12879            .find(r#"<script type="application/json" id="webui-data""#)
12880            .expect("hydration block missing");
12881        let data_end = html[data_start..]
12882            .find("</script>")
12883            .map(|offset| data_start + offset)
12884            .expect("hydration block never closes");
12885        let payload = &html[data_start..data_end];
12886
12887        assert!(
12888            !payload.contains(STATE_INJECT_KEY),
12889            "reserved key leaked: {payload}"
12890        );
12891        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12892        assert!(!payload.contains("serverOnly"), "{payload}");
12893        assert!(html.contains("<script>secret</script>"), "{html}");
12894    }
12895
12896    #[test]
12897    fn write_selected_state_strips_reserved_key_from_full_state() {
12898        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12899        let mut sink = TestWriter::new();
12900        let mut scratch = Vec::new();
12901        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12902        let json = sink.get_content();
12903        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12904        assert!(
12905            json.contains("\"a\":1") && json.contains("\"z\":2"),
12906            "{json}"
12907        );
12908    }
12909
12910    #[test]
12911    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12912        // The streaming record projects through interned key IDs, so the
12913        // reserved inject key must be filtered on that path too.
12914        let mut protocol = WebUIProtocol::new(HashMap::new());
12915        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12916        protocol.fragments.insert(
12917            "keep-card".to_string(),
12918            webui_protocol::FragmentList::default(),
12919        );
12920        protocol.components.insert(
12921            "keep-card".to_string(),
12922            webui_protocol::ComponentData {
12923                hydration_mode: StateProjectionMode::Keys as i32,
12924                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12925                ..Default::default()
12926            },
12927        );
12928        let protocol = Protocol::new(protocol);
12929        let index = protocol.component_reachability();
12930        let component = protocol.component_index()["keep-card"];
12931        let mut ids = Vec::new();
12932        assert!(!collect_hydration_key_ids_into(
12933            protocol.protocol(),
12934            index,
12935            [component],
12936            &mut ids
12937        ));
12938
12939        let state = test_json!({
12940            "$webui": { "bodyEnd": "<b>x</b>" },
12941            "keep": 1,
12942        });
12943        let mut sink = TestWriter::new();
12944        let mut scratch = Vec::new();
12945
12946        write_selected_state(
12947            &mut sink,
12948            &mut scratch,
12949            &state,
12950            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
12951        )
12952        .unwrap();
12953
12954        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
12955    }
12956
12957    #[test]
12958    fn write_selected_state_full_is_unchanged_without_reserved_key() {
12959        let state = test_json!({ "a": 1, "z": 2 });
12960        let mut sink = TestWriter::new();
12961        let mut scratch = Vec::new();
12962        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12963        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
12964    }
12965
12966    #[test]
12967    fn body_start_hook_dedupes_on_malformed_protocol() {
12968        let mut fragments = HashMap::new();
12969        fragments.insert(
12970            "index.html".to_string(),
12971            FragmentList {
12972                fragments: vec![
12973                    WebUIFragment::raw("<html><head></head><body>".to_string()),
12974                    structural_fragment("body_start"),
12975                    structural_fragment("body_start"),
12976                    WebUIFragment::raw("</body></html>".to_string()),
12977                ],
12978                contains_boundary: false,
12979            },
12980        );
12981        let protocol = WebUIProtocol::new(fragments);
12982        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
12983        let html = render_with(&protocol, &state, &state_inject_options());
12984        assert_eq!(
12985            html.matches("<i>once</i>").count(),
12986            1,
12987            "duplicate body_start signals must not duplicate the inject: {html}"
12988        );
12989    }
12990
12991    #[test]
12992    fn injects_are_no_op_when_unset() {
12993        let protocol = build_head_body_protocol();
12994        let state = test_json!({});
12995        let mut writer = TestWriter::new();
12996        handle(
12997            &protocol,
12998            &state,
12999            &RenderOptions::new("index.html", "/"),
13000            &mut writer,
13001        )
13002        .unwrap();
13003        let html = writer.get_content();
13004        assert!(!html.contains("<link rel=preload>"));
13005        assert!(!html.contains("<script>lr</script>"));
13006    }
13007
13008    #[test]
13009    fn empty_inject_string_treated_as_unset() {
13010        let protocol = build_head_body_protocol();
13011        let state = test_json!({});
13012        let mut writer = TestWriter::new();
13013        let opts = RenderOptions::new("index.html", "/")
13014            .with_head_inject("")
13015            .with_body_inject("");
13016        handle(&protocol, &state, &opts, &mut writer).unwrap();
13017        // No injection happens — empty strings are normalised to None
13018        // by the builder, so the output is identical to the no-options case.
13019        let html = writer.get_content();
13020        assert!(html.contains("</head>"));
13021        assert!(html.contains("</body>"));
13022    }
13023
13024    #[test]
13025    fn inject_html_is_passed_through_verbatim() {
13026        // The handler does NOT escape the inject string — hosts pass
13027        // raw HTML they trust. This test pins that contract: a `<` in
13028        // the inject is emitted as-is, not encoded as `&lt;`.
13029        let protocol = build_head_body_protocol();
13030        let state = test_json!({});
13031        let mut writer = TestWriter::new();
13032        let opts =
13033            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13034        handle(&protocol, &state, &opts, &mut writer).unwrap();
13035        assert!(writer.get_content().contains("<script>var x=1;</script>"));
13036    }
13037
13038    /// Both injects fire and appear at the correct structural
13039    /// positions. Critically, this is robust against `</head>` /
13040    /// `</body>` literals appearing elsewhere in the document — the
13041    /// signal-based emitter cannot mis-fire on byte patterns inside
13042    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
13043    /// previous byte-scanner could).
13044    #[test]
13045    fn injects_robust_against_marker_literals_in_content() {
13046        let mut fragments = HashMap::new();
13047        // The body intentionally contains `</body>` and `</head>`
13048        // literals before the actual structural close — these came
13049        // from a (hypothetical) iframe srcdoc or comment.
13050        fragments.insert(
13051            "index.html".to_string(),
13052            FragmentList {
13053                fragments: vec![
13054                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13055                    structural_fragment("head_end"),
13056                    WebUIFragment::raw(
13057                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13058                    ),
13059                    structural_fragment("body_end"),
13060                    WebUIFragment::raw("</body></html>".to_string()),
13061                ],
13062                contains_boundary: false,
13063            },
13064        );
13065        let protocol = WebUIProtocol::new(fragments);
13066        let state = test_json!({});
13067        let mut writer = TestWriter::new();
13068        let opts = RenderOptions::new("index.html", "/")
13069            .with_head_inject("<HEAD-INJ>")
13070            .with_body_inject("<BODY-INJ>");
13071        handle(&protocol, &state, &opts, &mut writer).unwrap();
13072        let html = writer.get_content();
13073        // The head inject sits between `<title>x</title>` and the
13074        // first `</head>` — the structural one, not the comment one.
13075        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13076        let head_close_idx = html.find("</head>").expect("</head> missing");
13077        assert!(head_inj_idx < head_close_idx);
13078        // The body inject sits before the structural `</body>` — NOT
13079        // before the `</body>` literal in the comment (which would
13080        // require the inject to appear inside `<p>hi</p>` somewhere).
13081        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13082        // Find the LAST `</body>` (the structural one).
13083        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13084        assert!(
13085            body_inj_idx < body_close_idx,
13086            "body_inject must precede the structural </body>: {html}"
13087        );
13088        // And the comment is preserved verbatim.
13089        assert!(html.contains("<!-- </body> </head> -->"));
13090    }
13091
13092    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13093    /// same render. Each fires at the correct structural boundary and
13094    /// neither leaks into the other's region.
13095    #[test]
13096    fn both_injects_fire_at_correct_boundaries() {
13097        let protocol = build_head_body_protocol();
13098        let state = test_json!({});
13099        let mut writer = TestWriter::new();
13100        let opts = RenderOptions::new("index.html", "/")
13101            .with_head_inject("<META-HEAD>")
13102            .with_body_inject("<SCRIPT-BODY>");
13103        handle(&protocol, &state, &opts, &mut writer).unwrap();
13104        let html = writer.get_content();
13105        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13106        let head_close = html.find("</head>").expect("</head> missing");
13107        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13108        let body_close = html.find("</body>").expect("</body> missing");
13109        assert!(head_idx < head_close, "head_inject before </head>");
13110        assert!(head_close < body_idx, "body_inject after </head>");
13111        assert!(body_idx < body_close, "body_inject before </body>");
13112        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13113        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13114    }
13115
13116    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13117    /// emitting `head_end` and `body_end` more than once must NOT
13118    /// duplicate the host inject HTML. Without the dedup guard,
13119    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13120    /// 1000 duplicate signals = 1 GiB output).
13121    #[test]
13122    fn injects_dedupe_against_duplicate_signals() {
13123        let mut fragments = HashMap::new();
13124        fragments.insert(
13125            "index.html".to_string(),
13126            FragmentList {
13127                fragments: vec![
13128                    WebUIFragment::raw("<html><head>".to_string()),
13129                    structural_fragment("head_end"),
13130                    structural_fragment("head_end"), // duplicate
13131                    structural_fragment("head_end"), // triplicate
13132                    WebUIFragment::raw("</head><body>".to_string()),
13133                    structural_fragment("body_end"),
13134                    structural_fragment("body_end"), // duplicate
13135                    WebUIFragment::raw("</body></html>".to_string()),
13136                ],
13137                contains_boundary: false,
13138            },
13139        );
13140        let protocol = WebUIProtocol::new(fragments);
13141        let state = test_json!({});
13142        let mut writer = TestWriter::new();
13143        let opts = RenderOptions::new("index.html", "/")
13144            .with_head_inject("<HINJ>")
13145            .with_body_inject("<BINJ>");
13146        handle(&protocol, &state, &opts, &mut writer).unwrap();
13147        let html = writer.get_content();
13148        assert_eq!(
13149            html.matches("<HINJ>").count(),
13150            1,
13151            "head_inject must emit exactly once even with duplicate head_end signals"
13152        );
13153        assert_eq!(
13154            html.matches("<BINJ>").count(),
13155            1,
13156            "body_inject must emit exactly once even with duplicate body_end signals"
13157        );
13158    }
13159
13160    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13161    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13162    /// never fire). Verifies the injects are no-ops, not panics.
13163    #[test]
13164    fn injects_no_op_when_no_head_or_body_signals() {
13165        let mut fragments = HashMap::new();
13166        fragments.insert(
13167            "index.html".to_string(),
13168            FragmentList {
13169                fragments: vec![WebUIFragment::raw(
13170                    "<my-component>hi</my-component>".to_string(),
13171                )],
13172                contains_boundary: false,
13173            },
13174        );
13175        let protocol = WebUIProtocol::new(fragments);
13176        let state = test_json!({});
13177        let mut writer = TestWriter::new();
13178        let opts = RenderOptions::new("index.html", "/")
13179            .with_head_inject("<HINJ>")
13180            .with_body_inject("<BINJ>");
13181        handle(&protocol, &state, &opts, &mut writer).unwrap();
13182        let html = writer.get_content();
13183        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13184        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13185        assert!(html.contains("<my-component>"));
13186    }
13187
13188    /// Coverage-19: the handler's `&self` is shared across threads.
13189    /// Two concurrent renders with different inject values must NOT
13190    /// cross-contaminate (each thread sees only its own inject).
13191    /// Per-render mutable state lives on the `WebUIProcessContext`,
13192    /// which is stack-allocated per call.
13193    #[test]
13194    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13195        let protocol = std::sync::Arc::new(build_head_body_protocol());
13196        let state = std::sync::Arc::new(test_json!({}));
13197        let handler = std::sync::Arc::new(WebUIHandler::new());
13198
13199        const N_THREADS: usize = 16;
13200        let mut handles = Vec::with_capacity(N_THREADS);
13201        for tid in 0..N_THREADS {
13202            let h = std::sync::Arc::clone(&handler);
13203            let p = std::sync::Arc::clone(&protocol);
13204            let s = std::sync::Arc::clone(&state);
13205            handles.push(std::thread::spawn(move || {
13206                let head = format!("<HEAD-T{tid}>");
13207                let body = format!("<BODY-T{tid}>");
13208                let mut writer = TestWriter::new();
13209                let opts = RenderOptions::new("index.html", "/")
13210                    .with_head_inject(&head)
13211                    .with_body_inject(&body);
13212                h.handle(&p, &s, &opts, &mut writer).unwrap();
13213                let html = writer.get_content();
13214                // Must contain my own injects exactly once.
13215                assert_eq!(html.matches(&head).count(), 1);
13216                assert_eq!(html.matches(&body).count(), 1);
13217                // Must NOT contain any other thread's inject.
13218                for other in 0..N_THREADS {
13219                    if other == tid {
13220                        continue;
13221                    }
13222                    let other_head = format!("<HEAD-T{other}>");
13223                    let other_body = format!("<BODY-T{other}>");
13224                    assert!(
13225                        !html.contains(&other_head),
13226                        "tid {tid} saw {other}'s head_inject"
13227                    );
13228                    assert!(
13229                        !html.contains(&other_body),
13230                        "tid {tid} saw {other}'s body_inject"
13231                    );
13232                }
13233            }));
13234        }
13235        for h in handles {
13236            h.join().expect("worker panicked");
13237        }
13238    }
13239
13240    /// Coverage-17: a large (1 MiB) head_inject must round-trip
13241    /// correctly without panic, truncation, or excessive overhead.
13242    /// (No size cap is enforced by the handler — the host owns the
13243    /// safety contract; see `with_head_inject` doc comment.)
13244    #[test]
13245    fn large_inject_roundtrips_without_truncation() {
13246        let protocol = build_head_body_protocol();
13247        let state = test_json!({});
13248        let mut writer = TestWriter::new();
13249        let big = "x".repeat(1024 * 1024);
13250        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13251        handle(&protocol, &state, &opts, &mut writer).unwrap();
13252        let html = writer.get_content();
13253        assert!(
13254            html.contains(&big),
13255            "large head_inject must be present verbatim ({} bytes)",
13256            big.len()
13257        );
13258        // Sanity: only one copy.
13259        assert_eq!(html.matches(&big).count(), 1);
13260    }
13261
13262    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
13263    /// matching the empty-string semantics of `with_head_inject` /
13264    /// `with_body_inject`. An empty content attribute is browser-
13265    /// ignored noise.
13266    #[test]
13267    fn empty_nonce_treated_as_unset() {
13268        let protocol = build_head_body_protocol();
13269        let state = test_json!({});
13270        let mut writer = TestWriter::new();
13271        let opts = RenderOptions::new("index.html", "/").with_nonce("");
13272        handle(&protocol, &state, &opts, &mut writer).unwrap();
13273        assert!(
13274            !writer.get_content().contains("webui-nonce"),
13275            "empty nonce must not emit <meta name=\"webui-nonce\">"
13276        );
13277    }
13278
13279    /// Regression for the bug Akrosh caught: the `pub` fields on
13280    /// `RenderOptions` let a caller bypass the `with_*` builder
13281    /// normalisation, e.g.:
13282    ///
13283    /// ```ignore
13284    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
13285    /// ```
13286    ///
13287    /// Without defensive normalisation at handler init, this would
13288    /// emit `<script nonce="">` on every inline script. Under a
13289    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
13290    /// empty nonce is a HARD CSP failure that blocks every inline
13291    /// script — a complete inline-script-execution outage.
13292    ///
13293    /// The handler now treats `Some("")` identically to `None` for
13294    /// all three injection points (nonce / head_inject / body_inject)
13295    /// regardless of how the option was populated.
13296    #[test]
13297    fn empty_field_bypass_is_normalised_at_handler_init() {
13298        let protocol = build_head_body_protocol();
13299        let state = test_json!({});
13300
13301        // Bypass the `with_nonce` builder by writing the field directly.
13302        let opts_with_empty_nonce = RenderOptions {
13303            nonce: Some(""),
13304            ..RenderOptions::new("index.html", "/")
13305        };
13306        let mut writer = TestWriter::new();
13307        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13308        let html = writer.get_content();
13309        assert!(
13310            !html.contains("webui-nonce"),
13311            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13312        );
13313        assert!(
13314            !html.contains("nonce=\"\""),
13315            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13316        );
13317
13318        // Same defence for inject fields.
13319        let opts_with_empty_injects = RenderOptions {
13320            head_inject: Some(""),
13321            body_inject: Some(""),
13322            ..RenderOptions::new("index.html", "/")
13323        };
13324        let mut writer = TestWriter::new();
13325        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13326        // No assertion needed beyond "doesn't panic and doesn't emit
13327        // empty inject markers" — the head_end / body_end paths must
13328        // treat the empty inject as no-op the same way the builder does.
13329    }
13330
13331    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
13332    /// path optimisation (insert key once + `get_mut`-swap value
13333    /// in-place) was suspected of corrupting the outer scope when a
13334    /// nested `<for>` loop reuses the same variable name. This test
13335    /// proves the optimisation is correct under that condition by
13336    /// requiring the outer `item` to be visible before, between, and
13337    /// after the inner loop, with its value preserved across inner
13338    /// iterations.
13339    ///
13340    /// Trace through the optimisation on `outer = [A, B]`,
13341    /// `inner = [X, Y]` with both loops using `item` as the variable:
13342    ///
13343    ///   outer pre-insert "item": Null
13344    ///   iter 1: get_mut → write A
13345    ///     emit "outer:A"
13346    ///     enter inner: saved = remove("item") = Some(A)
13347    ///                  pre-insert "item": Null
13348    ///                  iter 1: write X → emit "inner:X"
13349    ///                  iter 2: write Y → emit "inner:Y"
13350    ///                  restore: insert("item", A)   ← outer's A back
13351    ///     emit "outer:A again"               ← reads A correctly
13352    ///   iter 2: get_mut → write B (overwrites the restored A,
13353    ///                              but that's correct — we're now
13354    ///                              in iter 2 of the outer loop)
13355    ///     emit "outer:B"
13356    ///     enter inner: saved = remove("item") = Some(B), …, restore B
13357    ///     emit "outer:B again"
13358    ///
13359    /// If the audit's claim were correct — that the outer's `get_mut`
13360    /// somehow held a reference past the inner loop and clobbered the
13361    /// restoration — we'd see corrupted values in the "outer:X again"
13362    /// emissions. The assertion below pins the correct sequence.
13363    #[test]
13364    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13365        let mut fragments = HashMap::new();
13366        fragments.insert(
13367            "index.html".to_string(),
13368            FragmentList {
13369                fragments: vec![
13370                    WebUIFragment::raw("["),
13371                    WebUIFragment::for_loop("item", "outer", "outer_body"),
13372                    WebUIFragment::raw("]"),
13373                ],
13374                contains_boundary: false,
13375            },
13376        );
13377        fragments.insert(
13378            "outer_body".to_string(),
13379            FragmentList {
13380                fragments: vec![
13381                    WebUIFragment::raw("(O="),
13382                    WebUIFragment::signal("item.tag", false),
13383                    WebUIFragment::for_loop("item", "inner", "inner_body"),
13384                    WebUIFragment::raw(",O="),
13385                    WebUIFragment::signal("item.tag", false),
13386                    WebUIFragment::raw(")"),
13387                ],
13388                contains_boundary: false,
13389            },
13390        );
13391        fragments.insert(
13392            "inner_body".to_string(),
13393            FragmentList {
13394                fragments: vec![
13395                    WebUIFragment::raw("[I="),
13396                    WebUIFragment::signal("item.tag", false),
13397                    WebUIFragment::raw("]"),
13398                ],
13399                contains_boundary: false,
13400            },
13401        );
13402        let protocol = WebUIProtocol::new(fragments);
13403        let state = test_json!({
13404            "outer": [{"tag": "A"}, {"tag": "B"}],
13405            "inner": [{"tag": "X"}, {"tag": "Y"}],
13406        });
13407        let mut writer = TestWriter::new();
13408        handle(
13409            &protocol,
13410            &state,
13411            &RenderOptions::new("index.html", "/"),
13412            &mut writer,
13413        )
13414        .unwrap();
13415        // Expected sequence:
13416        //   outer iter 1 (item=A):
13417        //     emit "(O=A"               ← outer A before inner
13418        //     inner iter 1 (item=X) emit "[I=X]"
13419        //     inner iter 2 (item=Y) emit "[I=Y]"
13420        //     emit ",O=A)"              ← outer A AFTER inner restore
13421        //   outer iter 2 (item=B):
13422        //     emit "(O=B"
13423        //     inner iter 1 (item=X) emit "[I=X]"
13424        //     inner iter 2 (item=Y) emit "[I=Y]"
13425        //     emit ",O=B)"
13426        assert_eq!(
13427            writer.get_content(),
13428            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13429            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13430        );
13431    }
13432    #[derive(Default)]
13433    struct FlushTestWriter {
13434        output: String,
13435        flushes: Vec<usize>,
13436        fail_flush: bool,
13437        fail_flush_at: Option<usize>,
13438        flush_attempts: usize,
13439        ended: bool,
13440    }
13441
13442    impl ResponseWriter for FlushTestWriter {
13443        fn write(&mut self, content: &str) -> Result<()> {
13444            self.output.push_str(content);
13445            Ok(())
13446        }
13447
13448        fn end(&mut self) -> Result<()> {
13449            self.ended = true;
13450            Ok(())
13451        }
13452    }
13453
13454    impl FlushWriter for FlushTestWriter {
13455        fn flush(&mut self) -> Result<()> {
13456            let attempt = self.flush_attempts;
13457            self.flush_attempts += 1;
13458            if self.fail_flush || self.fail_flush_at == Some(attempt) {
13459                return Err(HandlerError::ClientDisconnected);
13460            }
13461            self.flushes.push(self.output.len());
13462            Ok(())
13463        }
13464    }
13465
13466    fn streaming_protocol(with_boundaries: bool) -> Protocol {
13467        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13468    }
13469
13470    fn streaming_protocol_with_state_strategy(
13471        with_boundaries: bool,
13472        state_strategy: InitialStateStrategy,
13473    ) -> Protocol {
13474        let mut fragments = HashMap::new();
13475        let mut entry = vec![
13476            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13477            structural_fragment("head_start"),
13478            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13479            structural_fragment("head_end"),
13480            WebUIFragment::raw("</HEAD><body>"),
13481            structural_fragment("body_start"),
13482        ];
13483        if with_boundaries {
13484            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13485        }
13486        entry.extend([
13487            WebUIFragment::raw("<my-counter"),
13488            structural_fragment("streaming_root:my-counter"),
13489            WebUIFragment::raw(">"),
13490            WebUIFragment::component("my-counter"),
13491            WebUIFragment::raw("</my-counter>"),
13492        ]);
13493        if with_boundaries {
13494            entry.push(WebUIFragment::boundary_end(0));
13495        }
13496        entry.extend([
13497            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13498            structural_fragment("body_end"),
13499            WebUIFragment::raw("</body></html>"),
13500        ]);
13501        fragments.insert(
13502            "index.html".to_string(),
13503            FragmentList {
13504                fragments: entry,
13505                contains_boundary: with_boundaries,
13506            },
13507        );
13508        fragments.insert(
13509            "my-counter".to_string(),
13510            FragmentList {
13511                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13512                contains_boundary: false,
13513            },
13514        );
13515
13516        let mut document = WebUIProtocol::new(fragments);
13517        document.initial_state_strategy = state_strategy as i32;
13518        document.components.insert(
13519            "my-counter".to_string(),
13520            webui_protocol::ComponentData {
13521                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13522                template_functions: "[function(){return true}]".to_string(),
13523                hydration_mode: StateProjectionMode::Keys as i32,
13524                hydration_keys: vec!["count".to_string()],
13525                ..Default::default()
13526            },
13527        );
13528        Protocol::new(document)
13529    }
13530
13531    #[test]
13532    fn handler_error_stays_small() {
13533        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
13534        // therefore `Result<(), HandlerError>` threaded through the hot legacy
13535        // render path — down to a single `String`-sized payload plus a
13536        // discriminant word. If the boundary payload is un-boxed back to
13537        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
13538        // this fails.
13539        assert!(
13540            std::mem::size_of::<HandlerError>()
13541                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13542            "HandlerError grew to {} bytes",
13543            std::mem::size_of::<HandlerError>()
13544        );
13545    }
13546
13547    #[test]
13548    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13549        let protocol = streaming_protocol(true);
13550        let handler = WebUIHandler::with_plugin(|| {
13551            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13552        });
13553        let state = test_json!({ "count": 1, "serverOnly": "secret" });
13554        let mut writer = FlushTestWriter::default();
13555
13556        handler
13557            .render_streaming(
13558                &protocol,
13559                &state,
13560                &RenderOptions::new("index.html", "/"),
13561                &mut writer,
13562            )
13563            .unwrap();
13564
13565        assert!(writer.ended);
13566        assert_eq!(
13567            writer.flushes.len(),
13568            3,
13569            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13570        );
13571        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13572        assert!(boundary_flush.contains("<!--wb:0-->"));
13573        assert!(boundary_flush.contains("<!--/wb:0-->"));
13574        assert!(
13575            boundary_flush.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
13576            "boundary flush: {boundary_flush}"
13577        );
13578        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13579        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13580        assert!(!boundary_flush.contains("slow tail"));
13581        assert!(!writer.output.contains("id=\"webui-data\""));
13582        // The terminal flush commits the scriptless tail without manufacturing
13583        // another state/template projection.
13584        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13585        assert!(terminal_flush.contains("slow tail"));
13586        assert!(writer.output.contains("[2,1,4,0,{}]"));
13587        assert!(!writer.output.contains("[2,1,0,1,"));
13588        assert!(!writer.output.contains("[2,2,4,0,{}]"));
13589
13590        let marker = writer
13591            .output
13592            .find(STREAMING_MARKER)
13593            .expect("streaming marker");
13594        let attributed_head = writer
13595            .output
13596            .find("<HEAD data-shell=\"main\">")
13597            .expect("attributed mixed-case head");
13598        let authored_script = writer
13599            .output
13600            .find("src=\"/index.js\"")
13601            .expect("entry script");
13602        assert!(attributed_head < marker && marker < authored_script);
13603    }
13604
13605    #[test]
13606    fn streaming_terminal_tail_never_resends_full_state() {
13607        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13608        let handler = WebUIHandler::with_plugin(|| {
13609            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13610        });
13611        let mut writer = FlushTestWriter::default();
13612
13613        handler
13614            .render_streaming(
13615                &protocol,
13616                &test_json!({ "count": 1, "serverOnly": "secret" }),
13617                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13618                &mut writer,
13619            )
13620            .unwrap();
13621
13622        assert_eq!(
13623            writer.output.matches("serverOnly").count(),
13624            1,
13625            "full state belongs only to the interactive boundary"
13626        );
13627        assert!(writer.output.contains("[2,1,4,0,{}]"));
13628    }
13629
13630    /// Streaming must place the reserved-state injects exactly where the
13631    /// ordinary render does, so a host can switch modes without its
13632    /// boundary HTML moving.
13633    #[test]
13634    fn state_inject_placement_matches_between_render_modes() {
13635        let entry = vec![
13636            WebUIFragment::raw("<html><head>"),
13637            structural_fragment("head_start"),
13638            structural_fragment("head_end"),
13639            WebUIFragment::raw("</head><body>"),
13640            structural_fragment("body_start"),
13641            WebUIFragment::raw("<main>static</main>"),
13642            structural_fragment("body_end"),
13643            WebUIFragment::raw("</body></html>"),
13644        ];
13645        let fragments = HashMap::from([(
13646            "index.html".to_string(),
13647            FragmentList {
13648                fragments: entry,
13649                contains_boundary: false,
13650            },
13651        )]);
13652        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13653        let state = test_json!({
13654            "$webui": {
13655                "headEnd": "<meta name=he>",
13656                "bodyStart": "<span id=bs></span>",
13657                "bodyEnd": "<script>be</script>",
13658            }
13659        });
13660        let options = RenderOptions::new("index.html", "/");
13661
13662        let mut ordinary = TestWriter::new();
13663        WebUIHandler::new()
13664            .render(&protocol, &state, &options, &mut ordinary)
13665            .unwrap();
13666        let ordinary_html = ordinary.get_content().to_string();
13667
13668        let mut streamed = FlushTestWriter::default();
13669        WebUIHandler::new()
13670            .render_streaming(&protocol, &state, &options, &mut streamed)
13671            .unwrap();
13672        let streamed_html = &streamed.output;
13673
13674        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13675            let head_end = html.find("<meta name=he>").expect("headEnd missing");
13676            let head_close = html.find("</head>").expect("</head> missing");
13677            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13678            let main = html.find("<main>static</main>").expect("content missing");
13679            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13680            let body_close = html.find("</body>").expect("</body> missing");
13681            assert!(head_end < head_close, "headEnd misplaced: {html}");
13682            assert!(body_start < main, "bodyStart misplaced: {html}");
13683            assert!(
13684                main < body_end && body_end < body_close,
13685                "bodyEnd misplaced: {html}"
13686            );
13687        }
13688
13689        // The streaming response still terminates with its single empty
13690        // terminal record: an inject must not perturb the record stream.
13691        assert!(
13692            streamed_html.contains(",4,0,{}]"),
13693            "streaming must still end in one empty terminal record: {streamed_html}"
13694        );
13695    }
13696
13697    #[test]
13698    fn streaming_state_inject_emits_and_strips_reserved_key() {
13699        let entry = vec![
13700            WebUIFragment::raw("<html><head>"),
13701            structural_fragment("head_start"),
13702            structural_fragment("head_end"),
13703            WebUIFragment::raw("</head><body>"),
13704            structural_fragment("body_start"),
13705            structural_fragment("body_end"),
13706            WebUIFragment::raw("</body></html>"),
13707        ];
13708        let fragments = HashMap::from([(
13709            "index.html".to_string(),
13710            FragmentList {
13711                fragments: entry,
13712                contains_boundary: false,
13713            },
13714        )]);
13715        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13716        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13717
13718        let mut writer = FlushTestWriter::default();
13719        WebUIHandler::new()
13720            .render_streaming(
13721                &protocol,
13722                &state,
13723                &RenderOptions::new("index.html", "/"),
13724                &mut writer,
13725            )
13726            .unwrap();
13727        assert!(writer.output.contains("<script>be</script>"));
13728        assert!(!writer.output.contains("$webui"));
13729    }
13730
13731    #[test]
13732    fn static_streaming_document_uses_one_empty_terminal_record() {
13733        let fragments = HashMap::from([(
13734            "index.html".to_string(),
13735            FragmentList {
13736                fragments: vec![
13737                    WebUIFragment::raw("<html><head>"),
13738                    structural_fragment("head_start"),
13739                    structural_fragment("head_end"),
13740                    WebUIFragment::raw("</head><body>"),
13741                    structural_fragment("body_start"),
13742                    WebUIFragment::raw("<main>static</main>"),
13743                    structural_fragment("body_end"),
13744                    WebUIFragment::raw("</body></html>"),
13745                ],
13746                contains_boundary: false,
13747            },
13748        )]);
13749        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13750        let mut writer = FlushTestWriter::default();
13751
13752        WebUIHandler::new()
13753            .render_streaming(
13754                &protocol,
13755                &test_json!({ "serverOnly": "secret" }),
13756                &RenderOptions::new("index.html", "/"),
13757                &mut writer,
13758            )
13759            .unwrap();
13760
13761        assert_eq!(writer.flushes.len(), 1);
13762        assert!(writer.output.contains(STREAMING_MARKER));
13763        assert!(writer.output.contains("[2,0,4,0,{}]"));
13764        assert!(!writer.output.contains("serverOnly"));
13765        assert!(!writer.output.contains("id=\"webui-data\""));
13766        assert!(!writer.output.contains("<!--wb:"));
13767    }
13768
13769    #[test]
13770    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13771        let protocol = streaming_protocol(true);
13772        let handler = WebUIHandler::with_plugin(|| {
13773            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13774        });
13775        let mut writer = FlushTestWriter::default();
13776        handler
13777            .render_streaming(
13778                &protocol,
13779                &test_json!({ "count": 1 }),
13780                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13781                &mut writer,
13782            )
13783            .unwrap();
13784
13785        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13786        let envelope = writer.output[boundary_end..]
13787            .find("data-webui-boundary nonce=\"test-nonce-123\"")
13788            .map(|index| index + boundary_end)
13789            .expect("nonce-bearing envelope");
13790        let functions = writer
13791            .output
13792            .find("templateFns")
13793            .expect("function side channel");
13794        let sentinel = writer
13795            .output
13796            .find("<webui-hydrate>")
13797            .expect("hydration sentinel");
13798        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13799        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13800    }
13801
13802    #[test]
13803    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13804        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13805            let mut fragments = vec![structural_fragment("head_start")];
13806            fragments.extend(boundaries);
13807            let fragments = HashMap::from([(
13808                "index.html".to_string(),
13809                FragmentList {
13810                    fragments,
13811                    contains_boundary: true,
13812                },
13813            )]);
13814            Protocol::new(WebUIProtocol::new(fragments))
13815        }
13816
13817        let handler = WebUIHandler::new();
13818        let mut writer = FlushTestWriter::default();
13819        let result = handler.render_streaming(
13820            &protocol(vec![WebUIFragment::boundary_end(0)]),
13821            &test_json!({}),
13822            &RenderOptions::new("index.html", "/"),
13823            &mut writer,
13824        );
13825        match result {
13826            Err(HandlerError::StreamingBoundary(err)) => {
13827                assert_eq!(err.signal, "boundary");
13828                assert!(err.reason.contains("no active occurrence"));
13829            }
13830            other => panic!("expected ordered-boundary error, got {other:?}"),
13831        }
13832
13833        let mut writer = FlushTestWriter::default();
13834        let result = handler.render_streaming(
13835            &protocol(vec![
13836                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13837                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13838            ]),
13839            &test_json!({}),
13840            &RenderOptions::new("index.html", "/"),
13841            &mut writer,
13842        );
13843        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13844    }
13845
13846    #[test]
13847    fn streaming_render_propagates_boundary_flush_disconnect() {
13848        let protocol = streaming_protocol(true);
13849        let handler = WebUIHandler::new();
13850        let mut writer = FlushTestWriter {
13851            fail_flush: true,
13852            ..FlushTestWriter::default()
13853        };
13854        let result = handler.render_streaming(
13855            &protocol,
13856            &test_json!({}),
13857            &RenderOptions::new("index.html", "/"),
13858            &mut writer,
13859        );
13860        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13861        assert!(!writer.ended);
13862    }
13863
13864    #[test]
13865    fn streaming_render_requires_structural_head_start_before_writing() {
13866        let fragments = HashMap::from([(
13867            "index.html".to_string(),
13868            FragmentList {
13869                fragments: vec![
13870                    WebUIFragment::raw(
13871                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13872                    ),
13873                    structural_fragment("head_end"),
13874                    WebUIFragment::raw("</HEAD><body>"),
13875                    structural_fragment("body_start"),
13876                    structural_fragment("body_end"),
13877                ],
13878                contains_boundary: false,
13879            },
13880        )]);
13881        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13882        let mut writer = FlushTestWriter::default();
13883
13884        let result = WebUIHandler::new().render_streaming(
13885            &protocol,
13886            &test_json!({}),
13887            &RenderOptions::new("index.html", "/"),
13888            &mut writer,
13889        );
13890
13891        assert!(matches!(
13892            result,
13893            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13894        ));
13895        assert_eq!(
13896            writer.output,
13897            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13898        );
13899        assert!(writer.flushes.is_empty());
13900        assert!(!writer.ended);
13901    }
13902
13903    #[test]
13904    fn streaming_render_rejects_duplicate_head_start() {
13905        let fragments = HashMap::from([(
13906            "index.html".to_string(),
13907            FragmentList {
13908                fragments: vec![
13909                    WebUIFragment::raw("<html><head>"),
13910                    structural_fragment("head_start"),
13911                    structural_fragment("head_start"),
13912                    structural_fragment("head_end"),
13913                    WebUIFragment::raw("</head><body>"),
13914                    structural_fragment("body_start"),
13915                    structural_fragment("body_end"),
13916                ],
13917                contains_boundary: false,
13918            },
13919        )]);
13920        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13921        let mut writer = FlushTestWriter::default();
13922
13923        let result = WebUIHandler::new().render_streaming(
13924            &protocol,
13925            &test_json!({}),
13926            &RenderOptions::new("index.html", "/"),
13927            &mut writer,
13928        );
13929
13930        assert!(matches!(
13931            result,
13932            Err(HandlerError::DuplicateStreamingHeadStart)
13933        ));
13934        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
13935        assert!(writer.flushes.is_empty());
13936    }
13937
13938    #[test]
13939    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
13940        let handler = WebUIHandler::with_plugin(|| {
13941            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13942        });
13943        let state = test_json!({
13944            "count": 1,
13945            "head_start": "must not render",
13946            "boundary_start:0": "must not render",
13947            "boundary_end:0": "must not render",
13948        });
13949        let mut with_boundaries = TestWriter::new();
13950        let mut without_boundaries = TestWriter::new();
13951        handler
13952            .render(
13953                &streaming_protocol(true),
13954                &state,
13955                &RenderOptions::new("index.html", "/"),
13956                &mut with_boundaries,
13957            )
13958            .unwrap();
13959        handler
13960            .render(
13961                &streaming_protocol(false),
13962                &state,
13963                &RenderOptions::new("index.html", "/"),
13964                &mut without_boundaries,
13965            )
13966            .unwrap();
13967        assert_eq!(
13968            with_boundaries.get_content(),
13969            without_boundaries.get_content()
13970        );
13971    }
13972
13973    #[test]
13974    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
13975        for source in [
13976            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
13977            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
13978        ] {
13979            let mut parser = HtmlParser::new();
13980            parser
13981                .parse("index.html", source)
13982                .expect("parse head fixture");
13983            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13984            let mut writer = TestWriter::new();
13985            WebUIHandler::new()
13986                .render(
13987                    &protocol,
13988                    &test_json!({ "theme": "light" }),
13989                    &RenderOptions::new("index.html", "/"),
13990                    &mut writer,
13991                )
13992                .unwrap();
13993
13994            assert_eq!(
13995                writer.get_content(),
13996                "<html><head><title>T</title></head><body>x</body></html>"
13997            );
13998        }
13999    }
14000
14001    #[test]
14002    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14003        let source =
14004            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14005        let mut parser = HtmlParser::new();
14006        parser.parse("index.html", source).expect("parse fixture");
14007        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14008
14009        let mut ordinary = TestWriter::new();
14010        WebUIHandler::new()
14011            .render(
14012                &protocol,
14013                &test_json!({}),
14014                &RenderOptions::new("index.html", "/"),
14015                &mut ordinary,
14016            )
14017            .unwrap();
14018        assert_eq!(ordinary.get_content(), source);
14019
14020        let mut streaming = FlushTestWriter::default();
14021        WebUIHandler::new()
14022            .render_streaming(
14023                &protocol,
14024                &test_json!({}),
14025                &RenderOptions::new("index.html", "/"),
14026                &mut streaming,
14027            )
14028            .unwrap();
14029        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14030        assert!(streaming.output.contains("</HEAD><BODY>x"));
14031        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14032        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14033        let title = streaming.output.find("<title>").expect("title");
14034        assert!(opening < marker && marker < title);
14035        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14036    }
14037
14038    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
14039    /// unprefixed `body_end` is ordinary authored content.
14040    #[test]
14041    fn unnamespaced_signal_is_ordinary_content() {
14042        let fragments = HashMap::from([(
14043            "index.html".to_string(),
14044            FragmentList {
14045                fragments: vec![
14046                    WebUIFragment::raw("<html><head>"),
14047                    structural_fragment("head_end"),
14048                    WebUIFragment::raw("</head><body>"),
14049                    WebUIFragment::signal("body_end".to_string(), false),
14050                    structural_fragment("body_end"),
14051                    WebUIFragment::raw("</body></html>"),
14052                ],
14053                contains_boundary: false,
14054            },
14055        )]);
14056        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14057        let mut writer = TestWriter::new();
14058        WebUIHandler::new()
14059            .render(
14060                &protocol,
14061                &test_json!({ "body_end": "content" }),
14062                &RenderOptions::new("index.html", "/"),
14063                &mut writer,
14064            )
14065            .expect("current protocol must render");
14066        assert!(writer.get_content().contains("content"));
14067    }
14068
14069    #[test]
14070    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14071        let source = concat!(
14072            "<html><head></head><body>",
14073            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14074            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14075            "</body></html>",
14076        );
14077        let state = test_json!({
14078            "head_start": "hs",
14079            "head_end": "he",
14080            "body_start": "bs",
14081            "body_end": "be",
14082            "boundary_start:0": "b0s",
14083            "boundary_end:0": "b0e",
14084            "streaming_root:forged": "root",
14085        });
14086        let mut parser = HtmlParser::new();
14087        parser.parse("index.html", source).expect("parse fixture");
14088        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14089        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14090
14091        let mut ordinary = TestWriter::new();
14092        WebUIHandler::new()
14093            .render(
14094                &protocol,
14095                &state,
14096                &RenderOptions::new("index.html", "/"),
14097                &mut ordinary,
14098            )
14099            .unwrap();
14100        assert_eq!(ordinary.get_content(), expected);
14101
14102        let mut streaming = FlushTestWriter::default();
14103        WebUIHandler::new()
14104            .render_streaming(
14105                &protocol,
14106                &state,
14107                &RenderOptions::new("index.html", "/"),
14108                &mut streaming,
14109            )
14110            .unwrap();
14111        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14112        assert!(!streaming.output.contains("<!--wb:"));
14113        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14114        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14115    }
14116
14117    #[test]
14118    fn streaming_rejects_structural_signals_after_terminal() {
14119        let fragments = HashMap::from([(
14120            "index.html".to_string(),
14121            FragmentList {
14122                fragments: vec![
14123                    WebUIFragment::raw("<html><head>"),
14124                    structural_fragment("head_start"),
14125                    structural_fragment("head_end"),
14126                    WebUIFragment::raw("</head><body>"),
14127                    structural_fragment("body_start"),
14128                    structural_fragment("body_end"),
14129                    WebUIFragment::raw("</body>"),
14130                    structural_fragment("boundary_start:0"),
14131                    WebUIFragment::raw("</html>"),
14132                ],
14133                contains_boundary: false,
14134            },
14135        )]);
14136        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14137        let mut streaming = FlushTestWriter::default();
14138        let result = WebUIHandler::new().render_streaming(
14139            &protocol,
14140            &test_json!({}),
14141            &RenderOptions::new("index.html", "/"),
14142            &mut streaming,
14143        );
14144
14145        match result {
14146            Err(HandlerError::StreamingBoundary(error)) => {
14147                assert_eq!(error.signal, "boundary_start:0");
14148                assert!(
14149                    error
14150                        .reason
14151                        .contains("structural signal arrived after body_end"),
14152                    "reason: {}",
14153                    error.reason
14154                );
14155            }
14156            other => panic!("expected post-terminal rejection, got {other:?}"),
14157        }
14158        assert_eq!(
14159            streaming.output,
14160            "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14161        );
14162        assert!(!streaming.output.contains("<!--wb:0-->"));
14163
14164        let mut ordinary = TestWriter::new();
14165        WebUIHandler::new()
14166            .render(
14167                &protocol,
14168                &test_json!({}),
14169                &RenderOptions::new("index.html", "/"),
14170                &mut ordinary,
14171            )
14172            .unwrap();
14173        assert_eq!(
14174            ordinary.get_content(),
14175            "<html><head></head><body></body></html>"
14176        );
14177    }
14178
14179    fn disjoint_streaming_protocol_ext(
14180        hosts: &[&str],
14181        emit_root_signal: bool,
14182        include_styles: bool,
14183    ) -> Protocol {
14184        let mut fragments = HashMap::new();
14185        let mut entry = vec![
14186            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14187            structural_fragment("head_start"),
14188            structural_fragment("head_end"),
14189            WebUIFragment::raw("</head><body>"),
14190            structural_fragment("body_start"),
14191        ];
14192        for (sequence, host) in hosts.iter().enumerate() {
14193            entry.push(WebUIFragment::boundary(
14194                sequence as u32,
14195                "index.html",
14196                format!("boundary-{sequence}"),
14197                None,
14198            ));
14199            entry.push(WebUIFragment::raw(format!("<{host}")));
14200            if emit_root_signal {
14201                entry.push(structural_fragment(format!("streaming_root:{host}")));
14202            }
14203            entry.push(WebUIFragment::raw(">"));
14204            entry.push(WebUIFragment::component(*host));
14205            entry.push(WebUIFragment::raw(format!("</{host}>")));
14206            entry.push(WebUIFragment::boundary_end(sequence as u32));
14207        }
14208        entry.push(structural_fragment("body_end"));
14209        entry.push(WebUIFragment::raw("</body></html>"));
14210        fragments.insert(
14211            "index.html".to_string(),
14212            FragmentList {
14213                fragments: entry,
14214                contains_boundary: true,
14215            },
14216        );
14217        fragments.insert(
14218            "comp-a".to_string(),
14219            FragmentList {
14220                fragments: vec![WebUIFragment::raw("<b>A</b>")],
14221                contains_boundary: false,
14222            },
14223        );
14224        fragments.insert(
14225            "comp-b".to_string(),
14226            FragmentList {
14227                fragments: vec![WebUIFragment::raw("<b>B</b>")],
14228                contains_boundary: false,
14229            },
14230        );
14231
14232        let mut document = WebUIProtocol::new(fragments);
14233        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14234        document.components.insert(
14235            "comp-a".to_string(),
14236            webui_protocol::ComponentData {
14237                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14238                hydration_mode: StateProjectionMode::Keys as i32,
14239                hydration_keys: vec!["a_count".to_string()],
14240                ..Default::default()
14241            },
14242        );
14243        document.components.insert(
14244            "comp-b".to_string(),
14245            webui_protocol::ComponentData {
14246                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14247                hydration_mode: StateProjectionMode::Keys as i32,
14248                hydration_keys: vec!["b_count".to_string()],
14249                ..Default::default()
14250            },
14251        );
14252        if include_styles {
14253            document.set_css_strategy(webui_protocol::CssStrategy::Style);
14254            document
14255                .components
14256                .get_mut("comp-a")
14257                .expect("comp-a component")
14258                .css = ".comp-a{color:red}".to_string();
14259            document
14260                .components
14261                .get_mut("comp-b")
14262                .expect("comp-b component")
14263                .css = ".comp-b{color:blue}".to_string();
14264            document.populate_style_closures(&["index.html"]);
14265        }
14266        Protocol::new(document)
14267    }
14268
14269    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14270        disjoint_streaming_protocol_ext(hosts, true, false)
14271    }
14272
14273    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14274        disjoint_streaming_protocol_ext(hosts, true, true)
14275    }
14276
14277    fn streaming_root_validation_protocol(
14278        mut host_fragments: Vec<WebUIFragment>,
14279        tail: Vec<WebUIFragment>,
14280    ) -> Protocol {
14281        let mut entry = vec![
14282            WebUIFragment::raw("<html><head>"),
14283            structural_fragment("head_start"),
14284            structural_fragment("head_end"),
14285            WebUIFragment::raw("</head><body>"),
14286            structural_fragment("body_start"),
14287            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14288        ];
14289        entry.append(&mut host_fragments);
14290        entry.extend(tail);
14291        let fragments = HashMap::from([
14292            (
14293                "index.html".to_string(),
14294                FragmentList {
14295                    fragments: entry,
14296                    contains_boundary: true,
14297                },
14298            ),
14299            (
14300                "comp-a".to_string(),
14301                FragmentList {
14302                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14303                    contains_boundary: false,
14304                },
14305            ),
14306            (
14307                "comp-b".to_string(),
14308                FragmentList {
14309                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
14310                    contains_boundary: false,
14311                },
14312            ),
14313        ]);
14314        Protocol::new(WebUIProtocol::new(fragments))
14315    }
14316
14317    fn completed_streaming_tail() -> Vec<WebUIFragment> {
14318        vec![
14319            WebUIFragment::raw("</comp-a>"),
14320            WebUIFragment::boundary_end(0),
14321            structural_fragment("body_end"),
14322            WebUIFragment::raw("</body></html>"),
14323        ]
14324    }
14325
14326    fn assert_streaming_root_error(
14327        protocol: &Protocol,
14328        expected_signal: &str,
14329        expected_reason: &str,
14330    ) {
14331        let mut writer = FlushTestWriter::default();
14332        let result = WebUIHandler::new().render_streaming(
14333            protocol,
14334            &test_json!({}),
14335            &RenderOptions::new("index.html", "/"),
14336            &mut writer,
14337        );
14338        match result {
14339            Err(HandlerError::StreamingBoundary(error)) => {
14340                assert_eq!(error.signal, expected_signal);
14341                assert!(
14342                    error.reason.contains(expected_reason),
14343                    "reason: {}",
14344                    error.reason
14345                );
14346            }
14347            other => panic!("expected streaming-root rejection, got {other:?}"),
14348        }
14349    }
14350
14351    #[test]
14352    fn streaming_root_signal_injects_data_ws_inside_boundary() {
14353        let protocol = disjoint_streaming_protocol(&["comp-a"]);
14354        let handler = WebUIHandler::with_plugin(|| {
14355            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14356        });
14357        let mut writer = FlushTestWriter::default();
14358        handler
14359            .render_streaming(
14360                &protocol,
14361                &test_json!({ "a_count": 1 }),
14362                &RenderOptions::new("index.html", "/"),
14363                &mut writer,
14364            )
14365            .unwrap();
14366        // The parser-owned signal is consumed to inject exactly ` data-ws`
14367        // inside the host's opening tag, before the custom element upgrades.
14368        assert!(
14369            writer.output.contains("<comp-a data-ws>"),
14370            "streamed host must carry data-ws: {}",
14371            writer.output
14372        );
14373    }
14374
14375    #[test]
14376    fn streaming_root_signal_preserves_ordinary_output_bytes() {
14377        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
14378        // output with and without the signal, and never a `data-ws` attribute.
14379        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14380        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14381        let state = test_json!({ "a_count": 1, "b_count": 2 });
14382        let plugin = || {
14383            WebUIHandler::with_plugin(
14384                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14385            )
14386        };
14387        let mut with_writer = TestWriter::new();
14388        let mut without_writer = TestWriter::new();
14389        plugin()
14390            .render(
14391                &with_signal,
14392                &state,
14393                &RenderOptions::new("index.html", "/"),
14394                &mut with_writer,
14395            )
14396            .unwrap();
14397        plugin()
14398            .render(
14399                &without_signal,
14400                &state,
14401                &RenderOptions::new("index.html", "/"),
14402                &mut without_writer,
14403            )
14404            .unwrap();
14405        // The rendered DOM (everything up to the inert data block) is
14406        // deterministic and is where a leaked `data-ws` would appear. The
14407        // ordinary template map is HashSet-ordered, so compare the DOM prefix
14408        // for byte identity rather than the whole document.
14409        let dom_prefix = |content: &str| -> String {
14410            content
14411                .split_once(r#"<script type="application/json" id="webui-data""#)
14412                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14413        };
14414        assert_eq!(
14415            dom_prefix(&with_writer.get_content()),
14416            dom_prefix(&without_writer.get_content())
14417        );
14418        assert!(!with_writer.get_content().contains("data-ws"));
14419        assert!(with_writer.get_content().contains("<comp-a>"));
14420    }
14421
14422    #[test]
14423    fn streaming_component_without_root_signal_is_rejected() {
14424        let protocol = streaming_root_validation_protocol(
14425            vec![
14426                WebUIFragment::raw("<comp-a>"),
14427                WebUIFragment::component("comp-a"),
14428            ],
14429            completed_streaming_tail(),
14430        );
14431
14432        assert_streaming_root_error(
14433            &protocol,
14434            "comp-a",
14435            "has no compiler-owned streaming_root signal",
14436        );
14437    }
14438
14439    #[test]
14440    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14441        let duplicate = streaming_root_validation_protocol(
14442            vec![
14443                WebUIFragment::raw("<comp-a"),
14444                structural_fragment("streaming_root:comp-a"),
14445                structural_fragment("streaming_root:comp-a"),
14446                WebUIFragment::raw(">"),
14447                WebUIFragment::component("comp-a"),
14448            ],
14449            completed_streaming_tail(),
14450        );
14451        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14452
14453        let after_tag_close = streaming_root_validation_protocol(
14454            vec![
14455                WebUIFragment::raw("<comp-a>"),
14456                structural_fragment("streaming_root:comp-a"),
14457                WebUIFragment::component("comp-a"),
14458            ],
14459            completed_streaming_tail(),
14460        );
14461        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14462
14463        let detached_from_opening = streaming_root_validation_protocol(
14464            vec![
14465                WebUIFragment::raw("not an opening tag"),
14466                structural_fragment("streaming_root:comp-a"),
14467                WebUIFragment::raw(">"),
14468                WebUIFragment::component("comp-a"),
14469            ],
14470            completed_streaming_tail(),
14471        );
14472        assert_streaming_root_error(
14473            &detached_from_opening,
14474            "comp-a",
14475            "unclosed component opening-tag close",
14476        );
14477    }
14478
14479    #[test]
14480    fn mismatched_streaming_root_signal_is_rejected() {
14481        let protocol = streaming_root_validation_protocol(
14482            vec![
14483                WebUIFragment::raw("<comp-a"),
14484                structural_fragment("streaming_root:comp-b"),
14485                WebUIFragment::raw(">"),
14486                WebUIFragment::component("comp-a"),
14487            ],
14488            completed_streaming_tail(),
14489        );
14490
14491        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14492    }
14493
14494    #[test]
14495    fn pending_streaming_root_is_rejected_at_structural_ends() {
14496        let protocol = streaming_root_validation_protocol(
14497            vec![
14498                WebUIFragment::raw("<comp-a"),
14499                structural_fragment("streaming_root:comp-a"),
14500                WebUIFragment::raw(">"),
14501            ],
14502            vec![structural_fragment("boundary_end:0")],
14503        );
14504        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14505    }
14506
14507    #[test]
14508    fn streaming_root_outside_boundary_is_rejected() {
14509        let fragments = HashMap::from([
14510            (
14511                "index.html".to_string(),
14512                FragmentList {
14513                    fragments: vec![
14514                        WebUIFragment::raw("<html><head>"),
14515                        structural_fragment("head_start"),
14516                        structural_fragment("head_end"),
14517                        WebUIFragment::raw("</head><body>"),
14518                        structural_fragment("body_start"),
14519                        WebUIFragment::raw("<comp-a"),
14520                        structural_fragment("streaming_root:comp-a"),
14521                        WebUIFragment::raw(">"),
14522                        WebUIFragment::component("comp-a"),
14523                        WebUIFragment::raw("</comp-a>"),
14524                        structural_fragment("body_end"),
14525                        WebUIFragment::raw("</body></html>"),
14526                    ],
14527                    contains_boundary: false,
14528                },
14529            ),
14530            (
14531                "comp-a".to_string(),
14532                FragmentList {
14533                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14534                    contains_boundary: false,
14535                },
14536            ),
14537        ]);
14538        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14539        let mut writer = FlushTestWriter::default();
14540        let result = WebUIHandler::new().render_streaming(
14541            &protocol,
14542            &test_json!({}),
14543            &RenderOptions::new("index.html", "/"),
14544            &mut writer,
14545        );
14546        match result {
14547            Err(HandlerError::StreamingBoundary(err)) => {
14548                assert_eq!(err.signal, "comp-a");
14549                assert!(
14550                    err.reason
14551                        .contains("no boundary or generated component span"),
14552                    "reason: {}",
14553                    err.reason
14554                );
14555            }
14556            other => panic!("expected outside-boundary rejection, got {other:?}"),
14557        }
14558    }
14559
14560    fn parser_route_protocol(with_boundary: bool) -> Protocol {
14561        let mut parser = HtmlParser::with_options(DomStrategy::Light);
14562        parser
14563            .component_registry_mut()
14564            .register_component(ComponentRegistration::new(
14565                "route-page",
14566                "<p>route content</p>",
14567                None,
14568                true,
14569            ))
14570            .expect("register route component");
14571        let route = r#"<route path="/" component="route-page" exact />"#;
14572        let html = if with_boundary {
14573            format!(
14574                "<html><head></head><body>\
14575                 <boundary name=\"route\">{route}</boundary>\
14576                 </body></html>"
14577            )
14578        } else {
14579            format!("<html><head></head><body>{route}</body></html>")
14580        };
14581        parser
14582            .parse("index.html", &html)
14583            .expect("parse route streaming fixture");
14584        let mut document = WebUIProtocol::new(parser.into_fragment_records());
14585        document.components.insert(
14586            "route-page".to_string(),
14587            webui_protocol::ComponentData::default(),
14588        );
14589        Protocol::new(document)
14590    }
14591
14592    #[test]
14593    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14594        let protocol = parser_route_protocol(true);
14595        let mut writer = FlushTestWriter::default();
14596        WebUIHandler::new()
14597            .render_streaming(
14598                &protocol,
14599                &test_json!({}),
14600                &RenderOptions::new("index.html", "/"),
14601                &mut writer,
14602            )
14603            .unwrap();
14604
14605        assert!(
14606            writer
14607                .output
14608                .contains("<route-page data-ws><p>route content</p></route-page>"),
14609            "matched route host must be deferred before upgrade: {}",
14610            writer.output
14611        );
14612    }
14613
14614    #[test]
14615    fn streaming_parser_route_host_outside_boundary_is_rejected() {
14616        let protocol = parser_route_protocol(false);
14617        let mut writer = FlushTestWriter::default();
14618        let result = WebUIHandler::new().render_streaming(
14619            &protocol,
14620            &test_json!({}),
14621            &RenderOptions::new("index.html", "/"),
14622            &mut writer,
14623        );
14624
14625        match result {
14626            Err(HandlerError::StreamingBoundary(error)) => {
14627                assert_eq!(error.signal, "route-page");
14628                assert!(
14629                    error
14630                        .reason
14631                        .contains("no boundary or generated component span"),
14632                    "reason: {}",
14633                    error.reason
14634                );
14635            }
14636            other => panic!("expected route boundary rejection, got {other:?}"),
14637        }
14638        assert!(
14639            !writer.output.contains("<route-page>"),
14640            "an unmarked custom-element opening must never be completed"
14641        );
14642    }
14643
14644    #[test]
14645    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14646        let entry = vec![
14647            WebUIFragment::raw("<html><head>"),
14648            structural_fragment("head_start"),
14649            structural_fragment("head_end"),
14650            WebUIFragment::raw("</head><body>"),
14651            structural_fragment("body_start"),
14652            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14653            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14654                path: "/account".into(),
14655                fragment_id: "account-shell".into(),
14656                exact: false,
14657                ..Default::default()
14658            }),
14659            WebUIFragment::boundary_end(0),
14660            structural_fragment("body_end"),
14661            WebUIFragment::raw("</body></html>"),
14662        ];
14663        let fragments = HashMap::from([
14664            (
14665                "index.html".to_string(),
14666                FragmentList {
14667                    fragments: entry,
14668                    contains_boundary: true,
14669                },
14670            ),
14671            (
14672                "account-shell".to_string(),
14673                FragmentList {
14674                    fragments: vec![WebUIFragment::route_from(
14675                        webui_protocol::WebUiFragmentRoute {
14676                            path: "./details".into(),
14677                            fragment_id: "details-page".into(),
14678                            exact: true,
14679                            pending_component: "details-loading".into(),
14680                            error_component: "details-error".into(),
14681                            ..Default::default()
14682                        },
14683                    )],
14684                    contains_boundary: false,
14685                },
14686            ),
14687            (
14688                "details-page".to_string(),
14689                FragmentList {
14690                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
14691                    contains_boundary: false,
14692                },
14693            ),
14694            (
14695                "details-loading".to_string(),
14696                FragmentList {
14697                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14698                    contains_boundary: false,
14699                },
14700            ),
14701            (
14702                "details-error".to_string(),
14703                FragmentList {
14704                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
14705                    contains_boundary: false,
14706                },
14707            ),
14708        ]);
14709        let mut document = WebUIProtocol::new(fragments);
14710        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14711        for name in [
14712            "account-shell",
14713            "details-page",
14714            "details-loading",
14715            "details-error",
14716        ] {
14717            document.components.insert(
14718                name.to_string(),
14719                webui_protocol::ComponentData {
14720                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14721                    ..Default::default()
14722                },
14723            );
14724        }
14725        let protocol = Protocol::new(document);
14726        let handler = WebUIHandler::with_plugin(|| {
14727            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14728        });
14729        let mut writer = FlushTestWriter::default();
14730
14731        handler
14732            .render_streaming(
14733                &protocol,
14734                &test_json!({}),
14735                &RenderOptions::new("index.html", "/account/details"),
14736                &mut writer,
14737            )
14738            .unwrap();
14739
14740        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14741        assert!(
14742            first.contains(r#""details-loading":"#),
14743            "relative-route pending metadata missing: {first}"
14744        );
14745        assert!(
14746            first.contains(r#""details-error":"#),
14747            "relative-route error metadata missing: {first}"
14748        );
14749    }
14750
14751    #[test]
14752    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14753        let entry = vec![
14754            WebUIFragment::raw("<html><head>"),
14755            structural_fragment("head_start"),
14756            structural_fragment("head_end"),
14757            WebUIFragment::raw("</head><body>"),
14758            structural_fragment("body_start"),
14759            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14760            WebUIFragment::raw("<static-shell"),
14761            structural_fragment("streaming_root:static-shell"),
14762            WebUIFragment::raw(">"),
14763            WebUIFragment::component("static-shell"),
14764            WebUIFragment::raw("</static-shell>"),
14765            WebUIFragment::raw("<route-shell"),
14766            structural_fragment("streaming_root:route-shell"),
14767            WebUIFragment::raw(">"),
14768            WebUIFragment::component("route-shell"),
14769            WebUIFragment::raw("</route-shell>"),
14770            WebUIFragment::boundary_end(0),
14771            structural_fragment("body_end"),
14772            WebUIFragment::raw("</body></html>"),
14773        ];
14774        let fragments = HashMap::from([
14775            (
14776                "index.html".to_string(),
14777                FragmentList {
14778                    fragments: entry,
14779                    contains_boundary: true,
14780                },
14781            ),
14782            (
14783                "static-shell".to_string(),
14784                FragmentList {
14785                    fragments: vec![WebUIFragment::if_cond(
14786                        ConditionExpr::identifier("show_hidden"),
14787                        "static-hidden-if",
14788                    )],
14789                    contains_boundary: false,
14790                },
14791            ),
14792            (
14793                "static-hidden-if".to_string(),
14794                FragmentList {
14795                    fragments: vec![WebUIFragment::component("static-hidden")],
14796                    contains_boundary: false,
14797                },
14798            ),
14799            (
14800                "static-hidden".to_string(),
14801                FragmentList {
14802                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14803                    contains_boundary: false,
14804                },
14805            ),
14806            (
14807                "route-shell".to_string(),
14808                FragmentList {
14809                    fragments: vec![WebUIFragment::route_from(
14810                        webui_protocol::WebUiFragmentRoute {
14811                            path: "/account".into(),
14812                            fragment_id: "route-page".into(),
14813                            exact: true,
14814                            ..Default::default()
14815                        },
14816                    )],
14817                    contains_boundary: false,
14818                },
14819            ),
14820            (
14821                "route-page".to_string(),
14822                FragmentList {
14823                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
14824                    contains_boundary: false,
14825                },
14826            ),
14827        ]);
14828        let mut document = WebUIProtocol::new(fragments);
14829        document.set_css_strategy(webui_protocol::CssStrategy::Link);
14830        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14831        for (name, keys) in [
14832            ("static-shell", vec!["show_hidden", "static_count"]),
14833            ("static-hidden", vec!["hidden_count"]),
14834            ("route-shell", vec!["route_shell_count"]),
14835            ("route-page", vec!["route_count"]),
14836        ] {
14837            document.components.insert(
14838                name.to_string(),
14839                webui_protocol::ComponentData {
14840                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14841                    hydration_mode: StateProjectionMode::Keys as i32,
14842                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
14843                    css_href: format!("/{name}.css"),
14844                    ..Default::default()
14845                },
14846            );
14847        }
14848        document.populate_style_closures(&["index.html"]);
14849        let protocol = Protocol::new(document);
14850        assert_eq!(
14851            protocol
14852                .component_reachability()
14853                .is_route_dependent(protocol.component_index()["route-shell"]),
14854            Some(true),
14855            "fixture must exercise request-aware checkpoint reachability"
14856        );
14857        let handler = WebUIHandler::with_plugin(|| {
14858            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14859        });
14860        let mut writer = FlushTestWriter::default();
14861
14862        handler
14863            .render_streaming(
14864                &protocol,
14865                &test_json!({
14866                    "show_hidden": false,
14867                    "static_count": 1,
14868                    "hidden_count": 2,
14869                    "route_shell_count": 3,
14870                    "route_count": 4,
14871                    "unrelated": "private",
14872                }),
14873                &RenderOptions::new("index.html", "/account"),
14874                &mut writer,
14875            )
14876            .unwrap();
14877
14878        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14879        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14880            assert!(
14881                checkpoint.contains(&format!(r#""{template}":"#)),
14882                "{template} metadata missing: {checkpoint}"
14883            );
14884        }
14885        for state in [
14886            r#""show_hidden":false"#,
14887            r#""static_count":1"#,
14888            r#""hidden_count":2"#,
14889            r#""route_shell_count":3"#,
14890            r#""route_count":4"#,
14891        ] {
14892            assert!(
14893                checkpoint.contains(state),
14894                "{state} state missing: {checkpoint}"
14895            );
14896        }
14897        assert!(
14898            !checkpoint.contains("unrelated"),
14899            "checkpoint leaked unrelated state: {checkpoint}"
14900        );
14901        assert!(
14902            checkpoint.contains(
14903                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14904            ),
14905            "checkpoint metadata lost source order: {checkpoint}"
14906        );
14907        assert!(
14908            checkpoint.contains(r#""componentStyles":"#)
14909                && checkpoint.contains(r#""strategy":"link""#)
14910                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14911            "checkpoint is missing tree-local style metadata: {checkpoint}"
14912        );
14913        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14914            assert_eq!(
14915                writer
14916                    .output
14917                    .matches(&format!(r#"data-webui-resource="{resource}""#))
14918                    .count(),
14919                1,
14920                "Document delivery state must persist across streamed checkpoints"
14921            );
14922        }
14923    }
14924
14925    #[test]
14926    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
14927        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
14928        // envelope must be locally scoped — templates/state for exactly the
14929        // components rendered since the previous checkpoint, with reused tags
14930        // carrying hydration state but never a duplicate template.
14931        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14932        let handler = WebUIHandler::with_plugin(|| {
14933            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14934        });
14935        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
14936        let mut writer = FlushTestWriter::default();
14937        handler
14938            .render_streaming(
14939                &protocol,
14940                &state,
14941                &RenderOptions::new("index.html", "/"),
14942                &mut writer,
14943            )
14944            .unwrap();
14945
14946        assert_eq!(
14947            writer.flushes.len(),
14948            5,
14949            "bootstrap, three boundary commits, and one terminal"
14950        );
14951        let segment = |index: usize| -> &str {
14952            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14953        };
14954        let b0 = segment(0);
14955        let b1 = segment(1);
14956        let b2 = segment(2);
14957
14958        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
14959        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
14960        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
14961        assert!(
14962            b0.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
14963            "b0: {b0}"
14964        );
14965        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
14966        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
14967        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
14968
14969        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
14970        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
14971        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
14972        assert!(
14973            b1.contains(r#"[2,1,0,1,{"declarationId":1,"componentStyles":"#),
14974            "b1: {b1}"
14975        );
14976        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
14977        assert!(
14978            !b1.contains(r#""templates":{"comp-a"#),
14979            "b1 re-sent comp-a: {b1}"
14980        );
14981        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
14982
14983        // Boundary 2: comp-a reused — state present, template absent (empty delta).
14984        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
14985        assert!(
14986            b2.contains(r#"[2,2,0,2,{"declarationId":2,"componentStyles":"#),
14987            "b2: {b2}"
14988        );
14989        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
14990        assert!(
14991            !b2.contains(r#""templates""#),
14992            "b2 re-sent a template: {b2}"
14993        );
14994
14995        // Server-only state never leaks into any envelope.
14996        assert!(!writer.output.contains("serverOnly"));
14997    }
14998
14999    #[test]
15000    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15001        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15002        let handler = WebUIHandler::with_plugin(|| {
15003            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15004        });
15005        let mut writer = FlushTestWriter::default();
15006        handler
15007            .render_streaming(
15008                &protocol,
15009                &test_json!({ "a_count": 1, "b_count": 2 }),
15010                &RenderOptions::new("index.html", "/"),
15011                &mut writer,
15012            )
15013            .unwrap();
15014
15015        let segment = |index: usize| -> &str {
15016            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15017        };
15018        let checkpoint_styles = |checkpoint: &str| -> Value {
15019            let script = checkpoint
15020                .rfind(r#"<script type="application/json" data-webui-boundary"#)
15021                .unwrap();
15022            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15023            let payload_end =
15024                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15025            let record: Value =
15026                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15027            record[4]["componentStyles"].clone()
15028        };
15029
15030        let first = checkpoint_styles(segment(0));
15031        assert!(first["resources"]["comp-a"].is_object());
15032        assert!(first["resources"]["comp-b"].is_object());
15033        assert!(first["closures"]["index.html"].is_array());
15034        assert!(first["closures"]["comp-a"].is_array());
15035
15036        let second = checkpoint_styles(segment(1));
15037        assert_eq!(second["resources"], test_json!({}));
15038        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15039
15040        let repeated = checkpoint_styles(segment(2));
15041        assert_eq!(repeated["resources"], test_json!({}));
15042        assert_eq!(repeated["closures"], test_json!({}));
15043        for resource in ["comp-a", "comp-b"] {
15044            assert_eq!(
15045                [&first, &second, &repeated]
15046                    .iter()
15047                    .filter(|styles| {
15048                        styles["resources"]
15049                            .as_object()
15050                            .is_some_and(|resources| resources.contains_key(resource))
15051                    })
15052                    .count(),
15053                1,
15054                "{resource} style metadata was serialized more than once"
15055            );
15056        }
15057    }
15058
15059    #[test]
15060    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15061        let entry = vec![
15062            WebUIFragment::raw("<html><head>"),
15063            structural_fragment("head_start"),
15064            structural_fragment("head_end"),
15065            WebUIFragment::raw("</head><body>"),
15066            structural_fragment("body_start"),
15067            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15068            WebUIFragment::raw("<comp-a"),
15069            structural_fragment("streaming_root:comp-a"),
15070            WebUIFragment::raw(">"),
15071            WebUIFragment::component("comp-a"),
15072            WebUIFragment::raw("</comp-a>"),
15073            WebUIFragment::boundary_end(0),
15074            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15075            WebUIFragment::raw("<comp-hidden"),
15076            structural_fragment("streaming_root:comp-hidden"),
15077            WebUIFragment::raw(">"),
15078            WebUIFragment::component("comp-hidden"),
15079            WebUIFragment::raw("</comp-hidden>"),
15080            WebUIFragment::boundary_end(1),
15081            structural_fragment("body_end"),
15082            WebUIFragment::raw("</body></html>"),
15083        ];
15084        let fragments = HashMap::from([
15085            (
15086                "index.html".to_string(),
15087                FragmentList {
15088                    fragments: entry,
15089                    contains_boundary: true,
15090                },
15091            ),
15092            (
15093                "comp-a".to_string(),
15094                FragmentList {
15095                    fragments: vec![WebUIFragment::if_cond(
15096                        ConditionExpr::identifier("show_hidden"),
15097                        "hidden-if",
15098                    )],
15099                    contains_boundary: false,
15100                },
15101            ),
15102            (
15103                "hidden-if".to_string(),
15104                FragmentList {
15105                    fragments: vec![WebUIFragment::component("comp-hidden")],
15106                    contains_boundary: false,
15107                },
15108            ),
15109            (
15110                "comp-hidden".to_string(),
15111                FragmentList {
15112                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15113                    contains_boundary: false,
15114                },
15115            ),
15116        ]);
15117        let mut document = WebUIProtocol::new(fragments);
15118        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15119        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15120        document.components.insert(
15121            "comp-a".to_string(),
15122            webui_protocol::ComponentData {
15123                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15124                hydration_mode: StateProjectionMode::Keys as i32,
15125                hydration_keys: vec!["show_hidden".to_string()],
15126                ..Default::default()
15127            },
15128        );
15129        document.components.insert(
15130            "comp-hidden".to_string(),
15131            webui_protocol::ComponentData {
15132                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15133                css: ".hidden{display:block}".to_string(),
15134                hydration_mode: StateProjectionMode::Keys as i32,
15135                hydration_keys: vec!["hidden_count".to_string()],
15136                ..Default::default()
15137            },
15138        );
15139        document.populate_style_closures(&["index.html"]);
15140        let protocol = Protocol::new(document);
15141        let handler = WebUIHandler::with_plugin(|| {
15142            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15143        });
15144        let mut writer = FlushTestWriter::default();
15145
15146        handler
15147            .render_streaming(
15148                &protocol,
15149                &test_json!({
15150                    "show_hidden": false,
15151                    "hidden_count": 7,
15152                    "unrelated": "private",
15153                }),
15154                &RenderOptions::new("index.html", "/"),
15155                &mut writer,
15156            )
15157            .unwrap();
15158
15159        let boundary = |index: usize| -> &str {
15160            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15161        };
15162        let first = boundary(0);
15163        let second = boundary(1);
15164
15165        assert!(first.contains(r#""comp-a":"#), "first: {first}");
15166        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15167        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15168        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15169        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15170        assert!(
15171            first.contains(r#"<script type="importmap""#),
15172            "first: {first}"
15173        );
15174        assert!(
15175            !first.contains("unrelated"),
15176            "first leaked unrelated state: {first}"
15177        );
15178
15179        assert!(
15180            !second.contains(r#""templates""#),
15181            "second re-sent metadata: {second}"
15182        );
15183        assert!(
15184            !second.contains(r#"<script type="importmap""#),
15185            "second re-sent CSS: {second}"
15186        );
15187        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15188        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15189        assert_eq!(
15190            writer
15191                .output
15192                .matches(r#"<script type="importmap">"#)
15193                .count(),
15194            1,
15195            "import-map output: {}",
15196            writer.output
15197        );
15198    }
15199
15200    #[test]
15201    fn streaming_flush_count_is_boundaries_plus_one() {
15202        for &count in &[1usize, 3, 10, 100] {
15203            let hosts: Vec<&str> = (0..count)
15204                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15205                .collect();
15206            let protocol = disjoint_streaming_protocol(&hosts);
15207            let handler = WebUIHandler::with_plugin(|| {
15208                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15209            });
15210            let mut writer = FlushTestWriter::default();
15211            handler
15212                .render_streaming(
15213                    &protocol,
15214                    &test_json!({ "a_count": 1, "b_count": 2 }),
15215                    &RenderOptions::new("index.html", "/"),
15216                    &mut writer,
15217                )
15218                .unwrap();
15219            assert_eq!(
15220                writer.flushes.len(),
15221                count + 2,
15222                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15223            );
15224        }
15225    }
15226
15227    #[test]
15228    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15229        let mut fragments = HashMap::new();
15230        fragments.insert("comp-a".to_string(), FragmentList::default());
15231        fragments.insert("comp-b".to_string(), FragmentList::default());
15232        let mut protocol = WebUIProtocol::new(fragments);
15233        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15234        protocol.components.insert(
15235            "comp-a".to_string(),
15236            webui_protocol::ComponentData {
15237                hydration_mode: StateProjectionMode::Keys as i32,
15238                hydration_keys: vec!["a".to_string(), "shared".to_string()],
15239                ..Default::default()
15240            },
15241        );
15242        protocol.components.insert(
15243            "comp-b".to_string(),
15244            webui_protocol::ComponentData {
15245                hydration_mode: StateProjectionMode::Keys as i32,
15246                hydration_keys: vec!["b".to_string(), "shared".to_string()],
15247                ..Default::default()
15248            },
15249        );
15250
15251        let protocol = Protocol::new(protocol);
15252        let component_index = protocol.component_index();
15253        let reachability = protocol.component_reachability();
15254        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15255        assert!(!collect_hydration_key_ids_into(
15256            protocol.protocol(),
15257            reachability,
15258            [component_index["comp-a"]],
15259            &mut scratch,
15260        ));
15261        let keys = scratch
15262            .iter()
15263            .filter_map(|id| reachability.hydration_key(*id))
15264            .collect::<Vec<_>>();
15265        assert_eq!(keys, ["a", "shared"]);
15266        let pointer = scratch.as_ptr();
15267        let capacity = scratch.capacity();
15268
15269        assert!(!collect_hydration_key_ids_into(
15270            protocol.protocol(),
15271            reachability,
15272            [component_index["comp-b"]],
15273            &mut scratch,
15274        ));
15275        let keys = scratch
15276            .iter()
15277            .filter_map(|id| reachability.hydration_key(*id))
15278            .collect::<Vec<_>>();
15279        assert_eq!(keys, ["b", "shared"]);
15280        assert_eq!(scratch.as_ptr(), pointer);
15281        assert_eq!(scratch.capacity(), capacity);
15282    }
15283}