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    /// Component tag → covering bundle chunk, built once per render.
869    ///
870    /// Empty (and allocation-free) for unbundled builds. Every style delivery
871    /// path resolves closure members through this so they all agree on what a
872    /// chunk already ships.
873    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
874    /// CSS strategy declared by the compiled protocol.
875    pub(crate) css_strategy: webui_protocol::CssStrategy,
876    /// HTML emitted at the structural `head_end` boundary (before
877    /// `</head>`), after the built-in nonce/CSS emissions.
878    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
879    /// (no per-render clone — saves an allocation when the host passes
880    /// a `&'static str` such as a dev livereload script).
881    pub(crate) head_inject: Option<&'protocol str>,
882    /// HTML emitted at the structural `body_end` boundary (before
883    /// `</body>`), after the built-in template metadata emissions.
884    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
885    pub(crate) body_inject: Option<&'protocol str>,
886    /// Host HTML resolved once per render from the reserved
887    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
888    /// emissions at each structural boundary, and after the corresponding
889    /// `RenderOptions` inject at `head_end` and `body_end`.
890    pub(crate) state_inject: StateInject<'state>,
891    /// Tracks whether the `head_end` hook has already fired in this
892    /// render. Defends against malformed protocols that emit the
893    /// signal more than once (e.g., a template with multiple `<head>`
894    /// tags) — without this, host-supplied `head_inject` HTML, CSS
895    /// resources, and the CSP `<meta>` nonce would be
896    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
897    pub(crate) head_end_emitted: bool,
898    /// Tracks whether the `body_start` hook has already fired in this
899    /// render. Defends against malformed protocols emitting the signal
900    /// twice — without this, state-supplied `bodyStart` HTML would be
901    /// duplicated.
902    pub(crate) body_start_emitted: bool,
903    /// Tracks whether compiler-owned component asset styles were emitted at
904    /// either the head or body fallback boundary.
905    pub(crate) component_asset_styles_emitted: bool,
906    /// Tracks whether the `body_end` hook has already fired in this
907    /// render. Defends against malformed protocols emitting the
908    /// signal twice — without this, hydration `<script>` blocks and
909    /// host-supplied `body_inject` would be duplicated.
910    pub(crate) body_end_emitted: bool,
911    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
912    pub(crate) route_index: &'protocol CompiledRouteIndex,
913    /// Counter for `data-ri` attributes on matched route elements.
914    /// Incremented each time a matched route is rendered, allowing O(1) element
915    /// binding on the client side instead of DOM-walking.
916    pub(crate) route_chain_index: usize,
917    /// Matched route metadata computed at most once when head styles or body
918    /// bootstrap first need it, then shared by streaming checkpoints.
919    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
920    /// Whether each matched route closure installs into the Document CSS tree.
921    ///
922    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
923    pub(crate) route_document_style_targets: Vec<bool>,
924    /// Request-reachable components in deterministic first-discovery order.
925    ///
926    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
927    /// `body_end` for hydration metadata so the fragment graph is not walked
928    /// twice per request.
929    pub(crate) reachable_components: Option<Vec<String>>,
930    /// Present only for the opt-in progressive streaming render path.
931    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
932    /// Reusable JSON serialization scratch buffer, owned by the render context.
933    /// The bootstrap/field/template helpers borrow it so each render reuses one
934    /// buffer across every serialized field and every streaming checkpoint
935    /// instead of allocating per value. It grows lazily on first serialization
936    /// and is dropped with the context at request end — no per-thread
937    /// high-water buffer is retained between requests.
938    pub(crate) json_scratch: Vec<u8>,
939    /// Small request-local pool of cleared scope maps reused across sibling
940    /// component roots. `process_component` recycles each finished component's
941    /// local/attr map here instead of dropping it, so a sibling reuses the
942    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
943    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
944    pub(crate) scope_pool: Vec<HashMap<String, Value>>,
945    /// Resources delivered into the Document CSS tree. The empty set does not
946    /// allocate, and streaming retains it across checkpoints.
947    pub(crate) document_style_resources: HashSet<String>,
948    /// Active Shadow roots and their route-activated resource indexes. The
949    /// route vector allocates only when a matched Light route contributes CSS.
950    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
951    /// Capacity-preserving pool for borrowed component scope maps.
952    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
953}
954
955/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
956/// authored double- or triple-brace expressions because it closes the binding.
957pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
958
959pub(crate) fn structural_signal_value(
960    signal: &webui_protocol::WebUIFragmentSignal,
961) -> Option<&str> {
962    if !signal.raw {
963        return None;
964    }
965    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
966}
967
968pub(crate) fn write_interaction_marker(
969    tag: &str,
970    context: &mut WebUIProcessContext<'_, '_, '_>,
971) -> Result<()> {
972    if matches!(
973        context
974            .protocol
975            .components
976            .get(tag)
977            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
978        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
979    ) {
980        context.writer.write(" data-webui-interaction")?;
981    }
982    Ok(())
983}
984
985/// Find the end of a leading doctype without treating quoted `>` bytes in
986/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
987fn doctype_prefix_end(raw: &str) -> Option<usize> {
988    const PREFIX: &[u8] = b"<!doctype";
989
990    let bytes = raw.as_bytes();
991    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
992    while bytes
993        .get(cursor)
994        .is_some_and(|byte| byte.is_ascii_whitespace())
995    {
996        cursor += 1;
997    }
998
999    let prefix_end = cursor.checked_add(PREFIX.len())?;
1000    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1001        return None;
1002    }
1003    let separator = *bytes.get(prefix_end)?;
1004    if separator != b'>' && !separator.is_ascii_whitespace() {
1005        return None;
1006    }
1007
1008    let mut quote = None;
1009    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1010        match quote {
1011            Some(active) if byte == active => quote = None,
1012            Some(_) => {}
1013            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1014            None if byte == b'>' => return Some(prefix_end + offset + 1),
1015            None => {}
1016        }
1017    }
1018    None
1019}
1020
1021/// Maximum scope maps retained in the request-local pool. Small: sibling
1022/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1023const SCOPE_POOL_CAP: usize = 8;
1024/// Borrowed component scopes typically contain only a handful of attributes.
1025const INLINE_SCOPE_SLOTS: usize = 4;
1026/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1027/// a typical app touches in one render while keeping the per-render zeroing cost
1028/// negligible; larger protocols fall back to the protocol map for the overflow
1029/// slots instead of eagerly materializing every list.
1030/// Inline memo slots for fragment lists.
1031///
1032/// Deliberately small: the array is zeroed on every render, so a shallow render
1033/// touching two fragments should not pay to clear slots it will never read.
1034/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1035/// memoized without charging that cost to the common shallow case.
1036const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1037const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1038
1039struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1040
1041impl Serialize for ComponentAssetStyleManifest<'_> {
1042    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1043    where
1044        S: serde::Serializer,
1045    {
1046        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1047        for entry in self.0 {
1048            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1049        }
1050        map.end()
1051    }
1052}
1053
1054fn serialize_component_asset_style_manifest(
1055    preloads: &[ComponentAssetStylePreload],
1056) -> std::result::Result<String, serde_json::Error> {
1057    if preloads.is_empty() {
1058        return Ok(String::new());
1059    }
1060    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1061    if json.contains("</") {
1062        Ok(json.replace("</", "<\\/"))
1063    } else {
1064        Ok(json)
1065    }
1066}
1067
1068fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1069    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1070    let mut seen = HashSet::with_capacity(href_count);
1071    let mut links = String::with_capacity(href_count.saturating_mul(48));
1072    for entry in preloads {
1073        for href in &entry.style_hrefs {
1074            if !seen.insert(href.as_str()) {
1075                continue;
1076            }
1077            links.push_str("<link rel=\"stylesheet\" href=\"");
1078            push_escaped_html_attribute(&mut links, href);
1079            links.push_str("\">");
1080        }
1081    }
1082    links
1083}
1084
1085fn push_escaped_html_attribute(output: &mut String, value: &str) {
1086    let mut start = 0;
1087    for (index, ch) in value.char_indices() {
1088        let escaped = match ch {
1089            '&' => Some("&amp;"),
1090            '"' => Some("&quot;"),
1091            '<' => Some("&lt;"),
1092            '>' => Some("&gt;"),
1093            _ => None,
1094        };
1095        let Some(entity) = escaped else {
1096            continue;
1097        };
1098        if start < index {
1099            output.push_str(&value[start..index]);
1100        }
1101        output.push_str(entity);
1102        start = index + ch.len_utf8();
1103    }
1104    if start < value.len() {
1105        output.push_str(&value[start..]);
1106    }
1107}
1108
1109/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1110/// empty. A fresh `HashMap` does not allocate until its first insert.
1111fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1112    pool.pop().unwrap_or_default()
1113}
1114
1115/// Return a spent scope map to the pool, clearing it but retaining its bucket
1116/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1117/// retained memory stays bounded.
1118fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1119    if pool.len() < SCOPE_POOL_CAP {
1120        map.clear();
1121        pool.push(map);
1122    }
1123}
1124
1125fn take_borrowed_scope<'protocol, 'state>(
1126    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1127) -> BorrowedScope<'protocol, 'state> {
1128    pool.pop().unwrap_or_default()
1129}
1130
1131fn recycle_borrowed_scope<'protocol, 'state>(
1132    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1133    mut scope: BorrowedScope<'protocol, 'state>,
1134) {
1135    if pool.len() < SCOPE_POOL_CAP {
1136        scope.clear();
1137        pool.push(scope);
1138    }
1139}
1140
1141pub(crate) struct WebUiBootstrap<'a> {
1142    pub(crate) declaration_id: Option<u32>,
1143    pub(crate) enclosing_span_instance_id: Option<u32>,
1144    pub(crate) state: &'a Value,
1145    pub(crate) state_selection: StateSelection<'a>,
1146    pub(crate) chain: &'a [Value],
1147    pub(crate) inventory: &'a str,
1148    pub(crate) nonce: Option<&'a str>,
1149    pub(crate) css_hrefs: &'a [&'a str],
1150    pub(crate) style_specs: &'a [&'a str],
1151    pub(crate) component_styles: &'a Value,
1152    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1153}
1154
1155/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1156///
1157/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1158/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1159/// `tabindex`) via the shared lookup table.
1160///
1161/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1162/// this, so it remains only as the single-name form of
1163/// [`push_component_attr_name`] for tests.
1164#[cfg(test)]
1165fn component_attr_name(name: &str) -> String {
1166    let mut buffer = String::new();
1167    push_component_attr_name(&mut buffer, name);
1168    buffer
1169}
1170
1171/// Append the canonical camelCase component prop name for `name` to `buffer`.
1172///
1173/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1174/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1175/// like `readonly`, `tabindex`) through the shared lookup table.
1176///
1177/// Used while preparing the render fragment index so a protocol with thousands
1178/// of attribute fragments grows one arena instead of allocating per name.
1179fn push_component_attr_name(buffer: &mut String, name: &str) {
1180    let stripped = name.strip_prefix(':').unwrap_or(name);
1181    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1182    // straight into the shared arena. That function returns an owned `String`,
1183    // so calling it here would allocate and drop one buffer per attribute while
1184    // preparing a protocol.
1185    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1186        buffer.push_str(property);
1187        return;
1188    }
1189    buffer.reserve(stripped.len());
1190    let mut capitalize_next = false;
1191    for ch in stripped.chars() {
1192        if ch == '-' {
1193            capitalize_next = true;
1194        } else if capitalize_next {
1195            buffer.extend(ch.to_uppercase());
1196            capitalize_next = false;
1197        } else {
1198            buffer.push(ch);
1199        }
1200    }
1201}
1202
1203#[cold]
1204#[inline(never)]
1205fn missing_component_attr_name_error() -> HandlerError {
1206    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1207}
1208
1209/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1210pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1211    if n == 0 {
1212        return writer.write("0");
1213    }
1214    // Max digits for a 64-bit usize is 20.
1215    let mut buf = [0u8; 20];
1216    let mut pos = buf.len();
1217    while n > 0 {
1218        pos -= 1;
1219        // n % 10 is always in 0..=9, fits in u8 without truncation.
1220        #[allow(clippy::cast_possible_truncation)]
1221        let digit = (n % 10) as u8;
1222        buf[pos] = b'0' + digit;
1223        n /= 10;
1224    }
1225    // Digits are always valid ASCII/UTF-8.
1226    match std::str::from_utf8(&buf[pos..]) {
1227        Ok(s) => writer.write(s),
1228        Err(_) => writer.write("0"),
1229    }
1230}
1231
1232pub(crate) fn write_script_safe_json<T>(
1233    writer: &mut dyn ResponseWriter,
1234    scratch: &mut Vec<u8>,
1235    value: &T,
1236) -> Result<()>
1237where
1238    T: Serialize + ?Sized,
1239{
1240    // Serialize into the caller's request-local `scratch`. The streaming path
1241    // emits one bootstrap envelope per committed boundary and the ordinary
1242    // body_end bootstrap serializes several fields, so reusing one buffer across
1243    // the render avoids a fresh allocation per value. The buffer grows lazily on
1244    // first use (no allocation until serialization needs it) and is dropped with
1245    // the render context — capacity is reused within a request but never
1246    // retained across requests.
1247    scratch.clear();
1248    serde_json::to_writer(&mut *scratch, value)
1249        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1250    let json = std::str::from_utf8(scratch)
1251        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1252    write_script_safe_json_str(writer, json)
1253}
1254
1255fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1256    let mut start = 0;
1257    while start < json.len() {
1258        let rest = &json[start..];
1259        let Some(offset) = rest.find("</") else {
1260            writer.write(rest)?;
1261            return Ok(());
1262        };
1263
1264        if offset > 0 {
1265            writer.write(&rest[..offset])?;
1266        }
1267        writer.write("<\\/")?;
1268        start += offset + 2;
1269    }
1270    Ok(())
1271}
1272
1273fn write_json_field_name(
1274    writer: &mut dyn ResponseWriter,
1275    wrote_field: &mut bool,
1276    name: &str,
1277) -> Result<()> {
1278    if *wrote_field {
1279        writer.write(",")?;
1280    }
1281    *wrote_field = true;
1282    writer.write("\"")?;
1283    writer.write(name)?;
1284    writer.write("\":")
1285}
1286
1287fn write_json_field<T>(
1288    writer: &mut dyn ResponseWriter,
1289    scratch: &mut Vec<u8>,
1290    wrote_field: &mut bool,
1291    name: &str,
1292    value: &T,
1293) -> Result<()>
1294where
1295    T: Serialize + ?Sized,
1296{
1297    write_json_field_name(writer, wrote_field, name)?;
1298    write_script_safe_json(writer, scratch, value)
1299}
1300
1301/// Serialize wrapper that projects an SSR state object down to only the
1302/// keys present in the build-time hydration allowlist.
1303///
1304/// This is the runtime half of the projected-hydration design: instead of
1305/// serializing the entire application state (potentially megabytes) on every
1306/// full-HTML render, only the fields a component actually hydrates are
1307/// emitted. The request allowlist conservatively includes every reachable
1308/// component's hydration keys so no field a component needs is dropped.
1309///
1310/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1311/// by compiled client metadata is browser-facing, so hosts must never place
1312/// secrets in browser render state.
1313///
1314/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1315/// is smaller: hydration keys with direct map lookup for wide states, or state
1316/// entries with binary-search membership for compact states. Non-object states
1317/// carry nothing hydratable and serialize as an empty object.
1318struct ProjectedState<'a> {
1319    value: &'a Value,
1320    keys: KeyView<'a>,
1321}
1322
1323impl Serialize for ProjectedState<'_> {
1324    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1325    where
1326        S: serde::Serializer,
1327    {
1328        let Value::Object(map) = self.value else {
1329            return serializer.serialize_map(Some(0))?.end();
1330        };
1331
1332        let mut out = serializer.serialize_map(None)?;
1333        if self.keys.len() < map.len() {
1334            let mut previous = None;
1335            for key in self.keys.iter() {
1336                if key == STATE_INJECT_KEY {
1337                    continue;
1338                }
1339                if previous == Some(key) {
1340                    continue;
1341                }
1342                previous = Some(key);
1343                if let Some(value) = map.get(key) {
1344                    out.serialize_entry(key, value)?;
1345                }
1346            }
1347        } else {
1348            for (key, value) in map {
1349                if key == STATE_INJECT_KEY {
1350                    continue;
1351                }
1352                if self.keys.contains(key.as_str()) {
1353                    out.serialize_entry(key, value)?;
1354                }
1355            }
1356        }
1357        out.end()
1358    }
1359}
1360
1361/// Write the SSR `state` into the bootstrap block according to the protocol's
1362/// build-time selection and escape it for safe embedding in a `<script>`.
1363///
1364/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1365/// payload — a large state with a small hydratable surface — serde ever only
1366/// touches the projected subset. Serialization reuses the proven
1367/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1368/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1369/// when every key is hydratable and collapses to a few bytes when it is not.
1370/// Buffering the projected bytes and escaping once is measurably faster than
1371/// streaming through a per-token `io::Write` adapter, and the projected buffer
1372/// is tiny in the common case.
1373/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1374/// omitted.
1375///
1376/// The reserved key carries host-supplied boundary HTML, not application
1377/// state, so it must never reach the client hydration payload — shipping it
1378/// would both duplicate the markup as a JSON string and expose the host's
1379/// inject channel to client code. Filtering happens during serialization so
1380/// the state tree is never cloned.
1381struct StateWithoutReservedKey<'a> {
1382    value: &'a Value,
1383}
1384
1385impl Serialize for StateWithoutReservedKey<'_> {
1386    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1387    where
1388        S: serde::Serializer,
1389    {
1390        let Value::Object(map) = self.value else {
1391            return self.value.serialize(serializer);
1392        };
1393        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1394        for (key, value) in map {
1395            if key == STATE_INJECT_KEY {
1396                continue;
1397            }
1398            out.serialize_entry(key, value)?;
1399        }
1400        out.end()
1401    }
1402}
1403
1404/// Write the complete state, stripping the reserved inject key when present.
1405///
1406/// The membership test is a single map lookup and the common case — no
1407/// reserved key — takes the original zero-overhead path unchanged.
1408fn write_full_state(
1409    writer: &mut dyn ResponseWriter,
1410    scratch: &mut Vec<u8>,
1411    state: &Value,
1412) -> Result<()> {
1413    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1414        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1415    }
1416    write_script_safe_json(writer, scratch, state)
1417}
1418
1419pub(crate) fn write_selected_state(
1420    writer: &mut dyn ResponseWriter,
1421    scratch: &mut Vec<u8>,
1422    state: &Value,
1423    selection: &StateSelection<'_>,
1424) -> Result<()> {
1425    let keys = match selection {
1426        StateSelection::Full => return write_full_state(writer, scratch, state),
1427        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1428        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1429    };
1430    if keys.is_empty() {
1431        return writer.write("{}");
1432    }
1433
1434    // Projection membership may use binary search, so a mis-sorted key set
1435    // would silently drop hydration keys. The key allowlist is produced sorted +
1436    // deduped at build time; this guard makes hand-built protocols that violate
1437    // the invariant fail loudly in tests at zero release cost.
1438    debug_assert!(
1439        keys.iter()
1440            .zip(keys.iter().skip(1))
1441            .all(|(left, right)| left <= right),
1442        "hydration keys must be sorted for binary-search projection"
1443    );
1444    if let Value::Object(map) = state {
1445        let selects_entire_map =
1446            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1447        if selects_entire_map {
1448            return write_full_state(writer, scratch, state);
1449        }
1450    }
1451    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1452}
1453
1454// Covers the common route surface without trusting protocol-derived counts for
1455// an eager allocation; larger key sets grow only as actual keys are visited.
1456pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1457
1458/// Request-scoped state selection derived from reachable component metadata.
1459pub(crate) enum StateSelection<'a> {
1460    /// Preserve the complete state value.
1461    Full,
1462    /// Project an object to a sorted, deduplicated key allowlist.
1463    Keys(Vec<&'a str>),
1464    /// Project using the streaming continuation's interned hydration key IDs.
1465    ///
1466    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1467    /// sorted key slice and the projection needs no borrowed-string buffer at
1468    /// all — the streaming record keeps its scratch as plain integers that
1469    /// survive every semantic step.
1470    KeyIds(HydrationKeySelection<'a>),
1471}
1472
1473/// A projection expressed as interned hydration key IDs.
1474#[derive(Clone, Copy)]
1475pub(crate) struct HydrationKeySelection<'a> {
1476    pub(crate) ids: &'a [u32],
1477    pub(crate) index: &'a ComponentReachabilityIndex,
1478}
1479
1480impl<'a> HydrationKeySelection<'a> {
1481    fn key(self, position: usize) -> Option<&'a str> {
1482        let id = self.ids.get(position).copied()?;
1483        self.index.hydration_key(id)
1484    }
1485}
1486
1487/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1488#[derive(Clone, Copy)]
1489enum KeyView<'a> {
1490    Borrowed(&'a [&'a str]),
1491    Ids(HydrationKeySelection<'a>),
1492}
1493
1494impl<'a> KeyView<'a> {
1495    fn len(self) -> usize {
1496        match self {
1497            Self::Borrowed(keys) => keys.len(),
1498            Self::Ids(selection) => selection.ids.len(),
1499        }
1500    }
1501
1502    fn is_empty(self) -> bool {
1503        self.len() == 0
1504    }
1505
1506    fn get(self, position: usize) -> Option<&'a str> {
1507        match self {
1508            Self::Borrowed(keys) => keys.get(position).copied(),
1509            Self::Ids(selection) => selection.key(position),
1510        }
1511    }
1512
1513    fn iter(self) -> impl Iterator<Item = &'a str> {
1514        (0..self.len()).map_while(move |position| self.get(position))
1515    }
1516
1517    /// Sorted-membership probe used when the state object is smaller than the
1518    /// allowlist.
1519    fn contains(self, key: &str) -> bool {
1520        match self {
1521            Self::Borrowed(keys) => keys
1522                .binary_search_by(|candidate| str::cmp(candidate, key))
1523                .is_ok(),
1524            Self::Ids(selection) => selection
1525                .ids
1526                .binary_search_by(|id| {
1527                    selection
1528                        .index
1529                        .hydration_key(*id)
1530                        .map_or(std::cmp::Ordering::Less, |candidate| {
1531                            str::cmp(candidate, key)
1532                        })
1533                })
1534                .is_ok(),
1535        }
1536    }
1537}
1538
1539#[derive(Clone, Copy)]
1540enum ComponentStateSurface {
1541    Hydration,
1542    Navigation,
1543}
1544
1545/// Select initial state for the components reachable on this request path.
1546///
1547/// Non-WebUI protocols preserve full state without walking component surfaces.
1548/// WebUI protocols project exact surfaces, while any unknown surface restores
1549/// the full state for correctness.
1550pub(crate) fn collect_hydration_state<'a, 'b>(
1551    protocol: &'a WebUIProtocol,
1552    components: impl IntoIterator<Item = &'b str>,
1553) -> StateSelection<'a> {
1554    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1555        return StateSelection::Full;
1556    }
1557    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1558}
1559
1560/// Fill a reusable hydration key-ID allowlist for a streaming record.
1561///
1562/// Components arrive as inventory indexes, so the projection never resolves a
1563/// component name, never hashes it against the compiled component map, and
1564/// never sorts strings: the interned runs concatenate and the integer sort
1565/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1566/// requires sending full state instead.
1567pub(crate) fn collect_hydration_key_ids_into(
1568    protocol: &WebUIProtocol,
1569    index: &ComponentReachabilityIndex,
1570    components: impl IntoIterator<Item = u32>,
1571    ids: &mut Vec<u32>,
1572) -> bool {
1573    ids.clear();
1574    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1575        return true;
1576    }
1577    for component in components {
1578        if index.extend_hydration_keys(component, ids) {
1579            ids.clear();
1580            return true;
1581        }
1582    }
1583    ids.sort_unstable();
1584    ids.dedup();
1585    false
1586}
1587
1588/// Select state for client-created components reachable during navigation.
1589pub(crate) fn collect_navigation_state<'a, 'b>(
1590    protocol: &'a WebUIProtocol,
1591    components: impl IntoIterator<Item = &'b str>,
1592) -> StateSelection<'a> {
1593    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1594}
1595
1596fn collect_component_state<'a, 'b>(
1597    protocol: &'a WebUIProtocol,
1598    components: impl IntoIterator<Item = &'b str>,
1599    surface: ComponentStateSurface,
1600) -> StateSelection<'a> {
1601    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1602    if collect_component_state_into(protocol, components, surface, &mut keys) {
1603        StateSelection::Full
1604    } else {
1605        StateSelection::Keys(keys)
1606    }
1607}
1608
1609/// Fill a reusable state-key allowlist. Returns `true` when correctness
1610/// requires sending full state instead of the collected keys.
1611fn collect_component_state_into<'a, 'b>(
1612    protocol: &'a WebUIProtocol,
1613    components: impl IntoIterator<Item = &'b str>,
1614    surface: ComponentStateSurface,
1615    keys: &mut Vec<&'a str>,
1616) -> bool {
1617    keys.clear();
1618    for name in components {
1619        let Some(component) = protocol.components.get(name) else {
1620            return true;
1621        };
1622        let (mode, component_keys) = match surface {
1623            ComponentStateSurface::Hydration => {
1624                (component.hydration_mode, &component.hydration_keys)
1625            }
1626            ComponentStateSurface::Navigation => (
1627                match component.navigation_mode {
1628                    Some(mode) => mode,
1629                    None if !component.navigation_keys.is_empty() => {
1630                        StateProjectionMode::Keys as i32
1631                    }
1632                    None => return true,
1633                },
1634                &component.navigation_keys,
1635            ),
1636        };
1637        if mode == StateProjectionMode::All as i32 {
1638            return true;
1639        }
1640        if mode == StateProjectionMode::Keys as i32
1641            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1642        {
1643            keys.extend(component_keys.iter().map(String::as_str));
1644        } else if mode != StateProjectionMode::None as i32 {
1645            return true;
1646        }
1647    }
1648    keys.sort_unstable();
1649    keys.dedup();
1650    false
1651}
1652
1653pub(crate) fn write_webui_bootstrap(
1654    writer: &mut dyn ResponseWriter,
1655    scratch: &mut Vec<u8>,
1656    bootstrap: WebUiBootstrap<'_>,
1657) -> Result<()> {
1658    let mut wrote_field = false;
1659
1660    writer.write("{")?;
1661    if let Some(declaration_id) = bootstrap.declaration_id {
1662        write_json_field(
1663            writer,
1664            scratch,
1665            &mut wrote_field,
1666            "declarationId",
1667            &declaration_id,
1668        )?;
1669    }
1670    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1671        write_json_field(
1672            writer,
1673            scratch,
1674            &mut wrote_field,
1675            "enclosingSpanInstanceId",
1676            &span_id,
1677        )?;
1678    }
1679    if !bootstrap.chain.is_empty() {
1680        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1681    }
1682    // Definitions must be visible before template metadata can cause the
1683    // runtime to create a component root.
1684    write_json_field(
1685        writer,
1686        scratch,
1687        &mut wrote_field,
1688        "componentStyles",
1689        bootstrap.component_styles,
1690    )?;
1691    if !bootstrap.css_hrefs.is_empty() {
1692        write_json_field(
1693            writer,
1694            scratch,
1695            &mut wrote_field,
1696            "css",
1697            bootstrap.css_hrefs,
1698        )?;
1699    }
1700    write_json_field(
1701        writer,
1702        scratch,
1703        &mut wrote_field,
1704        "inventory",
1705        bootstrap.inventory,
1706    )?;
1707    if let Some(nonce) = bootstrap.nonce {
1708        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1709    }
1710    write_json_field_name(writer, &mut wrote_field, "state")?;
1711    write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
1712    if !bootstrap.style_specs.is_empty() {
1713        write_json_field(
1714            writer,
1715            scratch,
1716            &mut wrote_field,
1717            "styles",
1718            bootstrap.style_specs,
1719        )?;
1720    }
1721    if bootstrap
1722        .templates
1723        .iter()
1724        .any(|template| !template.template_json.is_empty())
1725    {
1726        write_json_field_name(writer, &mut wrote_field, "templates")?;
1727        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1728    }
1729    writer.write("}")
1730}
1731
1732fn write_webui_data_block(
1733    writer: &mut dyn ResponseWriter,
1734    scratch: &mut Vec<u8>,
1735    bootstrap: WebUiBootstrap<'_>,
1736) -> Result<()> {
1737    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1738    if let Some(nonce) = bootstrap.nonce {
1739        writer.write(" nonce=\"")?;
1740        writer.write(nonce)?;
1741        writer.write("\"")?;
1742    }
1743
1744    writer.write(">")?;
1745    write_webui_bootstrap(writer, scratch, bootstrap)?;
1746    writer.write("</script>\n")
1747}
1748
1749fn write_component_asset_style_manifest(
1750    writer: &mut dyn ResponseWriter,
1751    manifest: &str,
1752    nonce: Option<&str>,
1753) -> Result<()> {
1754    if manifest.is_empty() {
1755        return Ok(());
1756    }
1757    writer.write("<script type=\"application/json\" id=\"")?;
1758    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1759    writer.write("\"")?;
1760    if let Some(nonce) = nonce {
1761        writer.write(" nonce=\"")?;
1762        writer.write(&crate::html_encode::encode_safe(nonce))?;
1763        writer.write("\"")?;
1764    }
1765    writer.write(">")?;
1766    writer.write(manifest)?;
1767    writer.write("</script>")
1768}
1769
1770fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1771    if context.component_asset_styles_emitted {
1772        return Ok(());
1773    }
1774    context.component_asset_styles_emitted = true;
1775    context.writer.write(context.component_asset_style_links)?;
1776    write_component_asset_style_manifest(
1777        context.writer,
1778        context.component_asset_style_manifest,
1779        context.nonce,
1780    )
1781}
1782
1783fn write_webui_template_json_map(
1784    writer: &mut dyn ResponseWriter,
1785    scratch: &mut Vec<u8>,
1786    templates: &[WebUiTemplatePayload<'_>],
1787) -> Result<()> {
1788    writer.write("{")?;
1789    let mut wrote = false;
1790    for template in templates {
1791        if template.template_json.is_empty() {
1792            continue;
1793        }
1794        if wrote {
1795            writer.write(",")?;
1796        }
1797        wrote = true;
1798        write_script_safe_json(writer, scratch, template.tag_name)?;
1799        writer.write(":")?;
1800        write_script_safe_json_str(writer, template.template_json)?;
1801    }
1802    writer.write("}")
1803}
1804
1805fn resolve_value_from_sources<'ctx, 'state>(
1806    path: &str,
1807    loop_vars: &'ctx [LoopBinding<'_, 'state>],
1808    visible_loop_scope: VisibleLoopScope,
1809    local_values: LocalValueSources<'ctx, '_, 'state>,
1810    state: &'state Value,
1811) -> Option<Cow<'ctx, Value>>
1812where
1813    'state: 'ctx,
1814{
1815    if let Some(binding) = loop_vars
1816        .get(visible_loop_scope.start..visible_loop_scope.end)
1817        .and_then(|bindings| bindings.last())
1818    {
1819        let name = binding.name;
1820        if path.len() == name.len() && path == name {
1821            return Some(Cow::Borrowed(binding.value));
1822        }
1823        if path.len() > name.len()
1824            && path.as_bytes().get(name.len()) == Some(&b'.')
1825            && path.starts_with(name)
1826        {
1827            if let Some(value) =
1828                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1829            {
1830                return Some(value);
1831            }
1832            return find_value_by_dotted_path_ref(path, state);
1833        }
1834    }
1835
1836    if let Some(first_part) = path.split('.').next() {
1837        if let Some(local_value) = local_values.borrowed.get(first_part) {
1838            if first_part.len() == path.len() {
1839                return Some(Cow::Borrowed(local_value));
1840            }
1841            let remaining = &path[first_part.len() + 1..];
1842            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1843                return Some(value);
1844            }
1845            return find_value_by_dotted_path_ref(path, state);
1846        }
1847        if let Some(local_value) = local_values.owned.get(first_part) {
1848            if first_part.len() == path.len() {
1849                return Some(Cow::Borrowed(local_value));
1850            }
1851            let remaining = &path[first_part.len() + 1..];
1852            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1853                return Some(value);
1854            }
1855            return find_value_by_dotted_path_ref(path, state);
1856        }
1857
1858        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1859            .iter()
1860            .rev()
1861        {
1862            if binding.name != first_part {
1863                continue;
1864            }
1865            if first_part.len() == path.len() {
1866                return Some(Cow::Borrowed(binding.value));
1867            }
1868            let remaining = &path[first_part.len() + 1..];
1869            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1870                return Some(value);
1871            }
1872            return find_value_by_dotted_path_ref(path, state);
1873        }
1874    }
1875
1876    find_value_by_dotted_path_ref(path, state)
1877}
1878
1879fn resolve_borrowed_collection<'state>(
1880    path: &str,
1881    loop_vars: &[LoopBinding<'_, 'state>],
1882    visible_loop_scope: VisibleLoopScope,
1883    local_values: LocalValueSources<'_, '_, 'state>,
1884    state: &'state Value,
1885) -> Option<&'state [Value]> {
1886    let first_part = path.split('.').next()?;
1887    if let Some(binding) = loop_vars
1888        .get(visible_loop_scope.start..visible_loop_scope.end)
1889        .and_then(|bindings| bindings.last())
1890        .filter(|binding| binding.name == first_part)
1891    {
1892        if first_part.len() == path.len() {
1893            return binding.value.as_array().map(Vec::as_slice);
1894        }
1895        let remaining = &path[first_part.len() + 1..];
1896        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1897            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1898            Some(Cow::Owned(_)) => None,
1899            None => borrowed_state_array(path, state),
1900        };
1901    }
1902    if let Some(local_value) = local_values.borrowed.get(first_part) {
1903        let local_match = if first_part.len() == path.len() {
1904            Some(Cow::Borrowed(local_value))
1905        } else {
1906            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1907        };
1908        return match local_match {
1909            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1910            Some(Cow::Owned(_)) => None,
1911            None => borrowed_state_array(path, state),
1912        };
1913    }
1914    if let Some(local_value) = local_values.owned.get(first_part) {
1915        let local_match = if first_part.len() == path.len() {
1916            Some(Cow::Borrowed(local_value))
1917        } else {
1918            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1919        };
1920        if local_match.is_some() {
1921            return None;
1922        }
1923        return borrowed_state_array(path, state);
1924    }
1925
1926    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1927        .iter()
1928        .rev()
1929    {
1930        if binding.name != first_part {
1931            continue;
1932        }
1933        if first_part.len() == path.len() {
1934            return binding.value.as_array().map(Vec::as_slice);
1935        }
1936        let remaining = &path[first_part.len() + 1..];
1937        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1938            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1939            Some(Cow::Owned(_)) => None,
1940            None => borrowed_state_array(path, state),
1941        };
1942    }
1943
1944    borrowed_state_array(path, state)
1945}
1946
1947fn resolve_state_backed_value<'state>(
1948    path: &str,
1949    loop_vars: &[LoopBinding<'_, 'state>],
1950    visible_loop_scope: VisibleLoopScope,
1951    local_values: LocalValueSources<'_, '_, 'state>,
1952    state: &'state Value,
1953) -> Option<&'state Value> {
1954    let first_part = path.split('.').next()?;
1955    if let Some(binding) = loop_vars
1956        .get(visible_loop_scope.start..visible_loop_scope.end)
1957        .and_then(|bindings| bindings.last())
1958        .filter(|binding| binding.name == first_part)
1959    {
1960        if first_part.len() == path.len() {
1961            return Some(binding.value);
1962        }
1963        let remaining = &path[first_part.len() + 1..];
1964        return match find_value_by_dotted_path_ref(remaining, binding.value) {
1965            Some(Cow::Borrowed(value)) => Some(value),
1966            Some(Cow::Owned(_)) => None,
1967            None => borrowed_state_value(path, state),
1968        };
1969    }
1970    if let Some(local_value) = local_values.borrowed.get(first_part) {
1971        if first_part.len() == path.len() {
1972            return Some(local_value);
1973        }
1974        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1975            Some(Cow::Borrowed(value)) => Some(value),
1976            Some(Cow::Owned(_)) => None,
1977            None => borrowed_state_value(path, state),
1978        };
1979    }
1980    if let Some(local_value) = local_values.owned.get(first_part) {
1981        if first_part.len() == path.len() {
1982            return None;
1983        }
1984        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1985            Some(_) => None,
1986            None => borrowed_state_value(path, state),
1987        };
1988    }
1989
1990    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1991        .iter()
1992        .rev()
1993    {
1994        if binding.name != first_part {
1995            continue;
1996        }
1997        if first_part.len() == path.len() {
1998            return Some(binding.value);
1999        }
2000        let remaining = &path[first_part.len() + 1..];
2001        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2002            Some(Cow::Borrowed(value)) => Some(value),
2003            Some(Cow::Owned(_)) => None,
2004            None => borrowed_state_value(path, state),
2005        };
2006    }
2007
2008    borrowed_state_value(path, state)
2009}
2010
2011fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2012    match find_value_by_dotted_path_ref(path, state) {
2013        Some(Cow::Borrowed(value)) => Some(value),
2014        Some(Cow::Owned(_)) | None => None,
2015    }
2016}
2017
2018fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2019    match find_value_by_dotted_path_ref(path, state)? {
2020        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2021        Cow::Owned(_) => None,
2022    }
2023}
2024
2025impl WebUIHandler {
2026    /// Create a new WebUI handler with no plugin.
2027    pub fn new() -> Self {
2028        Self {
2029            plugin_factory: None,
2030        }
2031    }
2032
2033    /// Create a new WebUI handler with a plugin factory.
2034    ///
2035    /// Each render call creates a fresh plugin instance from the factory,
2036    /// enabling concurrent renders with `&self`.
2037    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2038        Self {
2039            plugin_factory: Some(factory),
2040        }
2041    }
2042
2043    #[cfg(test)]
2044    fn handle(
2045        &self,
2046        document: &WebUIProtocol,
2047        state: &Value,
2048        options: &RenderOptions<'_>,
2049        writer: &mut dyn ResponseWriter,
2050    ) -> Result<()> {
2051        let protocol = Protocol::new(document.clone());
2052        self.render(&protocol, state, options, writer)
2053    }
2054
2055    /// Process a fragment by its prepared render slot, falling back to an ID
2056    /// lookup when the caller has no prepared slot (handler-generated hosts).
2057    ///
2058    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2059    /// such as loop iteration variables. This is separate from the global `state`.
2060    fn process_fragment_target<'data>(
2061        &self,
2062        target: Option<usize>,
2063        fragment_id: &str,
2064        context: &mut WebUIProcessContext<'data, '_, '_>,
2065    ) -> Result<()> {
2066        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2067            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2068        };
2069        let Some(fragment_list) = context.render_fragments.list(index) else {
2070            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2071        };
2072        self.process_fragment(fragment_list, context)
2073    }
2074
2075    /// Process a vector of fragments.
2076    ///
2077    /// The `context` maintains scope-specific variables that can be accessed by fragments
2078    /// during rendering, while `state` contains the global application state.
2079    fn process_fragment<'data>(
2080        &self,
2081        fragment_list: RenderFragmentList<'data>,
2082        context: &mut WebUIProcessContext<'data, '_, '_>,
2083    ) -> Result<()> {
2084        self.process_fragment_from(fragment_list, 0, context)
2085    }
2086
2087    fn process_fragment_from<'data>(
2088        &self,
2089        fragment_list: RenderFragmentList<'data>,
2090        start: usize,
2091        context: &mut WebUIProcessContext<'data, '_, '_>,
2092    ) -> Result<()> {
2093        let fragments = fragment_list.fragments;
2094        // Pre-scan: find the best matching route among sibling routes by specificity.
2095        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2096        // Resolves relative paths (`./`) using the current route_base.
2097        // Lists prepared without any route fragment cannot produce a match, so
2098        // the scan is skipped entirely for them.
2099        let best_route = if fragment_list.has_routes {
2100            route_renderer::find_best_route_match(
2101                fragments,
2102                context.request_path,
2103                &context.route_base,
2104                context.route_index,
2105            )
2106        } else {
2107            None
2108        };
2109        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2110    }
2111
2112    fn process_fragment_range<'data>(
2113        &self,
2114        fragment_list: RenderFragmentList<'data>,
2115        range: std::ops::Range<usize>,
2116        best_route: &Option<(String, route_matcher::RouteMatch)>,
2117        context: &mut WebUIProcessContext<'data, '_, '_>,
2118    ) -> Result<()> {
2119        let fragments = fragment_list.fragments;
2120        let Some(selected) = fragments.get(range.clone()) else {
2121            return Err(invalid_fragment_range_error(&range, fragments.len()));
2122        };
2123        for (offset, item) in selected.iter().enumerate() {
2124            let index = range.start + offset;
2125            if context.streaming.is_some() {
2126                validate_pending_streaming_root(item, context)?;
2127                validate_streaming_root_opening(&fragments[..index], item)?;
2128            }
2129            // The prepared target is only read by fragments that descend, so it
2130            // is resolved per-arm. Hoisting it here would charge every raw text
2131            // fragment for a lookup it never uses.
2132            match item.fragment.as_ref() {
2133                Some(Fragment::Raw(raw)) => {
2134                    context.writer.write(&raw.value)?;
2135                }
2136                Some(Fragment::Component(component)) => {
2137                    self.process_component(
2138                        component,
2139                        fragment_list.target(index),
2140                        ComponentHostOrigin::ParserProduced,
2141                        context,
2142                    )?;
2143                }
2144                Some(Fragment::ForLoop(for_loop)) => {
2145                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2146                }
2147                Some(Fragment::Signal(signal)) => {
2148                    self.process_signal(signal, context)?;
2149                }
2150                Some(Fragment::IfCond(if_cond)) => {
2151                    self.process_if(if_cond, fragment_list.target(index), context)?;
2152                }
2153                Some(Fragment::Attribute(attr)) => {
2154                    self.process_attribute(
2155                        attr,
2156                        fragment_list.target(index),
2157                        fragment_list.component_attr_name(index),
2158                        context,
2159                    )?;
2160                }
2161                Some(Fragment::Plugin(plugin_frag)) => {
2162                    if let Some(p) = &mut context.plugin {
2163                        p.on_element_data(&plugin_frag.data, context.writer)?;
2164                    }
2165                }
2166                Some(Fragment::Route(route_frag)) => {
2167                    self.process_route(route_frag, best_route, context)?;
2168                }
2169                Some(Fragment::Outlet(_)) => {
2170                    self.process_outlet(context)?;
2171                }
2172                Some(Fragment::Boundary(_)) => {}
2173                None => {}
2174            }
2175        }
2176        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2177    }
2178
2179    /// Process an `<outlet />` directive.
2180    ///
2181    /// Matches children from the currently active route's `children` field
2182    /// against the request path, renders the matched child `<webui-route>`
2183    /// elements directly at this position (no wrapper element).
2184    fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2185        let mut children = std::mem::take(&mut context.route_children);
2186        if children.is_empty() {
2187            return Ok(());
2188        }
2189
2190        // Find the best matching child route
2191        let request_segments = route_matcher::split_request_path(context.request_path);
2192        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2193        for (idx, child) in children.iter().enumerate() {
2194            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2195                context.route_index,
2196                &child.path,
2197                &context.route_base,
2198                &request_segments,
2199                child.exact,
2200            ) {
2201                let is_better = best
2202                    .as_ref()
2203                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2204                if is_better {
2205                    best = Some((idx, m));
2206                }
2207            }
2208        }
2209
2210        // Extract grandchildren from the matched child to avoid cloning.
2211        // We swap out the children vec so we can move it into context without
2212        // cloning, then swap an empty vec back for the sibling rendering pass.
2213        let grandchildren = if let Some((idx, _)) = &best {
2214            std::mem::take(&mut children[*idx].children)
2215        } else {
2216            Vec::new()
2217        };
2218
2219        if let Some((idx, ref rm)) = best {
2220            let matched_child = &children[idx];
2221            let comp = &matched_child.fragment_id;
2222
2223            if !comp.is_empty() {
2224                let saved_route_base = context.route_base.clone();
2225                let saved_route_children = std::mem::take(&mut context.route_children);
2226
2227                if rm.consumed_segments > 0 {
2228                    context.route_base = Cow::Owned(route_matcher::compute_route_base(
2229                        context.request_path,
2230                        rm.consumed_segments,
2231                    ));
2232                }
2233
2234                context.route_children = grandchildren;
2235
2236                // Emit matched <webui-route>
2237                context.writer.write("<webui-route")?;
2238                context.writer.write(" path=\"")?;
2239                context.writer.write(&matched_child.path)?;
2240                context.writer.write("\"")?;
2241                context.writer.write(" component=\"")?;
2242                context.writer.write(comp)?;
2243                context.writer.write("\"")?;
2244                if matched_child.exact {
2245                    context.writer.write(" exact")?;
2246                }
2247                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2248                // Emit data-ri for O(1) client-side element binding
2249                let ri = context.route_chain_index;
2250                context.route_chain_index += 1;
2251                context.writer.write(" data-ri=\"")?;
2252                write_usize(context.writer, ri)?;
2253                context.writer.write("\" active>")?;
2254
2255                if !Self::component_owns_css_tree(comp, context.protocol) {
2256                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2257                }
2258                if !matched_child.content_fragment_id.is_empty() {
2259                    self.process_fragment_target(
2260                        None,
2261                        &matched_child.content_fragment_id,
2262                        context,
2263                    )?;
2264                }
2265
2266                context.writer.write("<")?;
2267                context.writer.write(comp)?;
2268                if let Some(p) = &context.plugin {
2269                    p.write_route_component_state(context.state, context.writer)?;
2270                }
2271                write_interaction_marker(comp, context)?;
2272                prepare_generated_streaming_root(comp, context)?;
2273                context.writer.write(">")?;
2274
2275                self.process_component(
2276                    &webui_protocol::WebUIFragmentComponent {
2277                        fragment_id: comp.clone(),
2278                    },
2279                    None,
2280                    ComponentHostOrigin::HandlerGenerated,
2281                    context,
2282                )?;
2283
2284                context.writer.write("</")?;
2285                context.writer.write(comp)?;
2286                context.writer.write(">")?;
2287                context.writer.write("</webui-route>")?;
2288
2289                context.route_base = saved_route_base;
2290                context.route_children = saved_route_children;
2291            }
2292        }
2293
2294        // Render non-matched siblings as hidden
2295        for (idx, child) in children.iter().enumerate() {
2296            let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2297            if !is_matched && !child.fragment_id.is_empty() {
2298                context.writer.write("<webui-route")?;
2299                context.writer.write(" path=\"")?;
2300                context.writer.write(&child.path)?;
2301                context.writer.write("\"")?;
2302                context.writer.write(" component=\"")?;
2303                context.writer.write(&child.fragment_id)?;
2304                context.writer.write("\"")?;
2305                if child.exact {
2306                    context.writer.write(" exact")?;
2307                }
2308                route_renderer::write_route_navigation_attrs(context.writer, child)?;
2309                context
2310                    .writer
2311                    .write(" style=\"display:none\"></webui-route>")?;
2312            }
2313        }
2314
2315        Ok(())
2316    }
2317
2318    /// Emit a `<script type="importmap">` tag that registers a component's
2319    /// CSS module under its specifier via a `data:text/css,…` URI.
2320    ///
2321    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2322    /// independent importmap that the browser merges at the document
2323    /// level. The per-render CSP nonce is applied when set (importmap
2324    /// scripts honor `script-src`).
2325    ///
2326    /// Example for `my-comp` with CSS `span{color:blue;}`:
2327    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2328    fn emit_css_module_importmap(
2329        &self,
2330        specifier: &str,
2331        css: &str,
2332        context: &mut WebUIProcessContext,
2333    ) -> Result<()> {
2334        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2335        context.writer.write(&tag)?;
2336        Ok(())
2337    }
2338
2339    /// Install one compiler-ordered closure into the active CSS tree.
2340    fn emit_component_style_closure(
2341        &self,
2342        root: &str,
2343        install: StyleClosureInstall,
2344        context: &mut WebUIProcessContext,
2345    ) -> Result<()> {
2346        if context.protocol.style_closures.is_empty() {
2347            return Ok(());
2348        }
2349        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2350            match context.shadow_style_roots.last() {
2351                Some(shadow_root) => {
2352                    if !shadow_root.static_closure_emitted {
2353                        return Err(HandlerError::Invariant(
2354                            "routed styles reached a ShadowRoot before its static style hook"
2355                                .to_string(),
2356                        ));
2357                    }
2358                    let root_name = context
2359                        .component_index
2360                        .iter()
2361                        .find_map(|(name, index)| {
2362                            (*index == shadow_root.component_index).then_some(name.as_str())
2363                        })
2364                        .ok_or_else(|| {
2365                            HandlerError::Invariant(
2366                                "active Shadow style root lost its protocol index".to_string(),
2367                            )
2368                        })?;
2369                    Some(
2370                        context
2371                            .protocol
2372                            .style_closures
2373                            .get(root_name)
2374                            .ok_or_else(|| {
2375                                HandlerError::Invariant(format!(
2376                            "component style closure metadata is missing Shadow root `{root_name}`"
2377                        ))
2378                            })?,
2379                    )
2380                }
2381                None => None,
2382            }
2383        } else {
2384            None
2385        };
2386        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2387            HandlerError::Invariant(format!(
2388                "component style closure metadata is missing root `{root}`"
2389            ))
2390        })?;
2391        let is_document_tree = context.shadow_style_roots.is_empty();
2392        let strategy = context.css_strategy;
2393
2394        // A bundled build delivers merged chunks; otherwise every component
2395        // delivers its own stylesheet. Both walk the closure in the same order,
2396        // so the emitted cascade is identical either way. Bundling is a
2397        // build-wide decision, so the two never mix within one protocol.
2398        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2399        let mut emitted_resources = HashSet::with_capacity(unit_count);
2400
2401        for position in 0..unit_count {
2402            let unit = context
2403                .protocol
2404                .style_closure_unit(closure, &context.style_chunk_index, position)
2405                .ok_or_else(|| {
2406                    HandlerError::Invariant(format!(
2407                        "component style closure `{root}` references out-of-range unit {position}"
2408                    ))
2409                })?;
2410            let (name, chunk) = (unit.name, unit.chunk);
2411            if !emitted_resources.insert(name) {
2412                continue;
2413            }
2414            let resource = unit.resource.ok_or_else(|| match chunk {
2415                Some(index) => HandlerError::Invariant(format!(
2416                    "component style closure `{root}` references missing style chunk {index}"
2417                )),
2418                None => HandlerError::Invariant(format!(
2419                    "component style closure `{root}` references missing resource `{name}`"
2420                )),
2421            })?;
2422            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2423                continue;
2424            }
2425            if let Some(static_closure) = shadow_static_closure {
2426                let resource_index = match chunk {
2427                    Some(index) => {
2428                        if static_closure.style_chunks.contains(&index) {
2429                            continue;
2430                        }
2431                        index
2432                    }
2433                    None => {
2434                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2435                            continue;
2436                        }
2437                        context.component_index.get(name).copied().ok_or_else(|| {
2438                            HandlerError::Invariant(format!(
2439                                "component style resource `{name}` is missing its protocol index"
2440                            ))
2441                        })?
2442                    }
2443                };
2444                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2445                    HandlerError::Invariant(
2446                        "active Shadow style root disappeared during routed style delivery"
2447                            .to_string(),
2448                    )
2449                })?;
2450                if shadow_root.routed_resources.contains(&resource_index) {
2451                    continue;
2452                }
2453                shadow_root.routed_resources.push(resource_index);
2454            }
2455
2456            match strategy {
2457                webui_protocol::CssStrategy::Link => {
2458                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2459                    context
2460                        .writer
2461                        .write(&crate::html_encode::encode_safe(resource))?;
2462                    context.writer.write("\" data-webui-resource=\"")?;
2463                    context
2464                        .writer
2465                        .write(&crate::html_encode::encode_safe(name))?;
2466                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2467                }
2468                strategy => {
2469                    context.writer.write("<style")?;
2470                    if let Some(nonce) = context.nonce {
2471                        context.writer.write(" nonce=\"")?;
2472                        context
2473                            .writer
2474                            .write(&crate::html_encode::encode_safe(nonce))?;
2475                        context.writer.write("\"")?;
2476                    }
2477                    context.writer.write(" data-webui-resource=\"")?;
2478                    context
2479                        .writer
2480                        .write(&crate::html_encode::encode_safe(name))?;
2481                    context.writer.write("\" data-webui-strategy=\"")?;
2482                    context
2483                        .writer
2484                        .write(if strategy == webui_protocol::CssStrategy::Module {
2485                            "module"
2486                        } else {
2487                            "style"
2488                        })?;
2489                    context.writer.write("\">")?;
2490                    crate::html_encode::write_style_text(context.writer, resource)?;
2491                    context.writer.write("</style>")?;
2492                }
2493            }
2494        }
2495        Ok(())
2496    }
2497
2498    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2499        if context.route_chain.is_none() {
2500            let plan = crate::route_handler::collect_route_chain_plan(
2501                context.protocol,
2502                context.entry_id,
2503                context.request_path,
2504                context.route_index,
2505            );
2506            context.route_document_style_targets = plan.document_style_targets;
2507            context.route_chain = Some(plan.entries);
2508        }
2509    }
2510
2511    fn emit_active_route_styles(
2512        &self,
2513        preloaded: &mut Vec<u32>,
2514        context: &mut WebUIProcessContext,
2515    ) -> Result<()> {
2516        Self::ensure_request_route_chain(context);
2517        let Some(chain) = context.route_chain.take() else {
2518            return Err(route_style_plan_missing_error());
2519        };
2520        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2521        if chain.len() != document_targets.len() {
2522            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2523            context.route_document_style_targets = document_targets;
2524            context.route_chain = Some(chain);
2525            return Err(error);
2526        }
2527
2528        let result = (|| {
2529            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2530                if *targets_document {
2531                    self.emit_component_style_closure(
2532                        &entry.component,
2533                        StyleClosureInstall::Static,
2534                        context,
2535                    )?;
2536                    continue;
2537                }
2538
2539                if context.css_strategy != webui_protocol::CssStrategy::Link {
2540                    continue;
2541                }
2542                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2543            }
2544            Ok(())
2545        })();
2546
2547        context.route_document_style_targets = document_targets;
2548        context.route_chain = Some(chain);
2549        result
2550    }
2551
2552    fn emit_component_style_preloads(
2553        &self,
2554        root: &str,
2555        preloaded: &mut Vec<u32>,
2556        context: &mut WebUIProcessContext,
2557    ) -> Result<()> {
2558        let Some(closure) = context.protocol.style_closures.get(root) else {
2559            return Ok(());
2560        };
2561        // A chunk shared by several tree-local roots needs one preload.
2562        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2563        for position in 0..unit_count {
2564            let Some(unit) =
2565                context
2566                    .protocol
2567                    .style_closure_unit(closure, &context.style_chunk_index, position)
2568            else {
2569                continue;
2570            };
2571            let Some(href) = unit.resource else {
2572                continue;
2573            };
2574            let name = unit.name;
2575            if context.document_style_resources.contains(name) {
2576                continue;
2577            }
2578            let Some(&resource_index) = context.style_resource_index.get(name) else {
2579                continue;
2580            };
2581            if preloaded.contains(&resource_index) {
2582                continue;
2583            }
2584            context
2585                .writer
2586                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2587            context
2588                .writer
2589                .write(&crate::html_encode::encode_safe(href))?;
2590            context.writer.write("\">")?;
2591            preloaded.push(resource_index);
2592        }
2593        Ok(())
2594    }
2595
2596    fn emit_reachable_shadow_preloads(
2597        &self,
2598        preloaded: &mut Vec<u32>,
2599        context: &mut WebUIProcessContext,
2600    ) -> Result<()> {
2601        if context.css_strategy != webui_protocol::CssStrategy::Link {
2602            return Ok(());
2603        }
2604        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2605            crate::route_handler::collect_reachable_component_order_for_request(
2606                context.protocol,
2607                context.entry_id,
2608                context.request_path,
2609                context.route_index,
2610            )
2611        });
2612        let result = (|| {
2613            for component in &reachable {
2614                if context.protocol.component_uses_shadow_dom(component) {
2615                    self.emit_component_style_preloads(component, preloaded, context)?;
2616                }
2617            }
2618            Ok(())
2619        })();
2620        context.reachable_components = Some(reachable);
2621        result
2622    }
2623
2624    fn process_shadow_style_signal(
2625        &self,
2626        root: &str,
2627        context: &mut WebUIProcessContext,
2628    ) -> Result<()> {
2629        let Some(root_index) = context.component_index.get(root).copied() else {
2630            return Err(HandlerError::Invariant(format!(
2631                "Shadow style hook `{root}` references an unknown component"
2632            )));
2633        };
2634        if context
2635            .shadow_style_roots
2636            .last()
2637            .map(|shadow_root| shadow_root.component_index)
2638            != Some(root_index)
2639        {
2640            return Err(HandlerError::Invariant(format!(
2641                "Shadow style hook `{root}` does not match the active component root"
2642            )));
2643        }
2644        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2645        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2646            HandlerError::Invariant(
2647                "active Shadow style root disappeared after its static style hook".to_string(),
2648            )
2649        })?;
2650        shadow_root.static_closure_emitted = true;
2651        Ok(())
2652    }
2653
2654    /// Emit a component's CSS module importmap on its first render
2655    /// (deduped by `rendered_components`) into the component's light DOM,
2656    /// so the browser registers it under the component's specifier
2657    /// before the shadow root template is parsed. See
2658    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2659    ///
2660    /// Only components rendered on the current route get inline definitions;
2661    /// navigation responses carry later definitions in `componentStyles`.
2662    fn emit_css_module(
2663        &self,
2664        component: &webui_protocol::WebUIFragmentComponent,
2665        context: &mut WebUIProcessContext,
2666    ) -> Result<()> {
2667        if context.css_strategy != webui_protocol::CssStrategy::Module {
2668            return Ok(());
2669        }
2670        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2671            streaming_template_already_sent(
2672                streaming,
2673                context.component_index,
2674                &component.fragment_id,
2675            )
2676        });
2677        if !metadata_already_streamed
2678            && !context.rendered_components.contains(&component.fragment_id)
2679        {
2680            if let Some(css) = context
2681                .protocol
2682                .components
2683                .get(&component.fragment_id)
2684                .map(|c| c.css.as_str())
2685                .filter(|s| !s.is_empty())
2686            {
2687                self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2688            }
2689        }
2690        Ok(())
2691    }
2692
2693    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2694    fn process_route(
2695        &self,
2696        route_frag: &webui_protocol::WebUiFragmentRoute,
2697        best_route: &Option<(String, route_matcher::RouteMatch)>,
2698        context: &mut WebUIProcessContext,
2699    ) -> Result<()> {
2700        let is_matched = best_route
2701            .as_ref()
2702            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2703
2704        context.writer.write("<webui-route")?;
2705        context.writer.write(" path=\"")?;
2706        context.writer.write(&route_frag.path)?;
2707        context.writer.write("\"")?;
2708        if !route_frag.fragment_id.is_empty() {
2709            context.writer.write(" component=\"")?;
2710            context.writer.write(&route_frag.fragment_id)?;
2711            context.writer.write("\"")?;
2712        }
2713        if route_frag.exact {
2714            context.writer.write(" exact")?;
2715        }
2716        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2717
2718        if is_matched {
2719            // Emit data-ri for O(1) client-side element binding
2720            let ri = context.route_chain_index;
2721            context.route_chain_index += 1;
2722            context.writer.write(" data-ri=\"")?;
2723            write_usize(context.writer, ri)?;
2724            context.writer.write("\" active>")?;
2725
2726            let saved_route_base = context.route_base.clone();
2727            let saved_route_children = std::mem::take(&mut context.route_children);
2728            if let Some((_, ref rm)) = best_route {
2729                context.route_base = Cow::Owned(route_matcher::compute_route_base(
2730                    context.request_path,
2731                    rm.consumed_segments,
2732                ));
2733            }
2734            context.route_children = route_frag.children.clone();
2735
2736            if !route_frag.content_fragment_id.is_empty() {
2737                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2738            }
2739
2740            if !route_frag.fragment_id.is_empty() {
2741                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2742                    self.emit_component_style_closure(
2743                        &route_frag.fragment_id,
2744                        StyleClosureInstall::Routed,
2745                        context,
2746                    )?;
2747                }
2748                let comp = webui_protocol::WebUIFragmentComponent {
2749                    fragment_id: route_frag.fragment_id.clone(),
2750                };
2751
2752                context.writer.write("<")?;
2753                context.writer.write(&route_frag.fragment_id)?;
2754                if let Some(p) = &context.plugin {
2755                    p.write_route_component_state(context.state, context.writer)?;
2756                }
2757                write_interaction_marker(&route_frag.fragment_id, context)?;
2758                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2759                context.writer.write(">")?;
2760
2761                self.process_component(
2762                    &comp,
2763                    None,
2764                    ComponentHostOrigin::HandlerGenerated,
2765                    context,
2766                )?;
2767
2768                context.writer.write("</")?;
2769                context.writer.write(&route_frag.fragment_id)?;
2770                context.writer.write(">")?;
2771            }
2772            context.route_base = saved_route_base;
2773            context.route_children = saved_route_children;
2774        } else {
2775            context.writer.write(" style=\"display:none\">")?;
2776        }
2777
2778        context.writer.write("</webui-route>")?;
2779        Ok(())
2780    }
2781
2782    /// Process a component fragment.
2783    fn process_component(
2784        &self,
2785        component: &webui_protocol::WebUIFragmentComponent,
2786        target: Option<usize>,
2787        origin: ComponentHostOrigin,
2788        context: &mut WebUIProcessContext,
2789    ) -> Result<()> {
2790        if context.streaming.is_some() {
2791            consume_streaming_component_root(&component.fragment_id, origin, context)?;
2792            // Capture only after root parity succeeds, so malformed protocols
2793            // cannot contribute unmarked hosts to a checkpoint.
2794            record_checkpoint_tag(context, &component.fragment_id);
2795        }
2796
2797        // Emit the component's CSS module importmap into its light DOM and track
2798        // the component as rendered on first encounter only. `rendered_components`
2799        // is a set, so gating the `insert` (and its `String` clone) behind the
2800        // first-encounter check avoids allocating a throwaway `String` for every
2801        // duplicate instance while keeping the set contents identical.
2802        if !context.rendered_components.contains(&component.fragment_id) {
2803            self.emit_css_module(component, context)?;
2804            context
2805                .rendered_components
2806                .insert(component.fragment_id.clone());
2807        }
2808
2809        let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2810        if owns_css_tree {
2811            Self::push_shadow_style_root(&component.fragment_id, context)?;
2812        }
2813
2814        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
2815        let saved_local_vars = std::mem::take(&mut context.local_vars);
2816        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2817        // The component's accumulated attrs become its local vars; the next
2818        // sibling accumulates into a recycled (capacity-preserving) map from the
2819        // request-local pool instead of a freshly allocated `HashMap`.
2820        let saved_component_attrs = std::mem::replace(
2821            &mut context.component_attrs,
2822            take_scope_map(&mut context.scope_pool),
2823        );
2824        let saved_component_borrowed_attrs = std::mem::replace(
2825            &mut context.component_borrowed_attrs,
2826            take_borrowed_scope(&mut context.borrowed_scope_pool),
2827        );
2828        context.local_vars = saved_component_attrs;
2829        context.local_borrowed_vars = saved_component_borrowed_attrs;
2830        context.collecting_component_attrs = false;
2831        let saved_loop_scope = context.visible_loop_scope;
2832        context.visible_loop_scope = VisibleLoopScope {
2833            start: context.loop_vars.len(),
2834            end: context.loop_vars.len(),
2835        };
2836
2837        if let Some(p) = &mut context.plugin {
2838            p.push_scope();
2839        }
2840
2841        let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2842        context.visible_loop_scope = saved_loop_scope;
2843
2844        if owns_css_tree {
2845            Self::pop_shadow_style_root(&component.fragment_id, context)?;
2846        }
2847        render_result?;
2848
2849        if let Some(p) = &mut context.plugin {
2850            p.pop_scope();
2851        }
2852
2853        // Restore parent scope, recycling this component's local map (its
2854        // accumulated attrs) back into the pool so a sibling reuses its capacity.
2855        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2856        recycle_scope_map(&mut context.scope_pool, used_locals);
2857        let used_borrowed_locals =
2858            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2859        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2860        // The attr accumulator (pulled from the pool above) is cleared for the
2861        // next sibling while retaining its bucket capacity.
2862        context.component_attrs.clear();
2863        context.component_borrowed_attrs.clear();
2864
2865        Ok(())
2866    }
2867
2868    #[inline]
2869    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2870        !protocol.style_closures.is_empty()
2871            && protocol
2872                .components
2873                .get(component)
2874                .is_some_and(|data| data.uses_shadow_dom)
2875    }
2876
2877    pub(crate) fn push_shadow_style_root(
2878        component: &str,
2879        context: &mut WebUIProcessContext,
2880    ) -> Result<()> {
2881        if !context.protocol.style_closures.contains_key(component) {
2882            return Err(HandlerError::Invariant(format!(
2883                "component style closure metadata is missing Shadow root `{component}`"
2884            )));
2885        }
2886        let root_index = context
2887            .component_index
2888            .get(component)
2889            .copied()
2890            .ok_or_else(|| {
2891                HandlerError::Invariant(format!(
2892                    "Shadow component `{component}` is missing its protocol index"
2893                ))
2894            })?;
2895        context.shadow_style_roots.push(ShadowStyleRoot {
2896            component_index: root_index,
2897            static_closure_emitted: false,
2898            routed_resources: Vec::new(),
2899        });
2900        Ok(())
2901    }
2902
2903    pub(crate) fn pop_shadow_style_root(
2904        component: &str,
2905        context: &mut WebUIProcessContext,
2906    ) -> Result<()> {
2907        context.shadow_style_roots.pop().ok_or_else(|| {
2908            HandlerError::Invariant(format!(
2909                "Shadow component `{component}` lost its active style root"
2910            ))
2911        })?;
2912        Ok(())
2913    }
2914
2915    /// Resolve a dotted path into owned state for values retained by the context.
2916    fn resolve_value_owned(
2917        &self,
2918        path: &str,
2919        context: &WebUIProcessContext<'_, '_, '_>,
2920    ) -> Option<Value> {
2921        resolve_value_from_sources(
2922            path,
2923            &context.loop_vars,
2924            context.visible_loop_scope,
2925            LocalValueSources {
2926                owned: &context.local_vars,
2927                borrowed: &context.local_borrowed_vars,
2928            },
2929            context.state,
2930        )
2931        .map(Cow::into_owned)
2932    }
2933
2934    /// Evaluate a condition expression against the current context.
2935    ///
2936    /// Uses a resolver closure that checks local variables first, then falls
2937    /// back to global state — avoiding a full clone of the state tree.
2938    /// Missing identifier operands are falsy before logical operators are applied.
2939    /// Missing predicate values make the complete condition false.
2940    fn evaluate_condition(
2941        &self,
2942        condition: &webui_protocol::ConditionExpr,
2943        context: &WebUIProcessContext,
2944    ) -> Result<bool> {
2945        let loop_vars = &context.loop_vars;
2946        let visible_loop_scope = context.visible_loop_scope;
2947        let local_values = LocalValueSources {
2948            owned: &context.local_vars,
2949            borrowed: &context.local_borrowed_vars,
2950        };
2951        let state = context.state;
2952        match evaluate_with_resolver(condition, |path| {
2953            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
2954        }) {
2955            Ok(result) => Ok(result),
2956            Err(ExpressionError::MissingValue(_)) => Ok(false),
2957            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
2958        }
2959    }
2960
2961    /// Process a for loop fragment.
2962    ///
2963    /// Creates a new context for each iteration that includes the current loop item.
2964    /// This allows nested templates to access both the loop variable and any parent context.
2965    /// Example: `for item in items` makes "item" available in the loop body.
2966    fn process_for_loop<'protocol, 'state>(
2967        &self,
2968        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2969        target: Option<usize>,
2970        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2971    ) -> Result<()> {
2972        if let Some(items) = resolve_borrowed_collection(
2973            &for_loop.collection,
2974            &context.loop_vars,
2975            context.visible_loop_scope,
2976            LocalValueSources {
2977                owned: &context.local_vars,
2978                borrowed: &context.local_borrowed_vars,
2979            },
2980            context.state,
2981        ) {
2982            return self.process_borrowed_for_loop(for_loop, target, items, context);
2983        }
2984        self.process_owned_for_loop(for_loop, target, context)
2985    }
2986
2987    fn process_borrowed_for_loop<'protocol, 'state>(
2988        &self,
2989        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2990        target: Option<usize>,
2991        items: &'state [Value],
2992        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2993    ) -> Result<()> {
2994        if let Some(plugin) = &mut context.plugin {
2995            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
2996        }
2997
2998        let item_name = for_loop.item.as_str();
2999        let saved_value = context.local_vars.remove(item_name);
3000        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3001        let saved_scope = context.visible_loop_scope;
3002        for (index, item) in items.iter().enumerate() {
3003            if let Some(plugin) = &mut context.plugin {
3004                plugin.on_repeat_item_start(index, context.writer)?;
3005                plugin.push_scope();
3006            }
3007
3008            context.loop_vars.push(LoopBinding {
3009                name: item_name,
3010                value: item,
3011            });
3012            context.visible_loop_scope.end = context.loop_vars.len();
3013            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3014            context.loop_vars.pop();
3015            context.visible_loop_scope = saved_scope;
3016
3017            if let Some(plugin) = &mut context.plugin {
3018                plugin.pop_scope();
3019                plugin.on_repeat_item_end(index, context.writer)?;
3020            }
3021        }
3022        if let Some(value) = saved_value {
3023            context.local_vars.insert(item_name.to_string(), value);
3024        }
3025        if let Some(value) = saved_borrowed_value {
3026            context.local_borrowed_vars.insert(item_name, value);
3027        }
3028
3029        if let Some(plugin) = &mut context.plugin {
3030            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3031        }
3032        Ok(())
3033    }
3034
3035    fn process_owned_for_loop<'protocol, 'state>(
3036        &self,
3037        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3038        target: Option<usize>,
3039        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3040    ) -> Result<()> {
3041        let collection_name = &for_loop.collection;
3042
3043        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3044        // Hydration comments are always emitted regardless of collection presence.
3045        let items = match self.resolve_value_owned(collection_name, context) {
3046            Some(Value::Array(arr)) => arr,
3047            Some(_) => {
3048                return Err(HandlerError::TypeError(format!(
3049                    "Collection '{}' is not an array",
3050                    collection_name
3051                )))
3052            }
3053            None => Vec::new(),
3054        };
3055
3056        if let Some(p) = &mut context.plugin {
3057            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3058        }
3059
3060        // Hot-loop optimisation: the loop variable name is `String`-keyed
3061        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3062        // the key) on every iteration — a 1000-item loop pays 2000 String
3063        // clones for the key alone. Instead, we save the outer-scope
3064        // value (if any) ONCE before the loop, install the key ONCE with
3065        // an empty placeholder, then overwrite the value in-place each
3066        // iteration via `get_mut`. Restoration at the end happens once.
3067        let item_name = for_loop.item.as_str();
3068        let saved_value = context.local_vars.remove(item_name);
3069        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3070        // Pre-insert the key so per-iteration `get_mut` is infallible.
3071        // Cost: at most one `String::from(item_name)` for the lifetime
3072        // of the loop, regardless of iteration count.
3073        if !items.is_empty() {
3074            context
3075                .local_vars
3076                .insert(item_name.to_string(), Value::Null);
3077        }
3078        for (i, item) in items.into_iter().enumerate() {
3079            if let Some(p) = &mut context.plugin {
3080                p.on_repeat_item_start(i, context.writer)?;
3081                p.push_scope();
3082            }
3083
3084            // O(1) value swap; no key allocation.
3085            if let Some(slot) = context.local_vars.get_mut(item_name) {
3086                *slot = item;
3087            }
3088            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3089
3090            if let Some(p) = &mut context.plugin {
3091                p.pop_scope();
3092                p.on_repeat_item_end(i, context.writer)?;
3093            }
3094        }
3095        // Restore outer scope (or remove the placeholder we installed).
3096        match saved_value {
3097            Some(value) => {
3098                context.local_vars.insert(item_name.to_string(), value);
3099            }
3100            None => {
3101                context.local_vars.remove(item_name);
3102            }
3103        }
3104        if let Some(value) = saved_borrowed_value {
3105            context.local_borrowed_vars.insert(item_name, value);
3106        }
3107
3108        if let Some(p) = &mut context.plugin {
3109            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3110        }
3111
3112        Ok(())
3113    }
3114
3115    /// Process a signal fragment.
3116    ///
3117    /// Looks up the value in the context first (for local variables), then in the global state.
3118    /// This prioritization allows local variables (like loop items) to override global state.
3119    /// If the value is not found in either scope, an empty string is returned.
3120    #[inline]
3121    fn process_signal<'data>(
3122        &self,
3123        signal: &'data webui_protocol::WebUIFragmentSignal,
3124        context: &mut WebUIProcessContext<'data, '_, '_>,
3125    ) -> Result<()> {
3126        if signal.raw {
3127            self.process_raw_signal(signal, context)
3128        } else {
3129            self.process_state_signal(signal, false, context)
3130        }
3131    }
3132
3133    #[inline(never)]
3134    fn process_raw_signal<'data>(
3135        &self,
3136        signal: &'data webui_protocol::WebUIFragmentSignal,
3137        context: &mut WebUIProcessContext<'data, '_, '_>,
3138    ) -> Result<()> {
3139        let Some(structural_value) = structural_signal_value(signal) else {
3140            return self.process_state_signal(signal, !signal.raw_text_context, context);
3141        };
3142
3143        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3144            return self.process_shadow_style_signal(root, context);
3145        }
3146
3147        if context.streaming.is_some()
3148            && self.process_streaming_signal(structural_value, context)?
3149        {
3150            return Ok(());
3151        }
3152
3153        // Hook: emit nonce meta and Document-owned CSS before </head>.
3154        // Guarded by `head_end_emitted` so a malformed protocol cannot
3155        // emit nonce/preloads/inject more than once per render.
3156        if structural_value == "head_end" && !context.head_end_emitted {
3157            context.head_end_emitted = true;
3158            if let Some(nonce) = context.nonce {
3159                context
3160                    .writer
3161                    .write("<meta name=\"webui-nonce\" content=\"")?;
3162                context
3163                    .writer
3164                    .write(&crate::html_encode::encode_safe(nonce))?;
3165                context.writer.write("\">")?;
3166            }
3167
3168            // Render-policy CSS is emitted before component styles so an
3169            // authored declaration still wins on a tie.
3170            if !context.protocol.component_render_css.is_empty() {
3171                context.writer.write("<style data-webui-render-policy")?;
3172                if let Some(nonce) = context.nonce {
3173                    context.writer.write(" nonce=\"")?;
3174                    context
3175                        .writer
3176                        .write(&crate::html_encode::encode_safe(nonce))?;
3177                    context.writer.write("\"")?;
3178                }
3179                context.writer.write(">")?;
3180                context
3181                    .writer
3182                    .write(&context.protocol.component_render_css)?;
3183                context.writer.write("</style>")?;
3184            }
3185
3186            if !context.protocol.style_closures.is_empty() {
3187                let entry_id = context.entry_id;
3188                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3189                let mut preloaded = Vec::new();
3190                self.emit_active_route_styles(&mut preloaded, context)?;
3191                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3192            }
3193
3194            // Compiler-resolved `modulepreload` hints for the shared chunks
3195            // the page's module entries statically import. Those chunks are
3196            // named only inside the entry's own bytes, so without this the
3197            // browser must download and parse the entry before it can even
3198            // discover them.
3199            //
3200            // Emitted after the CSS links on purpose: CSS is render-blocking
3201            // and owns first paint, while these own first interaction, so the
3202            // stylesheet requests go out first. The list arrives pre-ordered
3203            // (largest chunk first) and pre-resolved from the build, so this
3204            // is a straight write with no per-request work.
3205            if !context.protocol.module_preloads.is_empty() {
3206                for href in &context.protocol.module_preloads {
3207                    context
3208                        .writer
3209                        .write("<link rel=\"modulepreload\" href=\"")?;
3210                    context.writer.write(href)?;
3211                    context.writer.write("\">")?;
3212                }
3213            }
3214
3215            write_component_asset_styles(context)?;
3216
3217            // Per-render `head_inject` HTML — image preloads, A/B test
3218            // markers, etc. supplied by the host via RenderOptions.
3219            // Emitted at the structural head_end boundary, after the
3220            // built-in nonce + CSS-link emissions, so host injects
3221            // appear immediately before `</head>`.
3222            if let Some(html) = context.head_inject {
3223                context.writer.write(html)?;
3224            }
3225
3226            // Reserved-state `headEnd` HTML, last at this boundary so a
3227            // host that sets both channels gets a deterministic order:
3228            // built-in emissions, then `RenderOptions`, then state.
3229            if let Some(html) = context.state_inject.head_end {
3230                context.writer.write(html)?;
3231            }
3232        }
3233
3234        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3235        // by its own dedup flag so a malformed protocol cannot duplicate it.
3236        if structural_value == "body_start" && !context.body_start_emitted {
3237            context.body_start_emitted = true;
3238            write_component_asset_styles(context)?;
3239            if let Some(html) = context.state_inject.body_start {
3240                context.writer.write(html)?;
3241            }
3242        }
3243
3244        // Hook: emit component templates and host body_inject before </body>.
3245        // Single guarded block so the dedup flag protects both the
3246        // hydration emission and the host inject from a malformed
3247        // protocol that fires `body_end` more than once per render.
3248        if structural_value == "body_end" && !context.body_end_emitted {
3249            context.body_end_emitted = true;
3250            if context.plugin.is_some() {
3251                // Emit templates for all REACHABLE components on the current route,
3252                // not just those rendered in this SSR pass. Components inside false
3253                // <if> blocks or empty <for> loops are reachable via client-side
3254                // state changes and need their templates available without a server
3255                // round-trip. The graph walker follows conditional and loop branches
3256                // unconditionally, but only descends into the matched route chain —
3257                // components on other routes are delivered via SPA partial navigation.
3258                let reachable = context
3259                    .reachable_components
3260                    .take()
3261                    .unwrap_or_else(|| {
3262                        crate::route_handler::collect_reachable_component_order_for_request(
3263                            context.protocol,
3264                            context.entry_id,
3265                            context.request_path,
3266                            context.route_index,
3267                        )
3268                    })
3269                    .into_iter()
3270                    .collect::<HashSet<_>>();
3271                let state_selection =
3272                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3273
3274                // Emit CSS module importmaps for reachable-but-unrendered
3275                // components so the framework can adopt them when an `<if>`
3276                // condition flips true client-side.
3277                if context.css_strategy == webui_protocol::CssStrategy::Module {
3278                    for name in &reachable {
3279                        if !context.rendered_components.contains(name) {
3280                            if let Some(css) = context
3281                                .protocol
3282                                .components
3283                                .get(name)
3284                                .map(|c| c.css.as_str())
3285                                .filter(|s| !s.is_empty())
3286                            {
3287                                self.emit_css_module_importmap(name, css, context)?;
3288                            }
3289                        }
3290                    }
3291                }
3292
3293                // Try to collect split WebUI template payloads. If the plugin
3294                // returns None (non-WebUI templates, e.g. FAST), fall back to
3295                // separate emission.
3296                let template_payloads = context
3297                    .plugin
3298                    .as_ref()
3299                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3300
3301                if template_payloads.is_none() {
3302                    // Non-JS templates (FAST plugins) - emit separately
3303                    if let Some(ref p) = context.plugin {
3304                        p.emit_templates(
3305                            context.protocol,
3306                            &reachable,
3307                            context.nonce,
3308                            context.writer,
3309                        )?;
3310                    }
3311                }
3312
3313                // Compute the inventory hex from actually rendered components.
3314                let inventory_hex = crate::route_handler::encode_component_inventory(
3315                    &context.rendered_components,
3316                    context.component_index,
3317                );
3318
3319                // Chain
3320                Self::ensure_request_route_chain(context);
3321                let chain = context.route_chain.as_deref().ok_or_else(|| {
3322                    HandlerError::Invariant(
3323                        "request route chain disappeared after collection".to_string(),
3324                    )
3325                })?;
3326                let chain_json: Vec<Value> = chain
3327                    .iter()
3328                    .map(crate::route_handler::RouteChainEntry::to_json)
3329                    .collect();
3330
3331                // CSS hrefs emitted during SSR (Link-strategy components)
3332                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3333                let mut css_hrefs: Vec<&str> = Vec::new();
3334                if is_link {
3335                    for name in &reachable {
3336                        if let Some(href) = context
3337                            .protocol
3338                            .components
3339                            .get(name)
3340                            .map(|c| c.css_href.as_str())
3341                            .filter(|h| !h.is_empty())
3342                        {
3343                            css_hrefs.push(href);
3344                        }
3345                    }
3346                }
3347
3348                // Module style specifiers emitted during SSR
3349                let mut style_specs: Vec<&str> = Vec::new();
3350                if context.css_strategy == webui_protocol::CssStrategy::Module {
3351                    for name in &reachable {
3352                        if context
3353                            .protocol
3354                            .components
3355                            .get(name)
3356                            .map(|c| !c.css.is_empty())
3357                            .unwrap_or(false)
3358                        {
3359                            style_specs.push(name);
3360                        }
3361                    }
3362                }
3363
3364                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3365                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3366                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3367                style_roots.push(context.entry_id);
3368                for entry in chain {
3369                    style_roots.push(entry.component.as_str());
3370                    if !entry.pending_component.is_empty() {
3371                        style_roots.push(entry.pending_component.as_str());
3372                    }
3373                    if !entry.error_component.is_empty() {
3374                        style_roots.push(entry.error_component.as_str());
3375                    }
3376                }
3377                style_roots.extend(reachable.iter().map(String::as_str));
3378                let component_styles =
3379                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3380                write_webui_data_block(
3381                    context.writer,
3382                    &mut context.json_scratch,
3383                    WebUiBootstrap {
3384                        declaration_id: None,
3385                        enclosing_span_instance_id: None,
3386                        state: context.state,
3387                        state_selection,
3388                        chain: &chain_json,
3389                        inventory: &inventory_hex,
3390                        nonce: context.nonce,
3391                        css_hrefs: &css_hrefs,
3392                        style_specs: &style_specs,
3393                        component_styles: &component_styles,
3394                        templates: payloads,
3395                    },
3396                )?;
3397
3398                // Let the active plugin emit any framework-specific executable
3399                // side channel. FAST plugins default to no-op; WebUI installs
3400                // templateFns. Client packages parse #webui-data lazily.
3401                if let Some(ref plugin) = context.plugin {
3402                    plugin.emit_bootstrap_extension(
3403                        BootstrapExtensionContext {
3404                            protocol: context.protocol,
3405                            components: &reachable,
3406                            payloads,
3407                            nonce: context.nonce,
3408                        },
3409                        context.writer,
3410                    )?;
3411                }
3412            }
3413
3414            // Per-render `body_inject` HTML — dev livereload script,
3415            // analytics, etc. supplied by the host via RenderOptions.
3416            // Inside the dedup block but outside the plugin-only
3417            // sub-block above, so it fires regardless of whether a
3418            // hydration plugin is active. Appears immediately before
3419            // `</body>`.
3420            if let Some(html) = context.body_inject {
3421                context.writer.write(html)?;
3422            }
3423
3424            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3425            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3426            if let Some(html) = context.state_inject.body_end {
3427                context.writer.write(html)?;
3428            }
3429        }
3430
3431        // Structural signals are never state lookups. In particular, ordinary
3432        // rendering ignores boundary/root markers byte-for-byte, while authored
3433        // raw bindings with the same visible key remain ordinary state.
3434        Ok(())
3435    }
3436
3437    /// Process a plain (non-structural) signal by resolving its value and
3438    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3439    ///
3440    /// `owns_html_range` only controls whether the active plugin emits
3441    /// replaceable sibling markers around the value (`on_binding_start/end`);
3442    /// it never affects escaping. In particular, `signal.raw_text_context`
3443    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3444    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3445    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3446    /// escaped binding inside an HTML raw-text element is an authoring
3447    /// footgun rather than something this function can safely correct.
3448    #[inline]
3449    fn process_state_signal(
3450        &self,
3451        signal: &webui_protocol::WebUIFragmentSignal,
3452        owns_html_range: bool,
3453        context: &mut WebUIProcessContext,
3454    ) -> Result<()> {
3455        if let Some(p) = &mut context.plugin {
3456            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3457        }
3458
3459        if let Some(value) = resolve_value_from_sources(
3460            &signal.value,
3461            &context.loop_vars,
3462            context.visible_loop_scope,
3463            LocalValueSources {
3464                owned: &context.local_vars,
3465                borrowed: &context.local_borrowed_vars,
3466            },
3467            context.state,
3468        ) {
3469            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3470        }
3471
3472        if let Some(p) = &mut context.plugin {
3473            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3474        }
3475        Ok(())
3476    }
3477
3478    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3479    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3480    ///
3481    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3482    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3483    /// context: it does not know whether it is writing into a raw-text
3484    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3485    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3486    fn write_signal_value(
3487        &self,
3488        value: &Value,
3489        raw: bool,
3490        writer: &mut dyn ResponseWriter,
3491    ) -> Result<()> {
3492        if raw {
3493            match value {
3494                Value::String(s) => writer.write(s),
3495                _ => writer.write(&value.to_string()),
3496            }
3497        } else {
3498            match value {
3499                Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3500                _ => {
3501                    let s = value.to_string();
3502                    writer.write(&crate::html_encode::encode_safe(&s))
3503                }
3504            }
3505        }
3506    }
3507
3508    /// Process an if condition fragment.
3509    fn process_if(
3510        &self,
3511        if_cond: &webui_protocol::WebUIFragmentIf,
3512        target: Option<usize>,
3513        context: &mut WebUIProcessContext,
3514    ) -> Result<()> {
3515        let condition = if_cond
3516            .condition
3517            .as_ref()
3518            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3519        let condition_met = self.evaluate_condition(condition, context)?;
3520
3521        if let Some(p) = &mut context.plugin {
3522            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3523        }
3524
3525        if condition_met {
3526            if let Some(p) = &mut context.plugin {
3527                p.push_scope();
3528            }
3529
3530            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3531
3532            if let Some(p) = &mut context.plugin {
3533                p.pop_scope();
3534            }
3535        }
3536
3537        if let Some(p) = &mut context.plugin {
3538            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3539        }
3540
3541        Ok(())
3542    }
3543
3544    /// Process an attribute fragment by rendering the attribute name/value pair.
3545    ///
3546    /// `template_target` and `component_name` are prepared once per protocol by
3547    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3548    /// camelCase prop-name conversion is repeated per render.
3549    fn process_attribute<'protocol, 'state>(
3550        &self,
3551        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3552        template_target: Option<usize>,
3553        component_name: Option<&'protocol str>,
3554        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3555    ) -> Result<()> {
3556        // Initialize component attribute accumulator on attrStart. Clearing the
3557        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3558        if attr.attr_start {
3559            context.component_attrs.clear();
3560            context.component_borrowed_attrs.clear();
3561            context.collecting_component_attrs = true;
3562        }
3563
3564        // Boolean attribute with condition tree
3565        if let Some(condition) = &attr.condition_tree {
3566            let condition_met = self.evaluate_condition(condition, context)?;
3567
3568            if context.collecting_component_attrs && !attr.attr_skip {
3569                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3570                context.component_borrowed_attrs.remove(name);
3571                context
3572                    .component_attrs
3573                    .insert(name.to_owned(), Value::Bool(condition_met));
3574            }
3575
3576            if condition_met {
3577                context.writer.write(" ")?;
3578                context.writer.write(&attr.name)?;
3579            }
3580            return Ok(());
3581        }
3582
3583        // Template attribute (mixed static + dynamic)
3584        if !attr.template.is_empty() {
3585            let raw_value =
3586                self.render_template_attr_value(&attr.template, template_target, context)?;
3587            let escaped = crate::html_encode::encode_safe(&raw_value);
3588            write_attr(context.writer, &attr.name, &escaped)?;
3589
3590            if context.collecting_component_attrs && !attr.attr_skip {
3591                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3592                context.component_borrowed_attrs.remove(name);
3593                context
3594                    .component_attrs
3595                    .insert(name.to_owned(), Value::String(raw_value));
3596            }
3597            return Ok(());
3598        }
3599
3600        // Simple attribute
3601        if !attr.value.is_empty() {
3602            if attr.raw_value {
3603                // Static attribute — value is the literal string
3604                write_attr(context.writer, &attr.name, &attr.value)?;
3605                if context.collecting_component_attrs && !attr.attr_skip {
3606                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3607                    context.component_borrowed_attrs.remove(name);
3608                    context
3609                        .component_attrs
3610                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3611                }
3612            } else if attr.complex {
3613                // Complex attribute — resolve value, don't render to HTML, store as state
3614                if context.collecting_component_attrs && !attr.attr_skip {
3615                    let state_backed_value = if context.streaming.is_none() {
3616                        resolve_state_backed_value(
3617                            &attr.value,
3618                            &context.loop_vars,
3619                            context.visible_loop_scope,
3620                            LocalValueSources {
3621                                owned: &context.local_vars,
3622                                borrowed: &context.local_borrowed_vars,
3623                            },
3624                            context.state,
3625                        )
3626                    } else {
3627                        None
3628                    };
3629                    if let Some(value) = state_backed_value {
3630                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3631                        context.component_attrs.remove(name);
3632                        context.component_borrowed_attrs.insert(name, value);
3633                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3634                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3635                        context.component_borrowed_attrs.remove(name);
3636                        context.component_attrs.insert(name.to_owned(), value);
3637                    }
3638                }
3639            } else {
3640                // Dynamic attribute — resolve and render
3641                // Streaming continuations must own component locals across host
3642                // calls, so borrowing there would add a second lookup before the
3643                // VM materializes the same value.
3644                let state_backed_value = if context.collecting_component_attrs
3645                    && !attr.attr_skip
3646                    && context.streaming.is_none()
3647                {
3648                    resolve_state_backed_value(
3649                        &attr.value,
3650                        &context.loop_vars,
3651                        context.visible_loop_scope,
3652                        LocalValueSources {
3653                            owned: &context.local_vars,
3654                            borrowed: &context.local_borrowed_vars,
3655                        },
3656                        context.state,
3657                    )
3658                } else {
3659                    None
3660                };
3661                let value = resolve_value_from_sources(
3662                    &attr.value,
3663                    &context.loop_vars,
3664                    context.visible_loop_scope,
3665                    LocalValueSources {
3666                        owned: &context.local_vars,
3667                        borrowed: &context.local_borrowed_vars,
3668                    },
3669                    context.state,
3670                );
3671                // Always emit the attribute so FAST hydration markers
3672                // (`data-fe`) match the DOM node structure.
3673                match value.as_deref() {
3674                    Some(Value::String(s)) => {
3675                        write_attr(
3676                            context.writer,
3677                            &attr.name,
3678                            &crate::html_encode::encode_safe(s),
3679                        )?;
3680                    }
3681                    Some(Value::Null) | None => {
3682                        write_attr(context.writer, &attr.name, "")?;
3683                    }
3684                    Some(other) => {
3685                        let s = other.to_string();
3686                        write_attr(
3687                            context.writer,
3688                            &attr.name,
3689                            &crate::html_encode::encode_safe(&s),
3690                        )?;
3691                    }
3692                }
3693
3694                if context.collecting_component_attrs && !attr.attr_skip {
3695                    if let Some(borrowed) = state_backed_value {
3696                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3697                        context.component_attrs.remove(name);
3698                        context.component_borrowed_attrs.insert(name, borrowed);
3699                    } else {
3700                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3701                        context.component_borrowed_attrs.remove(name);
3702                        context.component_attrs.insert(
3703                            name.to_owned(),
3704                            value
3705                                .map(Cow::into_owned)
3706                                .unwrap_or(Value::String(String::new())),
3707                        );
3708                    }
3709                }
3710            }
3711        }
3712
3713        Ok(())
3714    }
3715
3716    /// Render a template attribute's fragments into a raw (unescaped) string.
3717    fn render_template_attr_value(
3718        &self,
3719        template_id: &str,
3720        target: Option<usize>,
3721        context: &WebUIProcessContext,
3722    ) -> Result<String> {
3723        let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3724            return Err(HandlerError::MissingFragment(template_id.to_string()));
3725        };
3726        let fragments = context
3727            .render_fragments
3728            .list(index)
3729            .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3730        let mut raw_value = String::new();
3731        for frag in fragments.fragments {
3732            match frag.fragment.as_ref() {
3733                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3734                Some(Fragment::Signal(signal)) => {
3735                    if let Some(value) = resolve_value_from_sources(
3736                        &signal.value,
3737                        &context.loop_vars,
3738                        context.visible_loop_scope,
3739                        LocalValueSources {
3740                            owned: &context.local_vars,
3741                            borrowed: &context.local_borrowed_vars,
3742                        },
3743                        context.state,
3744                    ) {
3745                        match value.as_ref() {
3746                            Value::String(s) => raw_value.push_str(s),
3747                            _ => raw_value.push_str(&value.to_string()),
3748                        }
3749                    }
3750                }
3751                _ => {}
3752            }
3753        }
3754        Ok(raw_value)
3755    }
3756
3757    /// Render the UI based on the protocol and state.
3758    pub fn render<'a>(
3759        &self,
3760        protocol: &'a Protocol,
3761        state: &'a Value,
3762        options: &RenderOptions<'a>,
3763        writer: &'a mut dyn ResponseWriter,
3764    ) -> Result<()> {
3765        protocol.ensure_style_metadata()?;
3766        let document = protocol.protocol();
3767        let render_fragments = protocol.render_fragments().resolve(document);
3768        let Some(entry_index) = render_fragments.index(options.entry_id) else {
3769            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3770        };
3771        let entry = render_fragments
3772            .list(entry_index)
3773            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3774        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3775        let has_document_head_boundary = !entry_owns_css_tree
3776            && !document.style_closures.is_empty()
3777            && entry.fragments.iter().any(|fragment| {
3778                matches!(
3779                    fragment.fragment.as_ref(),
3780                    Some(Fragment::Signal(signal))
3781                        if structural_signal_value(signal) == Some("head_end")
3782                )
3783            });
3784        let doctype_split = (!entry_owns_css_tree
3785            && !has_document_head_boundary
3786            && !document.style_closures.is_empty())
3787        .then(|| {
3788            let Some(Fragment::Raw(raw)) = entry
3789                .fragments
3790                .first()
3791                .and_then(|fragment| fragment.fragment.as_ref())
3792            else {
3793                return None;
3794            };
3795            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3796        })
3797        .flatten();
3798        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3799        let mut context = WebUIProcessContext {
3800            protocol: document,
3801            render_fragments,
3802            component_asset_style_manifest,
3803            component_asset_style_links: protocol.component_asset_style_links(),
3804            state,
3805            writer,
3806            local_vars: HashMap::new(),
3807            local_borrowed_vars: BorrowedScope::default(),
3808            loop_vars: Vec::new(),
3809            visible_loop_scope: VisibleLoopScope::EMPTY,
3810            component_attrs: HashMap::new(),
3811            component_borrowed_attrs: BorrowedScope::default(),
3812            collecting_component_attrs: false,
3813            request_path: options.request_path,
3814            route_base: Cow::Borrowed("/"),
3815            rendered_components: HashSet::new(),
3816            plugin: self.plugin_factory.map(|f| f()),
3817            route_children: Vec::new(),
3818            entry_id: options.entry_id,
3819            // Same defensive normalisation as `handle()`. See the
3820            // doc-comment there for the CSP-outage rationale.
3821            nonce: options.nonce.filter(|s| !s.is_empty()),
3822            head_inject: options.head_inject.filter(|s| !s.is_empty()),
3823            body_inject: options.body_inject.filter(|s| !s.is_empty()),
3824            state_inject: StateInject::resolve(state),
3825            head_end_emitted: false,
3826            body_start_emitted: false,
3827            component_asset_styles_emitted: false,
3828            component_index: protocol.component_index(),
3829            style_resource_index: protocol.style_resource_index(),
3830            style_chunk_index: protocol.protocol().style_chunk_index(),
3831            css_strategy: protocol.css_strategy(),
3832            body_end_emitted: false,
3833            route_index: protocol.route_index(),
3834            route_chain_index: 0,
3835            route_chain: None,
3836            route_document_style_targets: Vec::new(),
3837            reachable_components: None,
3838            streaming: None,
3839            json_scratch: Vec::new(),
3840            scope_pool: Vec::new(),
3841            document_style_resources: HashSet::new(),
3842            shadow_style_roots: Vec::new(),
3843            borrowed_scope_pool: Vec::new(),
3844        };
3845
3846        if entry_owns_css_tree {
3847            Self::push_shadow_style_root(options.entry_id, &mut context)?;
3848        }
3849
3850        let render_result = if let Some((first_raw, split)) = doctype_split {
3851            context.writer.write(&first_raw[..split])?;
3852            self.emit_component_style_closure(
3853                options.entry_id,
3854                StyleClosureInstall::Static,
3855                &mut context,
3856            )?;
3857            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3858            context.writer.write(&first_raw[split..])?;
3859            self.process_fragment_from(entry, 1, &mut context)
3860        } else {
3861            if !entry_owns_css_tree
3862                && !has_document_head_boundary
3863                && !document.style_closures.is_empty()
3864            {
3865                self.emit_component_style_closure(
3866                    options.entry_id,
3867                    StyleClosureInstall::Static,
3868                    &mut context,
3869                )?;
3870                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3871            }
3872            self.process_fragment(entry, &mut context)
3873        };
3874
3875        if entry_owns_css_tree {
3876            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3877        }
3878        render_result?;
3879        writer.end()?;
3880
3881        Ok(())
3882    }
3883}
3884
3885impl Default for WebUIHandler {
3886    fn default() -> Self {
3887        Self::new()
3888    }
3889}
3890
3891/// Write ` name="value"` to the writer without allocating a format string.
3892fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3893    writer.write_attribute(name, value)
3894}
3895
3896#[cfg(test)]
3897fn handle(
3898    protocol: &WebUIProtocol,
3899    state: &Value,
3900    options: &RenderOptions<'_>,
3901    writer: &mut dyn ResponseWriter,
3902) -> Result<()> {
3903    let handler = WebUIHandler::new();
3904    handler.handle(protocol, state, options, writer)
3905}
3906
3907#[cfg(test)]
3908mod tests {
3909    use super::*;
3910    use crate::streaming::STREAMING_MARKER;
3911    use std::cell::RefCell;
3912    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
3913    use webui_protocol::{
3914        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
3915        WebUIFragmentAttribute, WebUiFragmentRoute,
3916    };
3917    use webui_test_utils::test_json;
3918
3919    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
3920        WebUIFragment::signal(
3921            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
3922            true,
3923        )
3924    }
3925
3926    #[test]
3927    fn borrowed_collection_reuses_state_array() {
3928        let state = test_json!({
3929            "items": [
3930                {"name": "first"},
3931                {"name": "second"}
3932            ]
3933        });
3934        let local_vars = HashMap::new();
3935        let local_borrowed_vars = BorrowedScope::default();
3936        let loop_vars = Vec::new();
3937        let items = resolve_borrowed_collection(
3938            "items",
3939            &loop_vars,
3940            VisibleLoopScope::EMPTY,
3941            LocalValueSources {
3942                owned: &local_vars,
3943                borrowed: &local_borrowed_vars,
3944            },
3945            &state,
3946        )
3947        .unwrap_or_else(|| panic!("state array should be borrowed"));
3948        let state_items = state["items"]
3949            .as_array()
3950            .unwrap_or_else(|| panic!("fixture items should be an array"));
3951        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3952    }
3953
3954    #[test]
3955    fn owned_local_collection_keeps_precedence() {
3956        let state = test_json!({"items": [{"name": "global"}]});
3957        let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
3958        let local_borrowed_vars = BorrowedScope::default();
3959        assert!(resolve_borrowed_collection(
3960            "items",
3961            &[],
3962            VisibleLoopScope::EMPTY,
3963            LocalValueSources {
3964                owned: &local_vars,
3965                borrowed: &local_borrowed_vars,
3966            },
3967            &state,
3968        )
3969        .is_none());
3970    }
3971
3972    #[test]
3973    fn borrowed_component_collection_reuses_state_array() {
3974        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
3975        let contacts = &state["teams"][0]["contacts"];
3976        let mut local_borrowed_vars = BorrowedScope::default();
3977        local_borrowed_vars.insert("contacts", contacts);
3978        let local_vars = HashMap::new();
3979        let items = resolve_borrowed_collection(
3980            "contacts",
3981            &[],
3982            VisibleLoopScope::EMPTY,
3983            LocalValueSources {
3984                owned: &local_vars,
3985                borrowed: &local_borrowed_vars,
3986            },
3987            &state,
3988        )
3989        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
3990        let state_items = contacts
3991            .as_array()
3992            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
3993        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3994    }
3995
3996    #[test]
3997    fn nested_borrowed_collection_reuses_child_array() {
3998        let state = test_json!({
3999            "items": [{
4000                "children": [
4001                    {"name": "first"},
4002                    {"name": "second"}
4003                ]
4004            }]
4005        });
4006        let item = &state["items"][0];
4007        let loop_vars = vec![LoopBinding {
4008            name: "item",
4009            value: item,
4010        }];
4011        let local_vars = HashMap::new();
4012        let local_borrowed_vars = BorrowedScope::default();
4013        let children = resolve_borrowed_collection(
4014            "item.children",
4015            &loop_vars,
4016            VisibleLoopScope { start: 0, end: 1 },
4017            LocalValueSources {
4018                owned: &local_vars,
4019                borrowed: &local_borrowed_vars,
4020            },
4021            &state,
4022        )
4023        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4024        let state_children = item["children"]
4025            .as_array()
4026            .unwrap_or_else(|| panic!("fixture children should be an array"));
4027        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4028    }
4029
4030    #[test]
4031    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4032        let state = test_json!({
4033            "item": {"name": "global", "fallback": "global fallback"},
4034            "outer": {"name": "outer"},
4035            "inner": {"name": "inner"}
4036        });
4037        let loop_vars = vec![
4038            LoopBinding {
4039                name: "item",
4040                value: &state["outer"],
4041            },
4042            LoopBinding {
4043                name: "item",
4044                value: &state["inner"],
4045            },
4046        ];
4047        let local_vars = HashMap::new();
4048        let local_borrowed_vars = BorrowedScope::default();
4049        let sources = LocalValueSources {
4050            owned: &local_vars,
4051            borrowed: &local_borrowed_vars,
4052        };
4053
4054        assert_eq!(
4055            resolve_value_from_sources(
4056                "item.name",
4057                &loop_vars,
4058                VisibleLoopScope { start: 0, end: 2 },
4059                sources,
4060                &state,
4061            )
4062            .as_deref()
4063            .and_then(Value::as_str),
4064            Some("inner")
4065        );
4066        assert_eq!(
4067            resolve_value_from_sources(
4068                "item.fallback",
4069                &loop_vars,
4070                VisibleLoopScope { start: 0, end: 2 },
4071                sources,
4072                &state,
4073            )
4074            .as_deref()
4075            .and_then(Value::as_str),
4076            Some("global fallback")
4077        );
4078        assert_eq!(
4079            resolve_value_from_sources(
4080                "item.name",
4081                &loop_vars,
4082                VisibleLoopScope { start: 2, end: 2 },
4083                sources,
4084                &state,
4085            )
4086            .as_deref()
4087            .and_then(Value::as_str),
4088            Some("global")
4089        );
4090    }
4091
4092    #[test]
4093    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4094        let values = [
4095            Value::String("a".to_string()),
4096            Value::String("b".to_string()),
4097            Value::String("c".to_string()),
4098            Value::String("d".to_string()),
4099            Value::String("e".to_string()),
4100            Value::String("f".to_string()),
4101            Value::String("replacement".to_string()),
4102        ];
4103        let mut scope = BorrowedScope::default();
4104        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4105            assert!(scope.insert(name, &values[index]).is_none());
4106        }
4107        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4108        assert_eq!(scope.overflow.len(), 2);
4109        assert_eq!(scope.get("a"), Some(&values[0]));
4110        assert_eq!(scope.get("f"), Some(&values[5]));
4111        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4112        assert_eq!(scope.get("a"), Some(&values[6]));
4113        assert_eq!(scope.remove("b"), Some(&values[1]));
4114        assert!(scope.get("b").is_none());
4115        assert_eq!(scope.get("f"), Some(&values[5]));
4116        scope.clear();
4117        assert!(scope.get("a").is_none());
4118        assert!(scope.overflow.is_empty());
4119    }
4120
4121    // A simple test writer implementation
4122    struct TestWriter {
4123        content: RefCell<String>,
4124        ended: RefCell<bool>,
4125    }
4126
4127    impl TestWriter {
4128        fn new() -> Self {
4129            Self {
4130                content: RefCell::new(String::new()),
4131                ended: RefCell::new(false),
4132            }
4133        }
4134
4135        fn get_content(&self) -> String {
4136            self.content.borrow().clone()
4137        }
4138
4139        fn is_ended(&self) -> bool {
4140            *self.ended.borrow()
4141        }
4142    }
4143
4144    impl ResponseWriter for TestWriter {
4145        fn write(&mut self, content: &str) -> Result<()> {
4146            self.content.borrow_mut().push_str(content);
4147            Ok(())
4148        }
4149
4150        fn end(&mut self) -> Result<()> {
4151            *self.ended.borrow_mut() = true;
4152            Ok(())
4153        }
4154    }
4155
4156    #[test]
4157    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4158        struct AttributeWriter {
4159            output: String,
4160        }
4161
4162        impl ResponseWriter for AttributeWriter {
4163            fn write(&mut self, content: &str) -> Result<()> {
4164                self.output.push_str(content);
4165                Ok(())
4166            }
4167
4168            crate::string_response_writer_methods!(output);
4169
4170            fn end(&mut self) -> Result<()> {
4171                Ok(())
4172            }
4173        }
4174
4175        let mut writer = AttributeWriter {
4176            output: String::new(),
4177        };
4178        write_attr(&mut writer, "data-id", "42")
4179            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4180        writer
4181            .write_boolean_attribute("disabled")
4182            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4183        assert_eq!(writer.output, " data-id=\"42\" disabled");
4184    }
4185
4186    #[test]
4187    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4188        let mut fragments = HashMap::new();
4189        fragments.insert(
4190            "index.html".to_string(),
4191            FragmentList {
4192                fragments: vec![
4193                    WebUIFragment {
4194                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4195                            name: ":data-title".to_string(),
4196                            value: "source".to_string(),
4197                            complex: true,
4198                            attr_start: true,
4199                            ..Default::default()
4200                        })),
4201                    },
4202                    WebUIFragment::component("prepared-child"),
4203                ],
4204                contains_boundary: false,
4205            },
4206        );
4207        fragments.insert(
4208            "prepared-child".to_string(),
4209            FragmentList {
4210                fragments: vec![WebUIFragment::signal("dataTitle", false)],
4211                contains_boundary: false,
4212            },
4213        );
4214        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4215        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4216        let entry = resolved
4217            .list_by_id("index.html")
4218            .unwrap_or_else(|| panic!("entry render plan should exist"));
4219        let child_index = protocol
4220            .render_fragments()
4221            .index("prepared-child")
4222            .unwrap_or_else(|| panic!("component target should be indexed"));
4223        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4224        assert_eq!(entry.target(1), Some(child_index));
4225        assert!(!entry.has_routes);
4226        let source_entry = protocol
4227            .protocol()
4228            .fragments
4229            .get("index.html")
4230            .unwrap_or_else(|| panic!("source entry should exist"));
4231        assert!(std::ptr::eq(
4232            entry.fragments.as_ptr(),
4233            source_entry.fragments.as_ptr()
4234        ));
4235
4236        let state = test_json!({"source": "Prepared"});
4237        let options = RenderOptions::new("index.html", "/");
4238        for handler in [
4239            WebUIHandler::new(),
4240            WebUIHandler::with_plugin(|| {
4241                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4242            }),
4243        ] {
4244            let mut writer = TestWriter::new();
4245            assert!(handler
4246                .render(&protocol, &state, &options, &mut writer)
4247                .is_ok());
4248            assert!(writer.get_content().contains("Prepared"));
4249        }
4250    }
4251
4252    #[test]
4253    fn prepared_graph_records_route_presence_per_fragment_list() {
4254        let mut fragments = HashMap::new();
4255        fragments.insert(
4256            "index.html".to_string(),
4257            FragmentList {
4258                fragments: vec![WebUIFragment::route("/", "dash-page")],
4259                contains_boundary: false,
4260            },
4261        );
4262        fragments.insert(
4263            "dash-page".to_string(),
4264            FragmentList {
4265                fragments: vec![WebUIFragment::raw("dashboard")],
4266                contains_boundary: false,
4267            },
4268        );
4269        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4270        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4271        let entry = resolved
4272            .list_by_id("index.html")
4273            .unwrap_or_else(|| panic!("entry render plan should exist"));
4274        let page = resolved
4275            .list_by_id("dash-page")
4276            .unwrap_or_else(|| panic!("page render plan should exist"));
4277        // Only lists that actually contain a route pay for the sibling scan.
4278        assert!(entry.has_routes);
4279        assert!(!page.has_routes);
4280    }
4281
4282    #[test]
4283    fn prepared_prop_names_match_attribute_to_camel_exactly() {
4284        // Preparation writes the camelCase conversion straight into the shared
4285        // arena instead of calling `attribute_to_camel`, which allocates. The two
4286        // must stay byte-identical, including the irregular lookup-table entries.
4287        let names = [
4288            "data-title",
4289            ":data-title",
4290            "aria-labelledby",
4291            "aria-valuemax",
4292            "readonly",
4293            "tabindex",
4294            "class",
4295            "for",
4296            "maxlength",
4297            "novalidate",
4298            "already-camel",
4299            "trailing-",
4300            "-leading",
4301            "multi-part-attribute-name",
4302            "single",
4303            "",
4304        ];
4305        for name in names {
4306            let stripped = name.strip_prefix(':').unwrap_or(name);
4307            assert_eq!(
4308                component_attr_name(name),
4309                webui_protocol::attrs::attribute_to_camel(stripped),
4310                "prepared name diverged for `{name}`"
4311            );
4312        }
4313    }
4314
4315    #[test]
4316    fn prepared_component_prop_names_match_per_branch_stripping() {
4317        // The render path strips a leading `:` twice on the complex-value branch
4318        // and once elsewhere. Preparation must reproduce that per branch or a
4319        // `::`-prefixed binding would change the emitted component prop name.
4320        let complex = WebUIFragmentAttribute {
4321            name: "::data-title".to_string(),
4322            value: "source".to_string(),
4323            complex: true,
4324            ..Default::default()
4325        };
4326        assert_eq!(component_attr_source(&complex), ":data-title");
4327        assert_eq!(
4328            component_attr_name(component_attr_source(&complex)),
4329            "dataTitle"
4330        );
4331
4332        // `raw_value` takes the static branch, which strips only once.
4333        let raw = WebUIFragmentAttribute {
4334            raw_value: true,
4335            ..complex.clone()
4336        };
4337        assert_eq!(component_attr_source(&raw), "::data-title");
4338
4339        // A template attribute returns before the complex branch is reached.
4340        let templated = WebUIFragmentAttribute {
4341            template: "tpl".to_string(),
4342            ..complex.clone()
4343        };
4344        assert_eq!(component_attr_source(&templated), "::data-title");
4345
4346        // An empty value never reaches the complex branch either.
4347        let empty_value = WebUIFragmentAttribute {
4348            value: String::new(),
4349            ..complex.clone()
4350        };
4351        assert_eq!(component_attr_source(&empty_value), "::data-title");
4352
4353        // Ordinary single-colon bindings are unaffected on every branch.
4354        let ordinary = WebUIFragmentAttribute {
4355            name: ":data-title".to_string(),
4356            ..complex
4357        };
4358        assert_eq!(component_attr_source(&ordinary), "data-title");
4359        assert_eq!(
4360            component_attr_name(component_attr_source(&ordinary)),
4361            "dataTitle"
4362        );
4363    }
4364
4365    #[test]
4366    fn render_slots_match_continuation_slots() {
4367        // The streaming VM reads prepared metadata with the continuation slot
4368        // directly. That is only sound while both numberings index the same
4369        // sorted fragment IDs, so pin the invariant here rather than paying for
4370        // an ID lookup per streamed fragment frame.
4371        let mut fragments = HashMap::new();
4372        for id in [
4373            "index.html",
4374            "zeta-last",
4375            "alpha-first",
4376            "middle-one",
4377            "middle-two",
4378        ] {
4379            fragments.insert(
4380                id.to_string(),
4381                FragmentList {
4382                    fragments: vec![WebUIFragment::raw(id)],
4383                    contains_boundary: false,
4384                },
4385            );
4386        }
4387        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4388        let index = protocol.render_fragments();
4389        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4390            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4391        {
4392            let id = protocol
4393                .fragment_id(slot)
4394                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4395            assert_eq!(
4396                index.index(id),
4397                Some(slot as usize),
4398                "render slot must equal continuation slot for `{id}`"
4399            );
4400            assert_eq!(protocol.fragment_slot(id), Some(slot));
4401        }
4402    }
4403
4404    #[test]
4405    fn prepared_graph_reports_missing_fragments_without_panicking() {
4406        let mut fragments = HashMap::new();
4407        fragments.insert(
4408            "index.html".to_string(),
4409            FragmentList {
4410                fragments: vec![WebUIFragment::component("absent-child")],
4411                contains_boundary: false,
4412            },
4413        );
4414        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4415        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4416        let entry = resolved
4417            .list_by_id("index.html")
4418            .unwrap_or_else(|| panic!("entry render plan should exist"));
4419        // An unresolvable reference keeps a `None` target and must not index
4420        // out of range or silently render an unrelated fragment list.
4421        assert_eq!(entry.target(0), None);
4422        assert!(resolved.list_by_id("absent-child").is_none());
4423        assert!(resolved.list(usize::MAX).is_none());
4424
4425        let state = test_json!({});
4426        let options = RenderOptions::new("index.html", "/");
4427        let mut writer = TestWriter::new();
4428        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4429            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4430            other => panic!("expected a missing fragment error, got {other:?}"),
4431        }
4432
4433        let mut writer = TestWriter::new();
4434        match WebUIHandler::new().render(
4435            &protocol,
4436            &state,
4437            &RenderOptions::new("absent-entry", "/"),
4438            &mut writer,
4439        ) {
4440            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4441            other => panic!("expected a missing entry error, got {other:?}"),
4442        }
4443    }
4444
4445    #[test]
4446    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4447        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4448        let mut fragments = HashMap::with_capacity(list_count);
4449        for index in 0..list_count {
4450            fragments.insert(
4451                format!("fragment-{index}"),
4452                FragmentList {
4453                    fragments: vec![WebUIFragment::raw("content")],
4454                    contains_boundary: false,
4455                },
4456            );
4457        }
4458        let protocol = Protocol::new(WebUIProtocol::new(fragments));
4459        let resolved = protocol.render_fragments().resolve(protocol.protocol());
4460        // Nothing is materialized until a slot is actually rendered.
4461        assert!(resolved.cache[0].get().is_none());
4462        assert!(resolved.spilled.get().is_none());
4463        assert!(resolved.list(0).is_some());
4464        assert!(resolved.cache[0].get().is_some());
4465        // Rendering only inline slots never allocates the spill region.
4466        assert!(resolved.spilled.get().is_none());
4467        // Slots past the inline cache spill once, then stay memoized so a deep
4468        // descent never re-hashes the fragment ID.
4469        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4470        let spilled = resolved
4471            .spilled
4472            .get()
4473            .expect("a slot past the inline cache should allocate the spill region");
4474        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4475        assert!(spilled[0].get().is_some());
4476        assert!(resolved.list(list_count - 1).is_some());
4477
4478        for (id, source) in &protocol.protocol().fragments {
4479            let prepared = resolved
4480                .list_by_id(id)
4481                .unwrap_or_else(|| panic!("render plan should contain {id}"));
4482            // Borrowed, never duplicated: the prepared list points at protocol storage.
4483            assert!(std::ptr::eq(
4484                prepared.fragments.as_ptr(),
4485                source.fragments.as_ptr()
4486            ));
4487        }
4488    }
4489
4490    #[test]
4491    fn test_handle_raw() {
4492        // Create a simple protocol
4493        let mut fragments = HashMap::new();
4494        fragments.insert(
4495            "index.html".to_string(),
4496            FragmentList {
4497                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4498                contains_boundary: false,
4499            },
4500        );
4501
4502        let protocol = WebUIProtocol::new(fragments);
4503        let state = test_json!({});
4504
4505        // Create a test writer
4506        let mut writer = TestWriter::new();
4507
4508        // Handle the protocol
4509        assert!(
4510            handle(
4511                &protocol,
4512                &state,
4513                &RenderOptions::new("index.html", "/"),
4514                &mut writer
4515            )
4516            .is_ok(),
4517            "Failed to handle raw protocol"
4518        );
4519
4520        // Check the output
4521        assert_eq!(writer.get_content(), "Hello, WebUI!");
4522        assert!(writer.is_ended());
4523    }
4524
4525    #[test]
4526    fn test_handle_signal() {
4527        // Create a protocol with a signal
4528        let mut fragments = HashMap::new();
4529        fragments.insert(
4530            "index.html".to_string(),
4531            FragmentList {
4532                fragments: vec![
4533                    WebUIFragment::raw("Hello, "),
4534                    WebUIFragment::signal("name", false),
4535                    WebUIFragment::raw("!"),
4536                ],
4537                contains_boundary: false,
4538            },
4539        );
4540
4541        let protocol = WebUIProtocol::new(fragments);
4542        let state = test_json!({"name": "WebUI"});
4543
4544        // Create a test writer
4545        let mut writer = TestWriter::new();
4546
4547        // Handle the protocol
4548        assert!(
4549            handle(
4550                &protocol,
4551                &state,
4552                &RenderOptions::new("index.html", "/"),
4553                &mut writer
4554            )
4555            .is_ok(),
4556            "Failed to handle signal protocol"
4557        );
4558
4559        // Check the output
4560        assert_eq!(writer.get_content(), "Hello, WebUI!");
4561        assert!(writer.is_ended());
4562    }
4563
4564    #[test]
4565    fn test_handle_for_loop() {
4566        // Create a protocol with a for loop
4567        let mut fragments = HashMap::new();
4568        fragments.insert(
4569            "index.html".to_string(),
4570            FragmentList {
4571                fragments: vec![
4572                    WebUIFragment::raw("People: "),
4573                    WebUIFragment::for_loop("person", "people", "person-item"),
4574                ],
4575                contains_boundary: false,
4576            },
4577        );
4578
4579        fragments.insert(
4580            "person-item".to_string(),
4581            FragmentList {
4582                fragments: vec![
4583                    WebUIFragment::signal("person.name", false),
4584                    WebUIFragment::raw(", "),
4585                ],
4586                contains_boundary: false,
4587            },
4588        );
4589
4590        let protocol = WebUIProtocol::new(fragments);
4591        let state = test_json!({
4592            "people": [
4593                {"name": "Alice"},
4594                {"name": "Bob"},
4595                {"name": "Charlie"}
4596            ]
4597        });
4598
4599        // Create a test writer
4600        let mut writer = TestWriter::new();
4601
4602        // Handle the protocol
4603        assert!(
4604            handle(
4605                &protocol,
4606                &state,
4607                &RenderOptions::new("index.html", "/"),
4608                &mut writer
4609            )
4610            .is_ok(),
4611            "Failed to handle for loop protocol"
4612        );
4613
4614        // Check the output
4615        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4616        assert!(writer.is_ended());
4617    }
4618
4619    #[test]
4620    fn test_handle_if_condition() {
4621        // Create a protocol with an if condition
4622        let mut fragments = HashMap::new();
4623        fragments.insert(
4624            "index.html".to_string(),
4625            FragmentList {
4626                fragments: vec![
4627                    WebUIFragment::raw("Status: "),
4628                    WebUIFragment::if_cond(
4629                        webui_protocol::ConditionExpr::identifier("isActive"),
4630                        "active-content",
4631                    ),
4632                    WebUIFragment::raw("End"),
4633                ],
4634                contains_boundary: false,
4635            },
4636        );
4637
4638        fragments.insert(
4639            "active-content".to_string(),
4640            FragmentList {
4641                fragments: vec![WebUIFragment::raw("Active")],
4642                contains_boundary: false,
4643            },
4644        );
4645
4646        let protocol = WebUIProtocol::new(fragments);
4647
4648        // Test with isActive = true
4649        let state_true = test_json!({"isActive": true});
4650        let mut writer_true = TestWriter::new();
4651        assert!(
4652            handle(
4653                &protocol,
4654                &state_true,
4655                &RenderOptions::new("index.html", "/"),
4656                &mut writer_true
4657            )
4658            .is_ok(),
4659            "Failed to handle if condition (true case)"
4660        );
4661        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4662        assert!(writer_true.is_ended());
4663
4664        // Test with isActive = false
4665        let state_false = test_json!({"isActive": false});
4666        let mut writer_false = TestWriter::new();
4667        assert!(
4668            handle(
4669                &protocol,
4670                &state_false,
4671                &RenderOptions::new("index.html", "/"),
4672                &mut writer_false
4673            )
4674            .is_ok(),
4675            "Failed to handle if condition (false case)"
4676        );
4677        assert_eq!(writer_false.get_content(), "Status: End");
4678        assert!(writer_false.is_ended());
4679    }
4680
4681    #[test]
4682    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4683        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>"#;
4684        let mut parser = HtmlParser::new();
4685        parser
4686            .parse("index.html", source)
4687            .expect("parse missing-path condition fixture");
4688        let protocol = WebUIProtocol::new(parser.into_fragment_records());
4689        let state = test_json!({
4690            "items": [
4691                {"label": "Normal"},
4692                {"label": "Search", "searchPresentation": true}
4693            ]
4694        });
4695        let mut writer = TestWriter::new();
4696
4697        handle(
4698            &protocol,
4699            &state,
4700            &RenderOptions::new("index.html", "/"),
4701            &mut writer,
4702        )
4703        .expect("render missing-path condition fixture");
4704
4705        assert_eq!(
4706            writer.get_content(),
4707            "top-negated<span>Normal</span><mark>Search</mark>"
4708        );
4709    }
4710
4711    #[test]
4712    fn native_dynamic_attribute_does_not_leak_into_next_component() {
4713        let mut fragments = HashMap::new();
4714        fragments.insert(
4715            "index.html".to_string(),
4716            FragmentList {
4717                fragments: vec![
4718                    WebUIFragment::raw("<div"),
4719                    WebUIFragment {
4720                        fragment: Some(web_ui_fragment::Fragment::Attribute(
4721                            WebUIFragmentAttribute {
4722                                name: "title".into(),
4723                                value: "nativeTitle".into(),
4724                                ..Default::default()
4725                            },
4726                        )),
4727                    },
4728                    WebUIFragment::raw("></div><my-comp>"),
4729                    WebUIFragment::component("my-comp"),
4730                    WebUIFragment::raw("</my-comp>"),
4731                ],
4732                contains_boundary: false,
4733            },
4734        );
4735        fragments.insert(
4736            "my-comp".to_string(),
4737            FragmentList {
4738                fragments: vec![WebUIFragment::signal("title", false)],
4739                contains_boundary: false,
4740            },
4741        );
4742        let protocol = WebUIProtocol::new(fragments);
4743        let state = test_json!({
4744            "nativeTitle": "native",
4745            "title": "global"
4746        });
4747        let mut writer = TestWriter::new();
4748        handle(
4749            &protocol,
4750            &state,
4751            &RenderOptions::new("index.html", "/"),
4752            &mut writer,
4753        )
4754        .unwrap_or_else(|error| panic!("render failed: {error}"));
4755        assert_eq!(
4756            writer.get_content(),
4757            "<div title=\"native\"></div><my-comp>global</my-comp>"
4758        );
4759    }
4760
4761    #[test]
4762    fn test_handle_component() {
4763        // Create a protocol with a component
4764        let mut fragments = HashMap::new();
4765        fragments.insert(
4766            "index.html".to_string(),
4767            FragmentList {
4768                fragments: vec![
4769                    WebUIFragment::raw("Component: "),
4770                    WebUIFragment::component("my-component"),
4771                ],
4772                contains_boundary: false,
4773            },
4774        );
4775
4776        fragments.insert(
4777            "my-component".to_string(),
4778            FragmentList {
4779                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
4780                contains_boundary: false,
4781            },
4782        );
4783
4784        let protocol = WebUIProtocol::new(fragments);
4785        let state = test_json!({});
4786
4787        // Create a test writer
4788        let mut writer = TestWriter::new();
4789
4790        // Handle the protocol
4791        assert!(
4792            handle(
4793                &protocol,
4794                &state,
4795                &RenderOptions::new("index.html", "/"),
4796                &mut writer
4797            )
4798            .is_ok(),
4799            "Failed to handle component protocol"
4800        );
4801
4802        // Check the output
4803        assert_eq!(
4804            writer.get_content(),
4805            "Component: <div>Component Content</div>"
4806        );
4807        assert!(writer.is_ended());
4808    }
4809
4810    #[test]
4811    fn test_missing_fragment() {
4812        // Create a protocol with a missing fragment reference
4813        let mut fragments = HashMap::new();
4814        fragments.insert(
4815            "index.html".to_string(),
4816            FragmentList {
4817                fragments: vec![WebUIFragment::component("missing-component")],
4818                contains_boundary: false,
4819            },
4820        );
4821
4822        let protocol = WebUIProtocol::new(fragments);
4823        let state = test_json!({});
4824
4825        // Create a test writer
4826        let mut writer = TestWriter::new();
4827
4828        // Handle the protocol
4829        let result = handle(
4830            &protocol,
4831            &state,
4832            &RenderOptions::new("index.html", "/"),
4833            &mut writer,
4834        );
4835
4836        // Expect an error
4837        assert!(result.is_err());
4838        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4839            assert_eq!(fragment_id, "missing-component");
4840        } else {
4841            panic!("Expected MissingFragment error");
4842        }
4843    }
4844
4845    #[test]
4846    fn test_missing_signal_renders_empty() {
4847        // A signal referencing a field absent from state should render as empty
4848        let mut fragments = HashMap::new();
4849        fragments.insert(
4850            "index.html".to_string(),
4851            FragmentList {
4852                fragments: vec![
4853                    WebUIFragment::raw("Hello, "),
4854                    WebUIFragment::signal("missing_field", false),
4855                    WebUIFragment::raw("!"),
4856                ],
4857                contains_boundary: false,
4858            },
4859        );
4860
4861        let protocol = WebUIProtocol::new(fragments);
4862        let state = test_json!({});
4863
4864        let mut writer = TestWriter::new();
4865
4866        assert!(
4867            handle(
4868                &protocol,
4869                &state,
4870                &RenderOptions::new("index.html", "/"),
4871                &mut writer
4872            )
4873            .is_ok(),
4874            "Missing signal should not produce an error"
4875        );
4876
4877        assert_eq!(writer.get_content(), "Hello, !");
4878        assert!(writer.is_ended());
4879    }
4880
4881    // ── Boolean attribute rendering tests ─────────────────────────────
4882
4883    #[test]
4884    fn test_boolean_attr_true() {
4885        let mut fragments = HashMap::new();
4886        fragments.insert(
4887            "index.html".to_string(),
4888            FragmentList {
4889                fragments: vec![
4890                    WebUIFragment::raw("<button"),
4891                    WebUIFragment::attribute_boolean(
4892                        "disabled",
4893                        ConditionExpr::identifier("isDisabled"),
4894                    ),
4895                    WebUIFragment::raw(">Click</button>"),
4896                ],
4897                contains_boundary: false,
4898            },
4899        );
4900        let protocol = WebUIProtocol::new(fragments);
4901        let state = test_json!({"isDisabled": true});
4902        let mut writer = TestWriter::new();
4903        handle(
4904            &protocol,
4905            &state,
4906            &RenderOptions::new("index.html", "/"),
4907            &mut writer,
4908        )
4909        .unwrap();
4910        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
4911    }
4912
4913    #[test]
4914    fn test_boolean_attr_false() {
4915        let mut fragments = HashMap::new();
4916        fragments.insert(
4917            "index.html".to_string(),
4918            FragmentList {
4919                fragments: vec![
4920                    WebUIFragment::raw("<button"),
4921                    WebUIFragment::attribute_boolean(
4922                        "disabled",
4923                        ConditionExpr::identifier("isDisabled"),
4924                    ),
4925                    WebUIFragment::raw(">Click</button>"),
4926                ],
4927                contains_boundary: false,
4928            },
4929        );
4930        let protocol = WebUIProtocol::new(fragments);
4931        let state = test_json!({"isDisabled": false});
4932        let mut writer = TestWriter::new();
4933        handle(
4934            &protocol,
4935            &state,
4936            &RenderOptions::new("index.html", "/"),
4937            &mut writer,
4938        )
4939        .unwrap();
4940        assert_eq!(writer.get_content(), "<button>Click</button>");
4941    }
4942
4943    #[test]
4944    fn test_boolean_attr_missing() {
4945        let mut fragments = HashMap::new();
4946        fragments.insert(
4947            "index.html".to_string(),
4948            FragmentList {
4949                fragments: vec![
4950                    WebUIFragment::raw("<input type=\"checkbox\""),
4951                    WebUIFragment::attribute_boolean(
4952                        "checked",
4953                        ConditionExpr::identifier("checked"),
4954                    ),
4955                    WebUIFragment::raw(">"),
4956                ],
4957                contains_boundary: false,
4958            },
4959        );
4960        let protocol = WebUIProtocol::new(fragments);
4961        let state = test_json!({});
4962        let mut writer = TestWriter::new();
4963        handle(
4964            &protocol,
4965            &state,
4966            &RenderOptions::new("index.html", "/"),
4967            &mut writer,
4968        )
4969        .unwrap();
4970        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
4971    }
4972
4973    #[test]
4974    fn test_boolean_attr_multiple() {
4975        let mut fragments = HashMap::new();
4976        fragments.insert(
4977            "index.html".to_string(),
4978            FragmentList {
4979                fragments: vec![
4980                    WebUIFragment::raw("<input type=\"checkbox\""),
4981                    WebUIFragment::attribute_boolean(
4982                        "checked",
4983                        ConditionExpr::identifier("checked"),
4984                    ),
4985                    WebUIFragment::attribute_boolean(
4986                        "disabled",
4987                        ConditionExpr::identifier("disabled"),
4988                    ),
4989                    WebUIFragment::raw(">"),
4990                ],
4991                contains_boundary: false,
4992            },
4993        );
4994        let protocol = WebUIProtocol::new(fragments);
4995        let state = test_json!({"checked": true, "disabled": false});
4996        let mut writer = TestWriter::new();
4997        handle(
4998            &protocol,
4999            &state,
5000            &RenderOptions::new("index.html", "/"),
5001            &mut writer,
5002        )
5003        .unwrap();
5004        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5005    }
5006
5007    // ── Simple attribute rendering tests ──────────────────────────────
5008
5009    #[test]
5010    fn test_attribute_with_value() {
5011        let mut fragments = HashMap::new();
5012        fragments.insert(
5013            "index.html".to_string(),
5014            FragmentList {
5015                fragments: vec![
5016                    WebUIFragment::raw("<input"),
5017                    WebUIFragment::attribute("value", "inputValue"),
5018                    WebUIFragment::raw(">"),
5019                ],
5020                contains_boundary: false,
5021            },
5022        );
5023        let protocol = WebUIProtocol::new(fragments);
5024        let state = test_json!({"inputValue": "Hello"});
5025        let mut writer = TestWriter::new();
5026        handle(
5027            &protocol,
5028            &state,
5029            &RenderOptions::new("index.html", "/"),
5030            &mut writer,
5031        )
5032        .unwrap();
5033        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5034    }
5035
5036    #[test]
5037    fn test_attribute_with_falsy_numeric() {
5038        let mut fragments = HashMap::new();
5039        fragments.insert(
5040            "index.html".to_string(),
5041            FragmentList {
5042                fragments: vec![
5043                    WebUIFragment::raw("<div name=\"test\""),
5044                    WebUIFragment::attribute("handle", "number"),
5045                    WebUIFragment::raw("></div>"),
5046                ],
5047                contains_boundary: false,
5048            },
5049        );
5050        let protocol = WebUIProtocol::new(fragments);
5051        let state = test_json!({"number": 0});
5052        let mut writer = TestWriter::new();
5053        handle(
5054            &protocol,
5055            &state,
5056            &RenderOptions::new("index.html", "/"),
5057            &mut writer,
5058        )
5059        .unwrap();
5060        assert_eq!(
5061            writer.get_content(),
5062            "<div name=\"test\" handle=\"0\"></div>"
5063        );
5064    }
5065
5066    // ── Dynamic attribute escaping for non-string JSON types ─────────
5067
5068    #[test]
5069    fn test_attribute_array_value_is_escaped() {
5070        let mut fragments = HashMap::new();
5071        fragments.insert(
5072            "index.html".to_string(),
5073            FragmentList {
5074                fragments: vec![
5075                    WebUIFragment::raw("<a"),
5076                    WebUIFragment::attribute("href", "value"),
5077                    WebUIFragment::raw(">demo</a>"),
5078                ],
5079                contains_boundary: false,
5080            },
5081        );
5082        let protocol = WebUIProtocol::new(fragments);
5083        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5084        let mut writer = TestWriter::new();
5085        handle(
5086            &protocol,
5087            &state,
5088            &RenderOptions::new("index.html", "/"),
5089            &mut writer,
5090        )
5091        .unwrap();
5092        let content = writer.get_content();
5093        // All inner double quotes must be entity-escaped so that the
5094        // browser never sees a second attribute boundary.
5095        assert!(
5096            content.contains("&quot;"),
5097            "Double quotes inside attribute value must be escaped: {content}"
5098        );
5099        // The href attribute value must be a single contiguous quoted
5100        // string — no extra attributes should appear.
5101        assert_eq!(
5102            content.matches("=\"").count(),
5103            1,
5104            "Only one attribute assignment expected: {content}"
5105        );
5106    }
5107
5108    #[test]
5109    fn test_attribute_object_value_is_escaped() {
5110        let mut fragments = HashMap::new();
5111        fragments.insert(
5112            "index.html".to_string(),
5113            FragmentList {
5114                fragments: vec![
5115                    WebUIFragment::raw("<div"),
5116                    WebUIFragment::attribute("data-cfg", "cfg"),
5117                    WebUIFragment::raw("></div>"),
5118                ],
5119                contains_boundary: false,
5120            },
5121        );
5122        let protocol = WebUIProtocol::new(fragments);
5123        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5124        let mut writer = TestWriter::new();
5125        handle(
5126            &protocol,
5127            &state,
5128            &RenderOptions::new("index.html", "/"),
5129            &mut writer,
5130        )
5131        .unwrap();
5132        let content = writer.get_content();
5133        assert!(
5134            content.contains("&quot;"),
5135            "Double quotes inside attribute value must be escaped: {content}"
5136        );
5137        assert_eq!(
5138            content.matches("=\"").count(),
5139            1,
5140            "Only one attribute assignment expected: {content}"
5141        );
5142    }
5143
5144    // ── Template attribute rendering tests ────────────────────────────
5145
5146    #[test]
5147    fn test_mixed_attribute_template() {
5148        let mut fragments = HashMap::new();
5149        fragments.insert(
5150            "index.html".to_string(),
5151            FragmentList {
5152                fragments: vec![
5153                    WebUIFragment::raw("<input"),
5154                    WebUIFragment::attribute_template("value", "attr-1"),
5155                    WebUIFragment::raw(">"),
5156                ],
5157                contains_boundary: false,
5158            },
5159        );
5160        fragments.insert(
5161            "attr-1".to_string(),
5162            FragmentList {
5163                fragments: vec![
5164                    WebUIFragment::raw("hello "),
5165                    WebUIFragment::signal("item", false),
5166                ],
5167                contains_boundary: false,
5168            },
5169        );
5170        let protocol = WebUIProtocol::new(fragments);
5171        let state = test_json!({"item": "world"});
5172        let mut writer = TestWriter::new();
5173        handle(
5174            &protocol,
5175            &state,
5176            &RenderOptions::new("index.html", "/"),
5177            &mut writer,
5178        )
5179        .unwrap();
5180        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5181    }
5182
5183    // ── Raw signal rendering test ─────────────────────────────────────
5184
5185    #[test]
5186    fn test_raw_signal_not_escaped() {
5187        let mut fragments = HashMap::new();
5188        fragments.insert(
5189            "index.html".to_string(),
5190            FragmentList {
5191                fragments: vec![
5192                    WebUIFragment::signal("html", false),
5193                    WebUIFragment::signal("html", true),
5194                ],
5195                contains_boundary: false,
5196            },
5197        );
5198        let protocol = WebUIProtocol::new(fragments);
5199        let state = test_json!({"html": "<strong>hi</strong>"});
5200        let mut writer = TestWriter::new();
5201        handle(
5202            &protocol,
5203            &state,
5204            &RenderOptions::new("index.html", "/"),
5205            &mut writer,
5206        )
5207        .unwrap();
5208        assert_eq!(
5209            writer.get_content(),
5210            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
5211        );
5212    }
5213
5214    // ── Nested for loop tests ─────────────────────────────────────────
5215
5216    #[test]
5217    fn test_nested_for_loop() {
5218        let mut fragments = HashMap::new();
5219        fragments.insert(
5220            "index.html".to_string(),
5221            FragmentList {
5222                fragments: vec![
5223                    WebUIFragment::raw("<div>"),
5224                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5225                    WebUIFragment::raw("</div>"),
5226                ],
5227                contains_boundary: false,
5228            },
5229        );
5230        fragments.insert(
5231            "outer".to_string(),
5232            FragmentList {
5233                fragments: vec![
5234                    WebUIFragment::raw("<div>"),
5235                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5236                    WebUIFragment::raw("</div>"),
5237                ],
5238                contains_boundary: false,
5239            },
5240        );
5241        fragments.insert(
5242            "inner".to_string(),
5243            FragmentList {
5244                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5245                contains_boundary: false,
5246            },
5247        );
5248        let protocol = WebUIProtocol::new(fragments);
5249        let state = test_json!({
5250            "outerItems": [
5251                {"innerItems": [{"name": "A"}, {"name": "B"}]},
5252                {"innerItems": [{"name": "C"}]}
5253            ]
5254        });
5255        let mut writer = TestWriter::new();
5256        handle(
5257            &protocol,
5258            &state,
5259            &RenderOptions::new("index.html", "/"),
5260            &mut writer,
5261        )
5262        .unwrap();
5263        assert_eq!(
5264            writer.get_content(),
5265            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5266        );
5267    }
5268
5269    #[test]
5270    fn test_nested_for_with_signals() {
5271        let mut fragments = HashMap::new();
5272        fragments.insert(
5273            "index.html".to_string(),
5274            FragmentList {
5275                fragments: vec![
5276                    WebUIFragment::raw("<div>"),
5277                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5278                    WebUIFragment::raw("</div>"),
5279                ],
5280                contains_boundary: false,
5281            },
5282        );
5283        fragments.insert(
5284            "outerTemplate".to_string(),
5285            FragmentList {
5286                fragments: vec![
5287                    WebUIFragment::raw("<div>"),
5288                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5289                    WebUIFragment::raw("</div>"),
5290                ],
5291                contains_boundary: false,
5292            },
5293        );
5294        fragments.insert(
5295            "innerTemplate".to_string(),
5296            FragmentList {
5297                fragments: vec![
5298                    WebUIFragment::raw("<span>"),
5299                    WebUIFragment::signal("innerItem.name", false),
5300                    WebUIFragment::raw("</span>"),
5301                ],
5302                contains_boundary: false,
5303            },
5304        );
5305        let protocol = WebUIProtocol::new(fragments);
5306        let state = test_json!({
5307            "outerItems": [
5308                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5309                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5310            ]
5311        });
5312        let mut writer = TestWriter::new();
5313        handle(
5314            &protocol,
5315            &state,
5316            &RenderOptions::new("index.html", "/"),
5317            &mut writer,
5318        )
5319        .unwrap();
5320        assert_eq!(
5321            writer.get_content(),
5322            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5323        );
5324    }
5325
5326    #[test]
5327    fn test_nested_for_with_global_state() {
5328        let mut fragments = HashMap::new();
5329        fragments.insert(
5330            "index.html".to_string(),
5331            FragmentList {
5332                fragments: vec![
5333                    WebUIFragment::raw("<div>"),
5334                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5335                    WebUIFragment::raw("</div>"),
5336                ],
5337                contains_boundary: false,
5338            },
5339        );
5340        fragments.insert(
5341            "outerTemplate".to_string(),
5342            FragmentList {
5343                fragments: vec![
5344                    WebUIFragment::raw("<div>"),
5345                    WebUIFragment::signal("globalOuter", false),
5346                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5347                    WebUIFragment::raw("</div>"),
5348                ],
5349                contains_boundary: false,
5350            },
5351        );
5352        fragments.insert(
5353            "innerTemplate".to_string(),
5354            FragmentList {
5355                fragments: vec![
5356                    WebUIFragment::raw("<span>"),
5357                    WebUIFragment::signal("innerItem.name", false),
5358                    WebUIFragment::signal("globalInner", false),
5359                    WebUIFragment::raw("</span>"),
5360                ],
5361                contains_boundary: false,
5362            },
5363        );
5364        let protocol = WebUIProtocol::new(fragments);
5365        let state = test_json!({
5366            "globalOuter": "GO",
5367            "globalInner": "GI",
5368            "outerItems": [
5369                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5370                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5371            ]
5372        });
5373        let mut writer = TestWriter::new();
5374        handle(
5375            &protocol,
5376            &state,
5377            &RenderOptions::new("index.html", "/"),
5378            &mut writer,
5379        )
5380        .unwrap();
5381        assert_eq!(
5382            writer.get_content(),
5383            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5384        );
5385    }
5386
5387    // ── For + If state scoping tests ──────────────────────────────────
5388
5389    #[test]
5390    fn test_if_in_for_uses_local_state() {
5391        let mut fragments = HashMap::new();
5392        fragments.insert(
5393            "index.html".to_string(),
5394            FragmentList {
5395                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5396                contains_boundary: false,
5397            },
5398        );
5399        fragments.insert(
5400            "item-tpl".to_string(),
5401            FragmentList {
5402                fragments: vec![WebUIFragment::if_cond(
5403                    ConditionExpr::identifier("item.visible"),
5404                    "visible-tpl",
5405                )],
5406                contains_boundary: false,
5407            },
5408        );
5409        fragments.insert(
5410            "visible-tpl".to_string(),
5411            FragmentList {
5412                fragments: vec![WebUIFragment::signal("item.name", false)],
5413                contains_boundary: false,
5414            },
5415        );
5416        let protocol = WebUIProtocol::new(fragments);
5417        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5418        let mut writer = TestWriter::new();
5419        handle(
5420            &protocol,
5421            &state,
5422            &RenderOptions::new("index.html", "/"),
5423            &mut writer,
5424        )
5425        .unwrap();
5426        assert_eq!(writer.get_content(), "Show");
5427    }
5428
5429    #[test]
5430    fn test_for_if_local_overrides_global() {
5431        let mut fragments = HashMap::new();
5432        fragments.insert(
5433            "index.html".to_string(),
5434            FragmentList {
5435                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5436                contains_boundary: false,
5437            },
5438        );
5439        fragments.insert(
5440            "item-tpl".to_string(),
5441            FragmentList {
5442                fragments: vec![WebUIFragment::if_cond(
5443                    ConditionExpr::identifier("item.flag"),
5444                    "show-tpl",
5445                )],
5446                contains_boundary: false,
5447            },
5448        );
5449        fragments.insert(
5450            "show-tpl".to_string(),
5451            FragmentList {
5452                fragments: vec![WebUIFragment::raw("yes")],
5453                contains_boundary: false,
5454            },
5455        );
5456        let protocol = WebUIProtocol::new(fragments);
5457        // Global flag is true, but local item.flag is false for second item
5458        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5459        let mut writer = TestWriter::new();
5460        handle(
5461            &protocol,
5462            &state,
5463            &RenderOptions::new("index.html", "/"),
5464            &mut writer,
5465        )
5466        .unwrap();
5467        assert_eq!(writer.get_content(), "yes");
5468    }
5469
5470    // ── Component attribute state tests ───────────────────────────────
5471
5472    #[test]
5473    fn test_component_attr_state_simple() {
5474        let mut fragments = HashMap::new();
5475        fragments.insert(
5476            "index.html".to_string(),
5477            FragmentList {
5478                fragments: vec![
5479                    WebUIFragment::raw("<my-comp"),
5480                    WebUIFragment {
5481                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5482                            WebUIFragmentAttribute {
5483                                name: "title".into(),
5484                                value: "Attribute Title".into(),
5485                                attr_start: true,
5486                                raw_value: true,
5487                                ..Default::default()
5488                            },
5489                        )),
5490                    },
5491                    WebUIFragment::raw(">"),
5492                    WebUIFragment::component("my-comp"),
5493                    WebUIFragment::raw("</my-comp>"),
5494                ],
5495
5496                contains_boundary: false,
5497            },
5498        );
5499        fragments.insert(
5500            "my-comp".to_string(),
5501            FragmentList {
5502                fragments: vec![
5503                    WebUIFragment::raw("<span>"),
5504                    WebUIFragment::signal("title", false),
5505                    WebUIFragment::raw("</span>"),
5506                ],
5507                contains_boundary: false,
5508            },
5509        );
5510        let protocol = WebUIProtocol::new(fragments);
5511        let state = test_json!({"title": "Global Title"});
5512        let mut writer = TestWriter::new();
5513        handle(
5514            &protocol,
5515            &state,
5516            &RenderOptions::new("index.html", "/"),
5517            &mut writer,
5518        )
5519        .unwrap();
5520        assert_eq!(
5521            writer.get_content(),
5522            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5523        );
5524    }
5525
5526    #[test]
5527    fn test_component_attr_state_template() {
5528        let mut fragments = HashMap::new();
5529        fragments.insert(
5530            "index.html".to_string(),
5531            FragmentList {
5532                fragments: vec![
5533                    WebUIFragment::raw("<my-comp"),
5534                    WebUIFragment {
5535                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5536                            WebUIFragmentAttribute {
5537                                name: "title".into(),
5538                                template: "title-attr".into(),
5539                                attr_start: true,
5540                                ..Default::default()
5541                            },
5542                        )),
5543                    },
5544                    WebUIFragment::raw(">"),
5545                    WebUIFragment::component("my-comp"),
5546                    WebUIFragment::raw("</my-comp>"),
5547                ],
5548
5549                contains_boundary: false,
5550            },
5551        );
5552        fragments.insert(
5553            "title-attr".to_string(),
5554            FragmentList {
5555                fragments: vec![
5556                    WebUIFragment::raw("hello "),
5557                    WebUIFragment::signal("item", false),
5558                ],
5559                contains_boundary: false,
5560            },
5561        );
5562        fragments.insert(
5563            "my-comp".to_string(),
5564            FragmentList {
5565                fragments: vec![
5566                    WebUIFragment::raw("<span>"),
5567                    WebUIFragment::signal("title", false),
5568                    WebUIFragment::raw("</span>"),
5569                ],
5570                contains_boundary: false,
5571            },
5572        );
5573        let protocol = WebUIProtocol::new(fragments);
5574        let state = test_json!({"item": "<world>"});
5575        let mut writer = TestWriter::new();
5576        handle(
5577            &protocol,
5578            &state,
5579            &RenderOptions::new("index.html", "/"),
5580            &mut writer,
5581        )
5582        .unwrap();
5583        assert_eq!(
5584            writer.get_content(),
5585            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
5586        );
5587    }
5588
5589    #[test]
5590    fn test_component_attr_camel_case() {
5591        let mut fragments = HashMap::new();
5592        fragments.insert(
5593            "index.html".to_string(),
5594            FragmentList {
5595                fragments: vec![
5596                    WebUIFragment::raw("<my-comp"),
5597                    WebUIFragment {
5598                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5599                            WebUIFragmentAttribute {
5600                                name: "data-title".into(),
5601                                template: "dt-attr".into(),
5602                                attr_start: true,
5603                                ..Default::default()
5604                            },
5605                        )),
5606                    },
5607                    WebUIFragment::raw(">"),
5608                    WebUIFragment::component("my-comp"),
5609                    WebUIFragment::raw("</my-comp>"),
5610                ],
5611
5612                contains_boundary: false,
5613            },
5614        );
5615        fragments.insert(
5616            "dt-attr".to_string(),
5617            FragmentList {
5618                fragments: vec![
5619                    WebUIFragment::raw("prefix "),
5620                    WebUIFragment::signal("item", false),
5621                ],
5622                contains_boundary: false,
5623            },
5624        );
5625        fragments.insert(
5626            "my-comp".to_string(),
5627            FragmentList {
5628                fragments: vec![
5629                    WebUIFragment::raw("<span>"),
5630                    WebUIFragment::signal("dataTitle", false),
5631                    WebUIFragment::raw("</span>"),
5632                ],
5633                contains_boundary: false,
5634            },
5635        );
5636        let protocol = WebUIProtocol::new(fragments);
5637        let state = test_json!({"item": "a&b"});
5638        let mut writer = TestWriter::new();
5639        handle(
5640            &protocol,
5641            &state,
5642            &RenderOptions::new("index.html", "/"),
5643            &mut writer,
5644        )
5645        .unwrap();
5646        assert_eq!(
5647            writer.get_content(),
5648            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
5649        );
5650    }
5651
5652    #[test]
5653    fn test_component_complex_attr() {
5654        let mut fragments = HashMap::new();
5655        fragments.insert(
5656            "index.html".to_string(),
5657            FragmentList {
5658                fragments: vec![
5659                    WebUIFragment::raw("<my-comp"),
5660                    WebUIFragment {
5661                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5662                            WebUIFragmentAttribute {
5663                                name: ":item".into(),
5664                                value: "complexItem".into(),
5665                                attr_start: true,
5666                                complex: true,
5667                                ..Default::default()
5668                            },
5669                        )),
5670                    },
5671                    WebUIFragment::raw(">"),
5672                    WebUIFragment::component("my-comp"),
5673                    WebUIFragment::raw("</my-comp>"),
5674                ],
5675
5676                contains_boundary: false,
5677            },
5678        );
5679        fragments.insert(
5680            "my-comp".to_string(),
5681            FragmentList {
5682                fragments: vec![
5683                    WebUIFragment::raw("<span>"),
5684                    WebUIFragment::signal("item.foo", false),
5685                    WebUIFragment::raw("</span><p>"),
5686                    WebUIFragment::signal("item.bar", false),
5687                    WebUIFragment::raw("</p>"),
5688                ],
5689                contains_boundary: false,
5690            },
5691        );
5692        let protocol = WebUIProtocol::new(fragments);
5693        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5694        let mut writer = TestWriter::new();
5695        handle(
5696            &protocol,
5697            &state,
5698            &RenderOptions::new("index.html", "/"),
5699            &mut writer,
5700        )
5701        .unwrap();
5702        assert_eq!(
5703            writer.get_content(),
5704            "<my-comp><span>1</span><p>true</p></my-comp>"
5705        );
5706    }
5707
5708    #[test]
5709    fn test_component_no_parent_pollution() {
5710        let mut fragments = HashMap::new();
5711        fragments.insert(
5712            "index.html".to_string(),
5713            FragmentList {
5714                fragments: vec![
5715                    WebUIFragment::raw("<parent"),
5716                    WebUIFragment {
5717                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5718                            WebUIFragmentAttribute {
5719                                name: "var".into(),
5720                                value: "var".into(),
5721                                attr_start: true,
5722                                ..Default::default()
5723                            },
5724                        )),
5725                    },
5726                    WebUIFragment::raw(">"),
5727                    WebUIFragment::component("parent"),
5728                    WebUIFragment::raw("</parent>"),
5729                ],
5730
5731                contains_boundary: false,
5732            },
5733        );
5734        fragments.insert(
5735            "parent".to_string(),
5736            FragmentList {
5737                fragments: vec![
5738                    WebUIFragment::raw("Before: "),
5739                    WebUIFragment::signal("var", false),
5740                    WebUIFragment::raw("<child foo"),
5741                    WebUIFragment {
5742                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5743                            WebUIFragmentAttribute {
5744                                name: "var".into(),
5745                                value: "replaced".into(),
5746                                raw_value: true,
5747                                ..Default::default()
5748                            },
5749                        )),
5750                    },
5751                    WebUIFragment::raw(">"),
5752                    WebUIFragment::component("child"),
5753                    WebUIFragment::raw("Label</child>After: "),
5754                    WebUIFragment::signal("var", false),
5755                ],
5756
5757                contains_boundary: false,
5758            },
5759        );
5760        fragments.insert(
5761            "child".to_string(),
5762            FragmentList {
5763                fragments: vec![],
5764                contains_boundary: false,
5765            },
5766        );
5767        let protocol = WebUIProtocol::new(fragments);
5768        let state = test_json!({"var": "original"});
5769        let mut writer = TestWriter::new();
5770        handle(
5771            &protocol,
5772            &state,
5773            &RenderOptions::new("index.html", "/"),
5774            &mut writer,
5775        )
5776        .unwrap();
5777        assert_eq!(
5778            writer.get_content(),
5779            "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
5780        );
5781    }
5782
5783    #[test]
5784    fn test_component_boolean_attr_state() {
5785        let mut fragments = HashMap::new();
5786        fragments.insert(
5787            "index.html".to_string(),
5788            FragmentList {
5789                fragments: vec![
5790                    WebUIFragment::raw("<my-comp"),
5791                    WebUIFragment {
5792                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5793                            WebUIFragmentAttribute {
5794                                name: "disabled".into(),
5795                                attr_start: true,
5796                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5797                                ..Default::default()
5798                            },
5799                        )),
5800                    },
5801                    WebUIFragment::raw(">"),
5802                    WebUIFragment::component("my-comp"),
5803                    WebUIFragment::raw("</my-comp>"),
5804                ],
5805
5806                contains_boundary: false,
5807            },
5808        );
5809        fragments.insert(
5810            "my-comp".to_string(),
5811            FragmentList {
5812                fragments: vec![WebUIFragment::if_cond(
5813                    ConditionExpr::identifier("disabled"),
5814                    "show",
5815                )],
5816                contains_boundary: false,
5817            },
5818        );
5819        fragments.insert(
5820            "show".to_string(),
5821            FragmentList {
5822                fragments: vec![WebUIFragment::raw("disabled!")],
5823                contains_boundary: false,
5824            },
5825        );
5826        let protocol = WebUIProtocol::new(fragments);
5827        let state = test_json!({"isDisabled": true});
5828        let mut writer = TestWriter::new();
5829        handle(
5830            &protocol,
5831            &state,
5832            &RenderOptions::new("index.html", "/"),
5833            &mut writer,
5834        )
5835        .unwrap();
5836        assert_eq!(
5837            writer.get_content(),
5838            "<my-comp disabled>disabled!</my-comp>"
5839        );
5840    }
5841
5842    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
5843
5844    /// Helper: render a signal value through the handler and return the escaped output.
5845    fn render_signal(value: &str) -> String {
5846        let mut fragments = HashMap::new();
5847        fragments.insert(
5848            "index.html".to_string(),
5849            FragmentList {
5850                fragments: vec![WebUIFragment::signal("v", false)],
5851                contains_boundary: false,
5852            },
5853        );
5854        let protocol = WebUIProtocol::new(fragments);
5855        let state = test_json!({"v": value});
5856        let mut writer = TestWriter::new();
5857        handle(
5858            &protocol,
5859            &state,
5860            &RenderOptions::new("index.html", "/"),
5861            &mut writer,
5862        )
5863        .unwrap();
5864        writer.get_content()
5865    }
5866
5867    #[test]
5868    fn test_escape_ampersand() {
5869        assert_eq!(render_signal("&"), "&amp;");
5870    }
5871
5872    #[test]
5873    fn test_escape_less_than() {
5874        assert_eq!(render_signal("<"), "&lt;");
5875    }
5876
5877    #[test]
5878    fn test_escape_greater_than() {
5879        assert_eq!(render_signal(">"), "&gt;");
5880    }
5881
5882    #[test]
5883    fn test_escape_double_quote() {
5884        assert_eq!(render_signal("\""), "&quot;");
5885    }
5886
5887    #[test]
5888    fn test_escape_single_quote() {
5889        // encode_safe escapes ' as &#x27;
5890        let result = render_signal("'");
5891        assert_eq!(
5892            result, "&#x27;",
5893            "Expected &#x27; for single quote, got: {result}"
5894        );
5895    }
5896
5897    #[test]
5898    fn test_escape_multiple_special_chars() {
5899        let result = render_signal("<script>alert('xss');</script>");
5900        assert!(
5901            result.contains("&lt;") && result.contains("&gt;"),
5902            "Expected escaped HTML, got: {}",
5903            result
5904        );
5905        assert!(
5906            !result.contains("<script>"),
5907            "Should not contain raw <script> tag"
5908        );
5909    }
5910
5911    #[test]
5912    fn test_escape_no_special_chars() {
5913        assert_eq!(render_signal("Hello World"), "Hello World");
5914    }
5915
5916    #[test]
5917    fn test_escape_empty_string() {
5918        assert_eq!(render_signal(""), "");
5919    }
5920
5921    #[test]
5922    fn test_escape_special_at_beginning() {
5923        let result = render_signal("<Hello");
5924        assert!(
5925            result.starts_with("&lt;"),
5926            "Expected &lt; at start, got: {}",
5927            result
5928        );
5929    }
5930
5931    #[test]
5932    fn test_escape_special_at_end() {
5933        let result = render_signal("Hello>");
5934        assert!(
5935            result.ends_with("&gt;"),
5936            "Expected &gt; at end, got: {}",
5937            result
5938        );
5939    }
5940
5941    #[test]
5942    fn test_escape_special_in_middle() {
5943        let result = render_signal("Hel&lo");
5944        assert!(
5945            result.contains("&amp;"),
5946            "Expected &amp; in middle, got: {}",
5947            result
5948        );
5949    }
5950
5951    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
5952
5953    #[test]
5954    fn test_boolean_attr_truthy_values() {
5955        // checked: 1
5956        {
5957            let mut fragments = HashMap::new();
5958            fragments.insert(
5959                "index.html".to_string(),
5960                FragmentList {
5961                    fragments: vec![
5962                        WebUIFragment::raw("<input"),
5963                        WebUIFragment::attribute_boolean(
5964                            "checked",
5965                            ConditionExpr::identifier("checked"),
5966                        ),
5967                        WebUIFragment::raw(">"),
5968                    ],
5969                    contains_boundary: false,
5970                },
5971            );
5972            let protocol = WebUIProtocol::new(fragments);
5973            let state = test_json!({"checked": 1});
5974            let mut writer = TestWriter::new();
5975            handle(
5976                &protocol,
5977                &state,
5978                &RenderOptions::new("index.html", "/"),
5979                &mut writer,
5980            )
5981            .unwrap();
5982            assert_eq!(writer.get_content(), "<input checked>");
5983        }
5984        // checked: "yes"
5985        {
5986            let mut fragments = HashMap::new();
5987            fragments.insert(
5988                "index.html".to_string(),
5989                FragmentList {
5990                    fragments: vec![
5991                        WebUIFragment::raw("<input"),
5992                        WebUIFragment::attribute_boolean(
5993                            "checked",
5994                            ConditionExpr::identifier("checked"),
5995                        ),
5996                        WebUIFragment::raw(">"),
5997                    ],
5998                    contains_boundary: false,
5999                },
6000            );
6001            let protocol = WebUIProtocol::new(fragments);
6002            let state = test_json!({"checked": "yes"});
6003            let mut writer = TestWriter::new();
6004            handle(
6005                &protocol,
6006                &state,
6007                &RenderOptions::new("index.html", "/"),
6008                &mut writer,
6009            )
6010            .unwrap();
6011            assert_eq!(writer.get_content(), "<input checked>");
6012        }
6013        // checked: {} (empty object is truthy)
6014        {
6015            let mut fragments = HashMap::new();
6016            fragments.insert(
6017                "index.html".to_string(),
6018                FragmentList {
6019                    fragments: vec![
6020                        WebUIFragment::raw("<input"),
6021                        WebUIFragment::attribute_boolean(
6022                            "checked",
6023                            ConditionExpr::identifier("checked"),
6024                        ),
6025                        WebUIFragment::raw(">"),
6026                    ],
6027                    contains_boundary: false,
6028                },
6029            );
6030            let protocol = WebUIProtocol::new(fragments);
6031            let state = test_json!({"checked": {}});
6032            let mut writer = TestWriter::new();
6033            handle(
6034                &protocol,
6035                &state,
6036                &RenderOptions::new("index.html", "/"),
6037                &mut writer,
6038            )
6039            .unwrap();
6040            // Empty object is falsy in this expression evaluator
6041            assert_eq!(writer.get_content(), "<input>");
6042        }
6043        // checked: "false" (string "false" is truthy)
6044        {
6045            let mut fragments = HashMap::new();
6046            fragments.insert(
6047                "index.html".to_string(),
6048                FragmentList {
6049                    fragments: vec![
6050                        WebUIFragment::raw("<input"),
6051                        WebUIFragment::attribute_boolean(
6052                            "checked",
6053                            ConditionExpr::identifier("checked"),
6054                        ),
6055                        WebUIFragment::raw(">"),
6056                    ],
6057                    contains_boundary: false,
6058                },
6059            );
6060            let protocol = WebUIProtocol::new(fragments);
6061            let state = test_json!({"checked": "false"});
6062            let mut writer = TestWriter::new();
6063            handle(
6064                &protocol,
6065                &state,
6066                &RenderOptions::new("index.html", "/"),
6067                &mut writer,
6068            )
6069            .unwrap();
6070            assert_eq!(writer.get_content(), "<input checked>");
6071        }
6072    }
6073
6074    #[test]
6075    fn test_boolean_attr_falsy_values() {
6076        // checked: 0
6077        {
6078            let mut fragments = HashMap::new();
6079            fragments.insert(
6080                "index.html".to_string(),
6081                FragmentList {
6082                    fragments: vec![
6083                        WebUIFragment::raw("<input"),
6084                        WebUIFragment::attribute_boolean(
6085                            "checked",
6086                            ConditionExpr::identifier("checked"),
6087                        ),
6088                        WebUIFragment::raw(">"),
6089                    ],
6090                    contains_boundary: false,
6091                },
6092            );
6093            let protocol = WebUIProtocol::new(fragments);
6094            let state = test_json!({"checked": 0});
6095            let mut writer = TestWriter::new();
6096            handle(
6097                &protocol,
6098                &state,
6099                &RenderOptions::new("index.html", "/"),
6100                &mut writer,
6101            )
6102            .unwrap();
6103            assert_eq!(writer.get_content(), "<input>");
6104        }
6105        // checked: ""
6106        {
6107            let mut fragments = HashMap::new();
6108            fragments.insert(
6109                "index.html".to_string(),
6110                FragmentList {
6111                    fragments: vec![
6112                        WebUIFragment::raw("<input"),
6113                        WebUIFragment::attribute_boolean(
6114                            "checked",
6115                            ConditionExpr::identifier("checked"),
6116                        ),
6117                        WebUIFragment::raw(">"),
6118                    ],
6119                    contains_boundary: false,
6120                },
6121            );
6122            let protocol = WebUIProtocol::new(fragments);
6123            let state = test_json!({"checked": ""});
6124            let mut writer = TestWriter::new();
6125            handle(
6126                &protocol,
6127                &state,
6128                &RenderOptions::new("index.html", "/"),
6129                &mut writer,
6130            )
6131            .unwrap();
6132            assert_eq!(writer.get_content(), "<input>");
6133        }
6134        // checked: false
6135        {
6136            let mut fragments = HashMap::new();
6137            fragments.insert(
6138                "index.html".to_string(),
6139                FragmentList {
6140                    fragments: vec![
6141                        WebUIFragment::raw("<input"),
6142                        WebUIFragment::attribute_boolean(
6143                            "checked",
6144                            ConditionExpr::identifier("checked"),
6145                        ),
6146                        WebUIFragment::raw(">"),
6147                    ],
6148                    contains_boundary: false,
6149                },
6150            );
6151            let protocol = WebUIProtocol::new(fragments);
6152            let state = test_json!({"checked": false});
6153            let mut writer = TestWriter::new();
6154            handle(
6155                &protocol,
6156                &state,
6157                &RenderOptions::new("index.html", "/"),
6158                &mut writer,
6159            )
6160            .unwrap();
6161            assert_eq!(writer.get_content(), "<input>");
6162        }
6163        // no checked key at all
6164        {
6165            let mut fragments = HashMap::new();
6166            fragments.insert(
6167                "index.html".to_string(),
6168                FragmentList {
6169                    fragments: vec![
6170                        WebUIFragment::raw("<input"),
6171                        WebUIFragment::attribute_boolean(
6172                            "checked",
6173                            ConditionExpr::identifier("checked"),
6174                        ),
6175                        WebUIFragment::raw(">"),
6176                    ],
6177                    contains_boundary: false,
6178                },
6179            );
6180            let protocol = WebUIProtocol::new(fragments);
6181            let state = test_json!({});
6182            let mut writer = TestWriter::new();
6183            handle(
6184                &protocol,
6185                &state,
6186                &RenderOptions::new("index.html", "/"),
6187                &mut writer,
6188            )
6189            .unwrap();
6190            assert_eq!(writer.get_content(), "<input>");
6191        }
6192    }
6193
6194    #[test]
6195    fn test_boolean_attr_expression_true() {
6196        let mut fragments = HashMap::new();
6197        fragments.insert(
6198            "index.html".to_string(),
6199            FragmentList {
6200                fragments: vec![
6201                    WebUIFragment::raw("<button"),
6202                    WebUIFragment::attribute_boolean(
6203                        "disabled",
6204                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6205                    ),
6206                    WebUIFragment::raw(">Click</button>"),
6207                ],
6208                contains_boundary: false,
6209            },
6210        );
6211        let protocol = WebUIProtocol::new(fragments);
6212        let state = test_json!({"itemCount": 5});
6213        let mut writer = TestWriter::new();
6214        handle(
6215            &protocol,
6216            &state,
6217            &RenderOptions::new("index.html", "/"),
6218            &mut writer,
6219        )
6220        .unwrap();
6221        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6222    }
6223
6224    #[test]
6225    fn test_boolean_attr_expression_false() {
6226        let mut fragments = HashMap::new();
6227        fragments.insert(
6228            "index.html".to_string(),
6229            FragmentList {
6230                fragments: vec![
6231                    WebUIFragment::raw("<button"),
6232                    WebUIFragment::attribute_boolean(
6233                        "disabled",
6234                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6235                    ),
6236                    WebUIFragment::raw(">Click</button>"),
6237                ],
6238                contains_boundary: false,
6239            },
6240        );
6241        let protocol = WebUIProtocol::new(fragments);
6242        let state = test_json!({"itemCount": 3});
6243        let mut writer = TestWriter::new();
6244        handle(
6245            &protocol,
6246            &state,
6247            &RenderOptions::new("index.html", "/"),
6248            &mut writer,
6249        )
6250        .unwrap();
6251        assert_eq!(writer.get_content(), "<button>Click</button>");
6252    }
6253
6254    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
6255
6256    #[test]
6257    fn test_nested_component_attr_capture() {
6258        let mut fragments = HashMap::new();
6259        fragments.insert(
6260            "index.html".to_string(),
6261            FragmentList {
6262                fragments: vec![
6263                    WebUIFragment::raw("<parent-component"),
6264                    WebUIFragment {
6265                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6266                            WebUIFragmentAttribute {
6267                                name: "title".into(),
6268                                template: "parent-title".into(),
6269                                attr_start: true,
6270                                ..Default::default()
6271                            },
6272                        )),
6273                    },
6274                    WebUIFragment::raw(">"),
6275                    WebUIFragment::component("parent-component"),
6276                    WebUIFragment::raw("</parent-component>"),
6277                ],
6278
6279                contains_boundary: false,
6280            },
6281        );
6282        fragments.insert(
6283            "parent-title".to_string(),
6284            FragmentList {
6285                fragments: vec![
6286                    WebUIFragment::raw("Hello "),
6287                    WebUIFragment::signal("who", false),
6288                ],
6289                contains_boundary: false,
6290            },
6291        );
6292        fragments.insert(
6293            "parent-component".to_string(),
6294            FragmentList {
6295                fragments: vec![
6296                    WebUIFragment::raw("<child-component"),
6297                    WebUIFragment {
6298                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6299                            WebUIFragmentAttribute {
6300                                name: "title".into(),
6301                                template: "child-title".into(),
6302                                attr_start: true,
6303                                ..Default::default()
6304                            },
6305                        )),
6306                    },
6307                    WebUIFragment::raw(">"),
6308                    WebUIFragment::component("child-component"),
6309                    WebUIFragment::raw("</child-component>"),
6310                ],
6311
6312                contains_boundary: false,
6313            },
6314        );
6315        fragments.insert(
6316            "child-title".to_string(),
6317            FragmentList {
6318                fragments: vec![
6319                    WebUIFragment::raw("Child of "),
6320                    WebUIFragment::signal("title", false),
6321                ],
6322                contains_boundary: false,
6323            },
6324        );
6325        fragments.insert(
6326            "child-component".to_string(),
6327            FragmentList {
6328                fragments: vec![
6329                    WebUIFragment::raw("<span>"),
6330                    WebUIFragment::signal("title", false),
6331                    WebUIFragment::raw("</span>"),
6332                ],
6333                contains_boundary: false,
6334            },
6335        );
6336        let protocol = WebUIProtocol::new(fragments);
6337        let state = test_json!({"who": "<world>"});
6338        let mut writer = TestWriter::new();
6339        handle(
6340            &protocol,
6341            &state,
6342            &RenderOptions::new("index.html", "/"),
6343            &mut writer,
6344        )
6345        .unwrap();
6346        assert_eq!(
6347            writer.get_content(),
6348            "<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>"
6349        );
6350    }
6351
6352    #[test]
6353    fn test_grandchild_attr_propagation() {
6354        let mut fragments = HashMap::new();
6355        fragments.insert(
6356            "index.html".to_string(),
6357            FragmentList {
6358                fragments: vec![
6359                    WebUIFragment::raw("<parent-component"),
6360                    WebUIFragment {
6361                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6362                            WebUIFragmentAttribute {
6363                                name: "title".into(),
6364                                template: "p-title".into(),
6365                                attr_start: true,
6366                                ..Default::default()
6367                            },
6368                        )),
6369                    },
6370                    WebUIFragment::raw(">"),
6371                    WebUIFragment::component("parent-component"),
6372                    WebUIFragment::raw("</parent-component>"),
6373                ],
6374
6375                contains_boundary: false,
6376            },
6377        );
6378        fragments.insert(
6379            "p-title".to_string(),
6380            FragmentList {
6381                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6382                contains_boundary: false,
6383            },
6384        );
6385        fragments.insert(
6386            "parent-component".to_string(),
6387            FragmentList {
6388                fragments: vec![
6389                    WebUIFragment::raw("<child-component"),
6390                    WebUIFragment {
6391                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6392                            WebUIFragmentAttribute {
6393                                name: "title".into(),
6394                                template: "c-title".into(),
6395                                attr_start: true,
6396                                ..Default::default()
6397                            },
6398                        )),
6399                    },
6400                    WebUIFragment::raw(">"),
6401                    WebUIFragment::component("child-component"),
6402                    WebUIFragment::raw("</child-component>"),
6403                ],
6404
6405                contains_boundary: false,
6406            },
6407        );
6408        fragments.insert(
6409            "c-title".to_string(),
6410            FragmentList {
6411                fragments: vec![
6412                    WebUIFragment::raw("C("),
6413                    WebUIFragment::signal("title", false),
6414                    WebUIFragment::raw(")-"),
6415                    WebUIFragment::signal("cExtra", false),
6416                ],
6417                contains_boundary: false,
6418            },
6419        );
6420        fragments.insert(
6421            "child-component".to_string(),
6422            FragmentList {
6423                fragments: vec![
6424                    WebUIFragment::raw("<grandchild-component"),
6425                    WebUIFragment {
6426                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6427                            WebUIFragmentAttribute {
6428                                name: "title".into(),
6429                                value: "title".into(),
6430                                attr_start: true,
6431                                ..Default::default()
6432                            },
6433                        )),
6434                    },
6435                    WebUIFragment::raw(">"),
6436                    WebUIFragment::component("grandchild-component"),
6437                    WebUIFragment::raw("</grandchild-component>"),
6438                ],
6439
6440                contains_boundary: false,
6441            },
6442        );
6443        fragments.insert(
6444            "grandchild-component".to_string(),
6445            FragmentList {
6446                fragments: vec![
6447                    WebUIFragment::raw("<span>"),
6448                    WebUIFragment::signal("title", false),
6449                    WebUIFragment::raw("</span>"),
6450                ],
6451                contains_boundary: false,
6452            },
6453        );
6454        let protocol = WebUIProtocol::new(fragments);
6455        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6456        let mut writer = TestWriter::new();
6457        handle(
6458            &protocol,
6459            &state,
6460            &RenderOptions::new("index.html", "/"),
6461            &mut writer,
6462        )
6463        .unwrap();
6464        assert_eq!(
6465            writer.get_content(),
6466            "<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>"
6467        );
6468    }
6469
6470    #[test]
6471    fn test_for_loop_component_attr() {
6472        let mut fragments = HashMap::new();
6473        fragments.insert(
6474            "index.html".to_string(),
6475            FragmentList {
6476                fragments: vec![
6477                    WebUIFragment::raw("<parent-component"),
6478                    WebUIFragment {
6479                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6480                            WebUIFragmentAttribute {
6481                                name: "title".into(),
6482                                template: "parent-title-loop".into(),
6483                                attr_start: true,
6484                                ..Default::default()
6485                            },
6486                        )),
6487                    },
6488                    WebUIFragment::raw(">"),
6489                    WebUIFragment::component("parent-component"),
6490                    WebUIFragment::raw("</parent-component>"),
6491                ],
6492
6493                contains_boundary: false,
6494            },
6495        );
6496        fragments.insert(
6497            "parent-title-loop".to_string(),
6498            FragmentList {
6499                fragments: vec![
6500                    WebUIFragment::raw("Parent:"),
6501                    WebUIFragment::signal("who", false),
6502                ],
6503                contains_boundary: false,
6504            },
6505        );
6506        fragments.insert(
6507            "parent-component".to_string(),
6508            FragmentList {
6509                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6510                contains_boundary: false,
6511            },
6512        );
6513        fragments.insert(
6514            "child-loop".to_string(),
6515            FragmentList {
6516                fragments: vec![
6517                    WebUIFragment::raw("<child-component"),
6518                    WebUIFragment {
6519                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6520                            WebUIFragmentAttribute {
6521                                name: "title".into(),
6522                                template: "child-title-loop".into(),
6523                                attr_start: true,
6524                                ..Default::default()
6525                            },
6526                        )),
6527                    },
6528                    WebUIFragment::raw(">"),
6529                    WebUIFragment::component("child-component"),
6530                    WebUIFragment::raw("</child-component>"),
6531                ],
6532
6533                contains_boundary: false,
6534            },
6535        );
6536        fragments.insert(
6537            "child-title-loop".to_string(),
6538            FragmentList {
6539                fragments: vec![
6540                    WebUIFragment::raw("Hi "),
6541                    WebUIFragment::signal("item.name", false),
6542                    WebUIFragment::raw(" / "),
6543                    WebUIFragment::signal("title", false),
6544                ],
6545                contains_boundary: false,
6546            },
6547        );
6548        fragments.insert(
6549            "child-component".to_string(),
6550            FragmentList {
6551                fragments: vec![
6552                    WebUIFragment::raw("<span>"),
6553                    WebUIFragment::signal("title", false),
6554                    WebUIFragment::raw("</span>"),
6555                ],
6556                contains_boundary: false,
6557            },
6558        );
6559        let protocol = WebUIProtocol::new(fragments);
6560        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6561        let mut writer = TestWriter::new();
6562        handle(
6563            &protocol,
6564            &state,
6565            &RenderOptions::new("index.html", "/"),
6566            &mut writer,
6567        )
6568        .unwrap();
6569        assert_eq!(
6570            writer.get_content(),
6571            "<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>"
6572        );
6573    }
6574
6575    #[test]
6576    fn test_multiple_template_attrs() {
6577        let mut fragments = HashMap::new();
6578        fragments.insert(
6579            "index.html".to_string(),
6580            FragmentList {
6581                fragments: vec![
6582                    WebUIFragment::raw("<my-component"),
6583                    WebUIFragment {
6584                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6585                            WebUIFragmentAttribute {
6586                                name: "title".into(),
6587                                template: "attr-title".into(),
6588                                attr_start: true,
6589                                ..Default::default()
6590                            },
6591                        )),
6592                    },
6593                    WebUIFragment {
6594                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6595                            WebUIFragmentAttribute {
6596                                name: "data-title".into(),
6597                                template: "attr-data-title".into(),
6598                                attr_start: false,
6599                                ..Default::default()
6600                            },
6601                        )),
6602                    },
6603                    WebUIFragment {
6604                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6605                            WebUIFragmentAttribute {
6606                                name: "aria-label".into(),
6607                                template: "attr-aria-label".into(),
6608                                attr_start: false,
6609                                ..Default::default()
6610                            },
6611                        )),
6612                    },
6613                    WebUIFragment::raw(">"),
6614                    WebUIFragment::component("my-component"),
6615                    WebUIFragment::raw("</my-component>"),
6616                ],
6617
6618                contains_boundary: false,
6619            },
6620        );
6621        fragments.insert(
6622            "attr-title".to_string(),
6623            FragmentList {
6624                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6625                contains_boundary: false,
6626            },
6627        );
6628        fragments.insert(
6629            "attr-data-title".to_string(),
6630            FragmentList {
6631                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6632                contains_boundary: false,
6633            },
6634        );
6635        fragments.insert(
6636            "attr-aria-label".to_string(),
6637            FragmentList {
6638                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6639                contains_boundary: false,
6640            },
6641        );
6642        fragments.insert(
6643            "my-component".to_string(),
6644            FragmentList {
6645                fragments: vec![
6646                    WebUIFragment::raw("<span>"),
6647                    WebUIFragment::signal("title", false),
6648                    WebUIFragment::raw("|"),
6649                    WebUIFragment::signal("dataTitle", false),
6650                    WebUIFragment::raw("|"),
6651                    WebUIFragment::signal("ariaLabel", false),
6652                    WebUIFragment::raw("</span>"),
6653                ],
6654                contains_boundary: false,
6655            },
6656        );
6657        let protocol = WebUIProtocol::new(fragments);
6658        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6659        let mut writer = TestWriter::new();
6660        handle(
6661            &protocol,
6662            &state,
6663            &RenderOptions::new("index.html", "/"),
6664            &mut writer,
6665        )
6666        .unwrap();
6667        assert_eq!(
6668            writer.get_content(),
6669            "<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>"
6670        );
6671    }
6672
6673    #[test]
6674    fn test_attr_priority_over_global() {
6675        let mut fragments = HashMap::new();
6676        fragments.insert(
6677            "index.html".to_string(),
6678            FragmentList {
6679                fragments: vec![
6680                    WebUIFragment::raw("<my-component"),
6681                    WebUIFragment {
6682                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6683                            WebUIFragmentAttribute {
6684                                name: "title".into(),
6685                                value: "Attribute Title".into(),
6686                                raw_value: true,
6687                                attr_start: true,
6688                                ..Default::default()
6689                            },
6690                        )),
6691                    },
6692                    WebUIFragment::raw(">"),
6693                    WebUIFragment::component("my-component"),
6694                    WebUIFragment::raw("</my-component>"),
6695                ],
6696
6697                contains_boundary: false,
6698            },
6699        );
6700        fragments.insert(
6701            "my-component".to_string(),
6702            FragmentList {
6703                fragments: vec![
6704                    WebUIFragment::raw("<span>"),
6705                    WebUIFragment::signal("title", false),
6706                    WebUIFragment::raw("</span>"),
6707                ],
6708                contains_boundary: false,
6709            },
6710        );
6711        let protocol = WebUIProtocol::new(fragments);
6712        let state = test_json!({"title": "Global Title"});
6713        let mut writer = TestWriter::new();
6714        handle(
6715            &protocol,
6716            &state,
6717            &RenderOptions::new("index.html", "/"),
6718            &mut writer,
6719        )
6720        .unwrap();
6721        assert_eq!(
6722            writer.get_content(),
6723            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6724        );
6725    }
6726
6727    #[test]
6728    fn test_attr_priority_over_local_and_global() {
6729        let mut fragments = HashMap::new();
6730        fragments.insert(
6731            "index.html".to_string(),
6732            FragmentList {
6733                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6734                contains_boundary: false,
6735            },
6736        );
6737        fragments.insert(
6738            "loop".to_string(),
6739            FragmentList {
6740                fragments: vec![
6741                    WebUIFragment::raw("<my-component"),
6742                    WebUIFragment {
6743                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6744                            WebUIFragmentAttribute {
6745                                name: "title".into(),
6746                                value: "Attribute Title".into(),
6747                                raw_value: true,
6748                                attr_start: true,
6749                                ..Default::default()
6750                            },
6751                        )),
6752                    },
6753                    WebUIFragment::raw(">"),
6754                    WebUIFragment::component("my-component"),
6755                    WebUIFragment::raw("</my-component>"),
6756                ],
6757
6758                contains_boundary: false,
6759            },
6760        );
6761        fragments.insert(
6762            "my-component".to_string(),
6763            FragmentList {
6764                fragments: vec![
6765                    WebUIFragment::raw("<span>"),
6766                    WebUIFragment::signal("title", false),
6767                    WebUIFragment::raw("</span>"),
6768                ],
6769                contains_boundary: false,
6770            },
6771        );
6772        let protocol = WebUIProtocol::new(fragments);
6773        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6774        let mut writer = TestWriter::new();
6775        handle(
6776            &protocol,
6777            &state,
6778            &RenderOptions::new("index.html", "/"),
6779            &mut writer,
6780        )
6781        .unwrap();
6782        assert_eq!(
6783            writer.get_content(),
6784            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6785        );
6786    }
6787
6788    #[test]
6789    fn test_boolean_attr_first_component_attr() {
6790        let mut fragments = HashMap::new();
6791        fragments.insert(
6792            "index.html".to_string(),
6793            FragmentList {
6794                fragments: vec![
6795                    WebUIFragment::raw("<my-component"),
6796                    WebUIFragment {
6797                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6798                            WebUIFragmentAttribute {
6799                                name: "disabled".into(),
6800                                attr_start: true,
6801                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6802                                ..Default::default()
6803                            },
6804                        )),
6805                    },
6806                    WebUIFragment {
6807                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6808                            WebUIFragmentAttribute {
6809                                name: "label".into(),
6810                                value: "Component Label".into(),
6811                                raw_value: true,
6812                                attr_start: false,
6813                                ..Default::default()
6814                            },
6815                        )),
6816                    },
6817                    WebUIFragment::raw(">"),
6818                    WebUIFragment::component("my-component"),
6819                    WebUIFragment::raw("</my-component>"),
6820                ],
6821
6822                contains_boundary: false,
6823            },
6824        );
6825        fragments.insert(
6826            "my-component".to_string(),
6827            FragmentList {
6828                fragments: vec![
6829                    WebUIFragment::if_cond(
6830                        ConditionExpr::identifier("disabled"),
6831                        "disabledTemplate",
6832                    ),
6833                    WebUIFragment::raw("<span>"),
6834                    WebUIFragment::signal("label", false),
6835                    WebUIFragment::raw("</span>"),
6836                ],
6837                contains_boundary: false,
6838            },
6839        );
6840        fragments.insert(
6841            "disabledTemplate".to_string(),
6842            FragmentList {
6843                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6844                contains_boundary: false,
6845            },
6846        );
6847        let protocol = WebUIProtocol::new(fragments);
6848        let state = test_json!({"isDisabled": true});
6849        let mut writer = TestWriter::new();
6850        handle(
6851            &protocol,
6852            &state,
6853            &RenderOptions::new("index.html", "/"),
6854            &mut writer,
6855        )
6856        .unwrap();
6857        assert_eq!(
6858            writer.get_content(),
6859            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
6860        );
6861    }
6862
6863    #[test]
6864    fn test_hyphenated_attr_camelcase() {
6865        let mut fragments = HashMap::new();
6866        fragments.insert(
6867            "index.html".to_string(),
6868            FragmentList {
6869                fragments: vec![
6870                    WebUIFragment::raw("<my-component"),
6871                    WebUIFragment {
6872                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6873                            WebUIFragmentAttribute {
6874                                name: "key-hyphen".into(),
6875                                value: "Local Value".into(),
6876                                raw_value: true,
6877                                attr_start: true,
6878                                ..Default::default()
6879                            },
6880                        )),
6881                    },
6882                    WebUIFragment::raw(">"),
6883                    WebUIFragment::component("my-component"),
6884                    WebUIFragment::raw("</my-component>"),
6885                ],
6886
6887                contains_boundary: false,
6888            },
6889        );
6890        fragments.insert(
6891            "my-component".to_string(),
6892            FragmentList {
6893                fragments: vec![
6894                    WebUIFragment::raw("<span>"),
6895                    WebUIFragment::signal("keyHyphen", false),
6896                    WebUIFragment::raw("</span>"),
6897                ],
6898                contains_boundary: false,
6899            },
6900        );
6901        let protocol = WebUIProtocol::new(fragments);
6902        let state = test_json!({"keyHyphen": "Global Value"});
6903        let mut writer = TestWriter::new();
6904        handle(
6905            &protocol,
6906            &state,
6907            &RenderOptions::new("index.html", "/"),
6908            &mut writer,
6909        )
6910        .unwrap();
6911        assert_eq!(
6912            writer.get_content(),
6913            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
6914        );
6915    }
6916
6917    #[test]
6918    fn test_skipped_component_attrs() {
6919        // Skipped attributes: class, style, role, data-*, aria-*
6920        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
6921        // These render on the HTML element but are NOT passed into component attribute state.
6922        let mut fragments = HashMap::new();
6923        fragments.insert(
6924            "index.html".to_string(),
6925            FragmentList {
6926                fragments: vec![
6927                    WebUIFragment::raw("<test-component"),
6928                    // Skipped: class
6929                    WebUIFragment {
6930                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6931                            WebUIFragmentAttribute {
6932                                name: "class".into(),
6933                                value: "skippedClass".into(),
6934                                attr_start: true,
6935                                attr_skip: true,
6936                                ..Default::default()
6937                            },
6938                        )),
6939                    },
6940                    // Skipped: style
6941                    WebUIFragment {
6942                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6943                            WebUIFragmentAttribute {
6944                                name: "style".into(),
6945                                value: "skippedStyle".into(),
6946                                attr_skip: true,
6947                                ..Default::default()
6948                            },
6949                        )),
6950                    },
6951                    // Skipped: role
6952                    WebUIFragment {
6953                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6954                            WebUIFragmentAttribute {
6955                                name: "role".into(),
6956                                value: "skippedRole".into(),
6957                                attr_skip: true,
6958                                ..Default::default()
6959                            },
6960                        )),
6961                    },
6962                    // Skipped: data-testid (data-* prefix)
6963                    WebUIFragment {
6964                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6965                            WebUIFragmentAttribute {
6966                                name: "data-testid".into(),
6967                                value: "skippedDataTestid".into(),
6968                                attr_skip: true,
6969                                ..Default::default()
6970                            },
6971                        )),
6972                    },
6973                    // Skipped: aria-label (aria-* prefix)
6974                    WebUIFragment {
6975                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6976                            WebUIFragmentAttribute {
6977                                name: "aria-label".into(),
6978                                value: "skippedAriaLabel".into(),
6979                                attr_skip: true,
6980                                ..Default::default()
6981                            },
6982                        )),
6983                    },
6984                    // NOT skipped: title
6985                    WebUIFragment {
6986                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6987                            WebUIFragmentAttribute {
6988                                name: "title".into(),
6989                                value: "title".into(),
6990                                ..Default::default()
6991                            },
6992                        )),
6993                    },
6994                    WebUIFragment::raw(">"),
6995                    WebUIFragment::component("test-component"),
6996                    WebUIFragment::raw("</test-component>"),
6997                ],
6998
6999                contains_boundary: false,
7000            },
7001        );
7002        fragments.insert(
7003            "test-component".to_string(),
7004            FragmentList {
7005                fragments: vec![
7006                    WebUIFragment::raw("<span>"),
7007                    WebUIFragment::signal("title", false),
7008                    WebUIFragment::raw("-"),
7009                    WebUIFragment::signal("class", false),
7010                    WebUIFragment::raw("-"),
7011                    WebUIFragment::signal("style", false),
7012                    WebUIFragment::raw("-"),
7013                    WebUIFragment::signal("role", false),
7014                    WebUIFragment::raw("-"),
7015                    WebUIFragment::signal("dataTestid", false),
7016                    WebUIFragment::raw("-"),
7017                    WebUIFragment::signal("ariaLabel", false),
7018                    WebUIFragment::raw("</span>"),
7019                ],
7020                contains_boundary: false,
7021            },
7022        );
7023        let protocol = WebUIProtocol::new(fragments);
7024        let state = test_json!({
7025            "title": "Hello",
7026            "skippedClass": "my-class",
7027            "skippedStyle": "color:red",
7028            "skippedRole": "button",
7029            "skippedDataTestid": "test-id",
7030            "skippedAriaLabel": "label-text"
7031        });
7032        let mut writer = TestWriter::new();
7033        handle(
7034            &protocol,
7035            &state,
7036            &RenderOptions::new("index.html", "/"),
7037            &mut writer,
7038        )
7039        .unwrap();
7040        // Skipped attrs render on the element but their values are NOT accessible inside the component.
7041        // The component's signals for skipped attrs resolve to empty strings.
7042        // Only "title" (non-skipped) is accessible.
7043        assert_eq!(
7044            writer.get_content(),
7045            "<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>"
7046        );
7047    }
7048
7049    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
7050
7051    #[test]
7052    fn test_attr_inherit_parent_to_child() {
7053        let mut fragments = HashMap::new();
7054        fragments.insert(
7055            "index.html".to_string(),
7056            FragmentList {
7057                fragments: vec![
7058                    WebUIFragment::raw("<parent-component"),
7059                    WebUIFragment {
7060                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7061                            WebUIFragmentAttribute {
7062                                name: "title".into(),
7063                                value: "Parent Title".into(),
7064                                raw_value: true,
7065                                attr_start: true,
7066                                ..Default::default()
7067                            },
7068                        )),
7069                    },
7070                    WebUIFragment::raw(">"),
7071                    WebUIFragment::component("parent-component"),
7072                    WebUIFragment::raw("</parent-component>"),
7073                ],
7074
7075                contains_boundary: false,
7076            },
7077        );
7078        fragments.insert(
7079            "parent-component".to_string(),
7080            FragmentList {
7081                fragments: vec![
7082                    WebUIFragment::raw("<h1>"),
7083                    WebUIFragment::signal("title", false),
7084                    WebUIFragment::raw("</h1><child-component"),
7085                    WebUIFragment {
7086                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7087                            WebUIFragmentAttribute {
7088                                name: "title".into(),
7089                                value: "title".into(),
7090                                attr_start: true,
7091                                ..Default::default()
7092                            },
7093                        )),
7094                    },
7095                    WebUIFragment::raw(">"),
7096                    WebUIFragment::component("child-component"),
7097                    WebUIFragment::raw("</child-component>"),
7098                ],
7099
7100                contains_boundary: false,
7101            },
7102        );
7103        fragments.insert(
7104            "child-component".to_string(),
7105            FragmentList {
7106                fragments: vec![
7107                    WebUIFragment::raw("<h2>"),
7108                    WebUIFragment::signal("title", false),
7109                    WebUIFragment::raw("</h2>"),
7110                ],
7111                contains_boundary: false,
7112            },
7113        );
7114        let protocol = WebUIProtocol::new(fragments);
7115        let state = test_json!({});
7116        let mut writer = TestWriter::new();
7117        handle(
7118            &protocol,
7119            &state,
7120            &RenderOptions::new("index.html", "/"),
7121            &mut writer,
7122        )
7123        .unwrap();
7124        assert_eq!(
7125            writer.get_content(),
7126            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7127        );
7128    }
7129
7130    #[test]
7131    fn test_attr_inherit_deep() {
7132        let mut fragments = HashMap::new();
7133        fragments.insert(
7134            "index.html".to_string(),
7135            FragmentList {
7136                fragments: vec![
7137                    WebUIFragment::raw("<parent-component"),
7138                    WebUIFragment {
7139                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7140                            WebUIFragmentAttribute {
7141                                name: "title".into(),
7142                                value: "Parent Title".into(),
7143                                raw_value: true,
7144                                attr_start: true,
7145                                ..Default::default()
7146                            },
7147                        )),
7148                    },
7149                    WebUIFragment::raw(">"),
7150                    WebUIFragment::component("parent-component"),
7151                    WebUIFragment::raw("</parent-component>"),
7152                ],
7153
7154                contains_boundary: false,
7155            },
7156        );
7157        fragments.insert(
7158            "parent-component".to_string(),
7159            FragmentList {
7160                fragments: vec![
7161                    WebUIFragment::raw("<child-component"),
7162                    WebUIFragment {
7163                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7164                            WebUIFragmentAttribute {
7165                                name: "title".into(),
7166                                value: "Child Title".into(),
7167                                raw_value: true,
7168                                attr_start: true,
7169                                ..Default::default()
7170                            },
7171                        )),
7172                    },
7173                    WebUIFragment::raw(">"),
7174                    WebUIFragment::component("child-component"),
7175                    WebUIFragment::raw("</child-component>"),
7176                ],
7177
7178                contains_boundary: false,
7179            },
7180        );
7181        fragments.insert(
7182            "child-component".to_string(),
7183            FragmentList {
7184                fragments: vec![
7185                    WebUIFragment::raw("<grandchild-component"),
7186                    WebUIFragment {
7187                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7188                            WebUIFragmentAttribute {
7189                                name: "title".into(),
7190                                value: "title".into(),
7191                                attr_start: true,
7192                                ..Default::default()
7193                            },
7194                        )),
7195                    },
7196                    WebUIFragment::raw(">"),
7197                    WebUIFragment::component("grandchild-component"),
7198                    WebUIFragment::raw("</grandchild-component>"),
7199                ],
7200
7201                contains_boundary: false,
7202            },
7203        );
7204        fragments.insert(
7205            "grandchild-component".to_string(),
7206            FragmentList {
7207                fragments: vec![
7208                    WebUIFragment::raw("<h3>"),
7209                    WebUIFragment::signal("title", false),
7210                    WebUIFragment::raw("</h3>"),
7211                ],
7212                contains_boundary: false,
7213            },
7214        );
7215        let protocol = WebUIProtocol::new(fragments);
7216        let state = test_json!({});
7217        let mut writer = TestWriter::new();
7218        handle(
7219            &protocol,
7220            &state,
7221            &RenderOptions::new("index.html", "/"),
7222            &mut writer,
7223        )
7224        .unwrap();
7225        assert_eq!(
7226            writer.get_content(),
7227            "<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>"
7228        );
7229    }
7230
7231    #[test]
7232    fn test_complex_attr_access() {
7233        let mut fragments = HashMap::new();
7234        fragments.insert(
7235            "index.html".to_string(),
7236            FragmentList {
7237                fragments: vec![
7238                    WebUIFragment::raw("<my-component"),
7239                    WebUIFragment {
7240                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7241                            WebUIFragmentAttribute {
7242                                name: ":item".into(),
7243                                value: "complexItem".into(),
7244                                attr_start: true,
7245                                complex: true,
7246                                ..Default::default()
7247                            },
7248                        )),
7249                    },
7250                    WebUIFragment::raw(">"),
7251                    WebUIFragment::component("my-component"),
7252                    WebUIFragment::raw("</my-component>"),
7253                ],
7254
7255                contains_boundary: false,
7256            },
7257        );
7258        fragments.insert(
7259            "my-component".to_string(),
7260            FragmentList {
7261                fragments: vec![
7262                    WebUIFragment::raw("<span>"),
7263                    WebUIFragment::signal("item.foo", false),
7264                    WebUIFragment::raw("</span><p>"),
7265                    WebUIFragment::signal("item.bar", false),
7266                    WebUIFragment::raw("</p>"),
7267                ],
7268                contains_boundary: false,
7269            },
7270        );
7271        let protocol = WebUIProtocol::new(fragments);
7272        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7273        let mut writer = TestWriter::new();
7274        handle(
7275            &protocol,
7276            &state,
7277            &RenderOptions::new("index.html", "/"),
7278            &mut writer,
7279        )
7280        .unwrap();
7281        assert_eq!(
7282            writer.get_content(),
7283            "<my-component><span>1</span><p>true</p></my-component>"
7284        );
7285    }
7286
7287    #[test]
7288    fn test_complex_attr_for_loop() {
7289        let mut fragments = HashMap::new();
7290        fragments.insert(
7291            "index.html".to_string(),
7292            FragmentList {
7293                fragments: vec![WebUIFragment::for_loop(
7294                    "item",
7295                    "list.items",
7296                    "listTemplate",
7297                )],
7298                contains_boundary: false,
7299            },
7300        );
7301        fragments.insert(
7302            "listTemplate".to_string(),
7303            FragmentList {
7304                fragments: vec![
7305                    WebUIFragment {
7306                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7307                            WebUIFragmentAttribute {
7308                                name: ":item".into(),
7309                                value: "item".into(),
7310                                attr_start: true,
7311                                complex: true,
7312                                ..Default::default()
7313                            },
7314                        )),
7315                    },
7316                    WebUIFragment::component("item_component"),
7317                ],
7318
7319                contains_boundary: false,
7320            },
7321        );
7322        fragments.insert(
7323            "item_component".to_string(),
7324            FragmentList {
7325                fragments: vec![
7326                    WebUIFragment::raw("<span>"),
7327                    WebUIFragment::signal("item.name", false),
7328                    WebUIFragment::raw("</span>"),
7329                ],
7330                contains_boundary: false,
7331            },
7332        );
7333        let protocol = WebUIProtocol::new(fragments);
7334        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7335        let mut writer = TestWriter::new();
7336        handle(
7337            &protocol,
7338            &state,
7339            &RenderOptions::new("index.html", "/"),
7340            &mut writer,
7341        )
7342        .unwrap();
7343        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7344    }
7345
7346    #[test]
7347    fn test_complex_attr_nested_for() {
7348        let mut fragments = HashMap::new();
7349        fragments.insert(
7350            "index.html".to_string(),
7351            FragmentList {
7352                fragments: vec![WebUIFragment::for_loop(
7353                    "outer",
7354                    "data.outer",
7355                    "outerTemplate",
7356                )],
7357                contains_boundary: false,
7358            },
7359        );
7360        fragments.insert(
7361            "outerTemplate".to_string(),
7362            FragmentList {
7363                fragments: vec![WebUIFragment::for_loop(
7364                    "middle",
7365                    "outer.middle",
7366                    "middleTemplate",
7367                )],
7368                contains_boundary: false,
7369            },
7370        );
7371        fragments.insert(
7372            "middleTemplate".to_string(),
7373            FragmentList {
7374                fragments: vec![WebUIFragment::for_loop(
7375                    "inner",
7376                    "middle.inner",
7377                    "innerTemplate",
7378                )],
7379                contains_boundary: false,
7380            },
7381        );
7382        fragments.insert(
7383            "innerTemplate".to_string(),
7384            FragmentList {
7385                fragments: vec![
7386                    WebUIFragment::raw("<card"),
7387                    WebUIFragment {
7388                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7389                            WebUIFragmentAttribute {
7390                                name: ":outer".into(),
7391                                value: "outer".into(),
7392                                attr_start: true,
7393                                complex: true,
7394                                ..Default::default()
7395                            },
7396                        )),
7397                    },
7398                    WebUIFragment {
7399                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7400                            WebUIFragmentAttribute {
7401                                name: ":middle".into(),
7402                                value: "middle".into(),
7403                                attr_start: false,
7404                                complex: true,
7405                                ..Default::default()
7406                            },
7407                        )),
7408                    },
7409                    WebUIFragment {
7410                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7411                            WebUIFragmentAttribute {
7412                                name: ":inner".into(),
7413                                value: "inner".into(),
7414                                attr_start: false,
7415                                complex: true,
7416                                ..Default::default()
7417                            },
7418                        )),
7419                    },
7420                    WebUIFragment::raw(">"),
7421                    WebUIFragment::component("card_component"),
7422                    WebUIFragment::raw("</card>"),
7423                ],
7424
7425                contains_boundary: false,
7426            },
7427        );
7428        fragments.insert(
7429            "card_component".to_string(),
7430            FragmentList {
7431                fragments: vec![
7432                    WebUIFragment::raw("<p>"),
7433                    WebUIFragment::signal("outer.label", false),
7434                    WebUIFragment::raw(" / "),
7435                    WebUIFragment::signal("middle.label", false),
7436                    WebUIFragment::raw(" / "),
7437                    WebUIFragment::signal("inner.label", false),
7438                    WebUIFragment::raw("</p>"),
7439                ],
7440                contains_boundary: false,
7441            },
7442        );
7443        let protocol = WebUIProtocol::new(fragments);
7444        let state = test_json!({"data": {"outer": [
7445            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7446            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7447        ]}});
7448        let mut writer = TestWriter::new();
7449        handle(
7450            &protocol,
7451            &state,
7452            &RenderOptions::new("index.html", "/"),
7453            &mut writer,
7454        )
7455        .unwrap();
7456        assert_eq!(
7457            writer.get_content(),
7458            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7459        );
7460    }
7461
7462    // ── GROUP 8: Boolean Component State ──────────────────────────────
7463
7464    #[test]
7465    fn test_bool_component_state_true() {
7466        let mut fragments = HashMap::new();
7467        fragments.insert(
7468            "index.html".to_string(),
7469            FragmentList {
7470                fragments: vec![
7471                    WebUIFragment::raw("<my-component"),
7472                    WebUIFragment {
7473                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7474                            WebUIFragmentAttribute {
7475                                name: "disabled".into(),
7476                                attr_start: true,
7477                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7478                                ..Default::default()
7479                            },
7480                        )),
7481                    },
7482                    WebUIFragment::raw(">"),
7483                    WebUIFragment::component("my-component"),
7484                    WebUIFragment::raw("</my-component>"),
7485                ],
7486
7487                contains_boundary: false,
7488            },
7489        );
7490        fragments.insert(
7491            "my-component".to_string(),
7492            FragmentList {
7493                fragments: vec![
7494                    WebUIFragment::if_cond(
7495                        ConditionExpr::identifier("disabled"),
7496                        "disabledTemplate",
7497                    ),
7498                    WebUIFragment::if_cond(
7499                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7500                        "enabledTemplate",
7501                    ),
7502                ],
7503                contains_boundary: false,
7504            },
7505        );
7506        fragments.insert(
7507            "disabledTemplate".to_string(),
7508            FragmentList {
7509                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7510                contains_boundary: false,
7511            },
7512        );
7513        fragments.insert(
7514            "enabledTemplate".to_string(),
7515            FragmentList {
7516                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7517                contains_boundary: false,
7518            },
7519        );
7520        let protocol = WebUIProtocol::new(fragments);
7521        let state = test_json!({"isDisabled": true});
7522        let mut writer = TestWriter::new();
7523        handle(
7524            &protocol,
7525            &state,
7526            &RenderOptions::new("index.html", "/"),
7527            &mut writer,
7528        )
7529        .unwrap();
7530        assert_eq!(
7531            writer.get_content(),
7532            "<my-component disabled><span>Disabled</span></my-component>"
7533        );
7534    }
7535
7536    #[test]
7537    fn test_bool_component_state_false() {
7538        let mut fragments = HashMap::new();
7539        fragments.insert(
7540            "index.html".to_string(),
7541            FragmentList {
7542                fragments: vec![
7543                    WebUIFragment::raw("<my-component"),
7544                    WebUIFragment {
7545                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7546                            WebUIFragmentAttribute {
7547                                name: "disabled".into(),
7548                                attr_start: true,
7549                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7550                                ..Default::default()
7551                            },
7552                        )),
7553                    },
7554                    WebUIFragment::raw(">"),
7555                    WebUIFragment::component("my-component"),
7556                    WebUIFragment::raw("</my-component>"),
7557                ],
7558
7559                contains_boundary: false,
7560            },
7561        );
7562        fragments.insert(
7563            "my-component".to_string(),
7564            FragmentList {
7565                fragments: vec![
7566                    WebUIFragment::if_cond(
7567                        ConditionExpr::identifier("disabled"),
7568                        "disabledTemplate",
7569                    ),
7570                    WebUIFragment::if_cond(
7571                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7572                        "enabledTemplate",
7573                    ),
7574                ],
7575                contains_boundary: false,
7576            },
7577        );
7578        fragments.insert(
7579            "disabledTemplate".to_string(),
7580            FragmentList {
7581                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7582                contains_boundary: false,
7583            },
7584        );
7585        fragments.insert(
7586            "enabledTemplate".to_string(),
7587            FragmentList {
7588                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7589                contains_boundary: false,
7590            },
7591        );
7592        let protocol = WebUIProtocol::new(fragments);
7593        let state = test_json!({"isDisabled": false});
7594        let mut writer = TestWriter::new();
7595        handle(
7596            &protocol,
7597            &state,
7598            &RenderOptions::new("index.html", "/"),
7599            &mut writer,
7600        )
7601        .unwrap();
7602        assert_eq!(
7603            writer.get_content(),
7604            "<my-component><span>Enabled</span></my-component>"
7605        );
7606    }
7607
7608    #[test]
7609    fn test_bool_component_state_forward() {
7610        let mut fragments = HashMap::new();
7611        fragments.insert(
7612            "index.html".to_string(),
7613            FragmentList {
7614                fragments: vec![
7615                    WebUIFragment::raw("<parent-component"),
7616                    WebUIFragment {
7617                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7618                            WebUIFragmentAttribute {
7619                                name: "disabled".into(),
7620                                attr_start: true,
7621                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7622                                ..Default::default()
7623                            },
7624                        )),
7625                    },
7626                    WebUIFragment::raw(">"),
7627                    WebUIFragment::component("parent-component"),
7628                    WebUIFragment::raw("</parent-component>"),
7629                ],
7630
7631                contains_boundary: false,
7632            },
7633        );
7634        fragments.insert(
7635            "parent-component".to_string(),
7636            FragmentList {
7637                fragments: vec![
7638                    WebUIFragment::if_cond(
7639                        ConditionExpr::identifier("disabled"),
7640                        "parentDisabledTemplate",
7641                    ),
7642                    WebUIFragment::raw("<child-component"),
7643                    WebUIFragment {
7644                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7645                            WebUIFragmentAttribute {
7646                                name: "disabled".into(),
7647                                attr_start: true,
7648                                condition_tree: Some(ConditionExpr::identifier("disabled")),
7649                                ..Default::default()
7650                            },
7651                        )),
7652                    },
7653                    WebUIFragment::raw(">"),
7654                    WebUIFragment::component("child-component"),
7655                    WebUIFragment::raw("</child-component>"),
7656                ],
7657
7658                contains_boundary: false,
7659            },
7660        );
7661        fragments.insert(
7662            "parentDisabledTemplate".to_string(),
7663            FragmentList {
7664                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7665                contains_boundary: false,
7666            },
7667        );
7668        fragments.insert(
7669            "child-component".to_string(),
7670            FragmentList {
7671                fragments: vec![
7672                    WebUIFragment::if_cond(
7673                        ConditionExpr::identifier("disabled"),
7674                        "childDisabledTemplate",
7675                    ),
7676                    WebUIFragment::if_cond(
7677                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7678                        "childEnabledTemplate",
7679                    ),
7680                ],
7681                contains_boundary: false,
7682            },
7683        );
7684        fragments.insert(
7685            "childDisabledTemplate".to_string(),
7686            FragmentList {
7687                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7688                contains_boundary: false,
7689            },
7690        );
7691        fragments.insert(
7692            "childEnabledTemplate".to_string(),
7693            FragmentList {
7694                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7695                contains_boundary: false,
7696            },
7697        );
7698
7699        // Test case 1: isDisabled = true
7700        {
7701            let protocol = WebUIProtocol::new(fragments.clone());
7702            let state = test_json!({"isDisabled": true});
7703            let mut writer = TestWriter::new();
7704            handle(
7705                &protocol,
7706                &state,
7707                &RenderOptions::new("index.html", "/"),
7708                &mut writer,
7709            )
7710            .unwrap();
7711            assert_eq!(
7712                writer.get_content(),
7713                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7714            );
7715        }
7716
7717        // Test case 2: isDisabled = false
7718        {
7719            let protocol = WebUIProtocol::new(fragments.clone());
7720            let state = test_json!({"isDisabled": false});
7721            let mut writer = TestWriter::new();
7722            handle(
7723                &protocol,
7724                &state,
7725                &RenderOptions::new("index.html", "/"),
7726                &mut writer,
7727            )
7728            .unwrap();
7729            assert_eq!(
7730                writer.get_content(),
7731                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7732            );
7733        }
7734    }
7735
7736    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
7737
7738    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
7739
7740    // ── Component tests ──────────────────────────────────────────────
7741
7742    #[test]
7743    fn test_component_with_template() {
7744        let mut fragments = HashMap::new();
7745        fragments.insert(
7746            "index.html".to_string(),
7747            FragmentList {
7748                fragments: vec![
7749                    WebUIFragment::raw("<custom-element>"),
7750                    WebUIFragment::component("custom-element"),
7751                    WebUIFragment::raw("</custom-element>"),
7752                ],
7753                contains_boundary: false,
7754            },
7755        );
7756        fragments.insert(
7757            "custom-element".to_string(),
7758            FragmentList {
7759                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7760                contains_boundary: false,
7761            },
7762        );
7763        let protocol = WebUIProtocol::new(fragments);
7764        let state = test_json!({});
7765        let mut writer = TestWriter::new();
7766        handle(
7767            &protocol,
7768            &state,
7769            &RenderOptions::new("index.html", "/"),
7770            &mut writer,
7771        )
7772        .unwrap();
7773        assert_eq!(
7774            writer.get_content(),
7775            "<custom-element><div>Custom Element</div></custom-element>"
7776        );
7777        assert!(writer.is_ended());
7778    }
7779
7780    #[test]
7781    fn test_component_with_slots() {
7782        let mut fragments = HashMap::new();
7783        fragments.insert(
7784            "index.html".to_string(),
7785            FragmentList {
7786                fragments: vec![
7787                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7788                    WebUIFragment::component("custom-element"),
7789                    WebUIFragment::raw("Hello World</custom-element>"),
7790                ],
7791                contains_boundary: false,
7792            },
7793        );
7794        fragments.insert(
7795            "custom-element".to_string(),
7796            FragmentList {
7797                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7798                contains_boundary: false,
7799            },
7800        );
7801        let protocol = WebUIProtocol::new(fragments);
7802        let state = test_json!({});
7803        let mut writer = TestWriter::new();
7804        handle(
7805            &protocol,
7806            &state,
7807            &RenderOptions::new("index.html", "/"),
7808            &mut writer,
7809        )
7810        .unwrap();
7811        assert_eq!(
7812            writer.get_content(),
7813            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7814        );
7815        assert!(writer.is_ended());
7816    }
7817
7818    #[test]
7819    fn test_multiple_nested_components() {
7820        let mut fragments = HashMap::new();
7821        fragments.insert(
7822            "index.html".to_string(),
7823            FragmentList {
7824                fragments: vec![
7825                    WebUIFragment::raw("<div>"),
7826                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
7827                    WebUIFragment::raw("</div>"),
7828                ],
7829                contains_boundary: false,
7830            },
7831        );
7832        fragments.insert(
7833            "custom-button".to_string(),
7834            FragmentList {
7835                fragments: vec![WebUIFragment::raw("<slot></slot>")],
7836                contains_boundary: false,
7837            },
7838        );
7839        fragments.insert(
7840            "custom-element".to_string(),
7841            FragmentList {
7842                fragments: vec![
7843                    WebUIFragment::raw("<custom-child>"),
7844                    WebUIFragment::component("custom-child"),
7845                    WebUIFragment::raw("</custom-child><slot></slot>"),
7846                ],
7847                contains_boundary: false,
7848            },
7849        );
7850        fragments.insert(
7851            "custom-child".to_string(),
7852            FragmentList {
7853                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
7854                contains_boundary: false,
7855            },
7856        );
7857        fragments.insert(
7858            "templateRepeat".to_string(),
7859            FragmentList {
7860                fragments: vec![
7861                    WebUIFragment::raw("<custom-element>"),
7862                    WebUIFragment::component("custom-element"),
7863                    WebUIFragment::raw("<custom-button>"),
7864                    WebUIFragment::component("custom-button"),
7865                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
7866                ],
7867                contains_boundary: false,
7868            },
7869        );
7870        let protocol = WebUIProtocol::new(fragments);
7871        let state = test_json!({"items": [{"name": "Item1"}]});
7872        let mut writer = TestWriter::new();
7873        handle(
7874            &protocol,
7875            &state,
7876            &RenderOptions::new("index.html", "/"),
7877            &mut writer,
7878        )
7879        .unwrap();
7880        assert_eq!(
7881            writer.get_content(),
7882            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
7883        );
7884        assert!(writer.is_ended());
7885    }
7886
7887    // ── Conditional tests ────────────────────────────────────────────
7888
7889    #[test]
7890    fn test_if_with_binary_expression() {
7891        let mut fragments = HashMap::new();
7892        fragments.insert(
7893            "index.html".to_string(),
7894            FragmentList {
7895                fragments: vec![
7896                    WebUIFragment::raw("<div>"),
7897                    WebUIFragment::if_cond(
7898                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
7899                        "if-1",
7900                    ),
7901                    WebUIFragment::raw("</div>"),
7902                ],
7903                contains_boundary: false,
7904            },
7905        );
7906        fragments.insert(
7907            "if-1".to_string(),
7908            FragmentList {
7909                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
7910                contains_boundary: false,
7911            },
7912        );
7913        let protocol = WebUIProtocol::new(fragments);
7914
7915        // True case: x = 10 > 5
7916        let state_true = test_json!({"x": 10});
7917        let mut writer_true = TestWriter::new();
7918        handle(
7919            &protocol,
7920            &state_true,
7921            &RenderOptions::new("index.html", "/"),
7922            &mut writer_true,
7923        )
7924        .unwrap();
7925        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
7926
7927        // False case: x = 1 <= 5
7928        let state_false = test_json!({"x": 1});
7929        let mut writer_false = TestWriter::new();
7930        handle(
7931            &protocol,
7932            &state_false,
7933            &RenderOptions::new("index.html", "/"),
7934            &mut writer_false,
7935        )
7936        .unwrap();
7937        assert_eq!(writer_false.get_content(), "<div></div>");
7938    }
7939
7940    #[test]
7941    fn test_for_if_overlapping_local_state() {
7942        let mut fragments = HashMap::new();
7943        fragments.insert(
7944            "index.html".to_string(),
7945            FragmentList {
7946                fragments: vec![
7947                    WebUIFragment::raw("<div>"),
7948                    WebUIFragment::for_loop("item", "items", "template1"),
7949                    WebUIFragment::raw("</div>"),
7950                ],
7951                contains_boundary: false,
7952            },
7953        );
7954        fragments.insert(
7955            "template1".to_string(),
7956            FragmentList {
7957                fragments: vec![
7958                    WebUIFragment::raw("<div>"),
7959                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
7960                    WebUIFragment::raw("</div>"),
7961                ],
7962                contains_boundary: false,
7963            },
7964        );
7965        fragments.insert(
7966            "ifBlock".to_string(),
7967            FragmentList {
7968                fragments: vec![
7969                    WebUIFragment::raw("<span>"),
7970                    WebUIFragment::signal("item.label", false),
7971                    WebUIFragment::raw("</span>"),
7972                ],
7973                contains_boundary: false,
7974            },
7975        );
7976        let protocol = WebUIProtocol::new(fragments);
7977        let state = test_json!({
7978            "flag": false,
7979            "items": [
7980                {"label": "A", "flag": true},
7981                {"label": "B", "flag": false},
7982                {"label": "C", "flag": true}
7983            ]
7984        });
7985        let mut writer = TestWriter::new();
7986        handle(
7987            &protocol,
7988            &state,
7989            &RenderOptions::new("index.html", "/"),
7990            &mut writer,
7991        )
7992        .unwrap();
7993        assert_eq!(
7994            writer.get_content(),
7995            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
7996        );
7997    }
7998
7999    #[test]
8000    fn test_for_if_global_flag_no_effect() {
8001        let mut fragments = HashMap::new();
8002        fragments.insert(
8003            "index.html".to_string(),
8004            FragmentList {
8005                fragments: vec![
8006                    WebUIFragment::raw("<div>"),
8007                    WebUIFragment::for_loop("item", "items", "template1"),
8008                    WebUIFragment::raw("</div>"),
8009                ],
8010                contains_boundary: false,
8011            },
8012        );
8013        fragments.insert(
8014            "template1".to_string(),
8015            FragmentList {
8016                fragments: vec![
8017                    WebUIFragment::raw("<div>"),
8018                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8019                    WebUIFragment::raw("</div>"),
8020                ],
8021                contains_boundary: false,
8022            },
8023        );
8024        fragments.insert(
8025            "ifBlock".to_string(),
8026            FragmentList {
8027                fragments: vec![
8028                    WebUIFragment::raw("<span>"),
8029                    WebUIFragment::signal("item.label", false),
8030                    WebUIFragment::raw("</span>"),
8031                ],
8032                contains_boundary: false,
8033            },
8034        );
8035        let protocol = WebUIProtocol::new(fragments);
8036        let state = test_json!({
8037            "item": {"flag": true},
8038            "items": [
8039                {"label": "A", "flag": false},
8040                {"label": "B", "flag": true},
8041                {"label": "C", "flag": false}
8042            ]
8043        });
8044        let mut writer = TestWriter::new();
8045        handle(
8046            &protocol,
8047            &state,
8048            &RenderOptions::new("index.html", "/"),
8049            &mut writer,
8050        )
8051        .unwrap();
8052        assert_eq!(
8053            writer.get_content(),
8054            "<div><div></div><div><span>B</span></div><div></div></div>"
8055        );
8056    }
8057
8058    // ── Recursive template test ──────────────────────────────────────
8059
8060    #[test]
8061    fn test_recursive_template_refs() {
8062        let mut fragments = HashMap::new();
8063        fragments.insert(
8064            "index.html".to_string(),
8065            FragmentList {
8066                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8067                contains_boundary: false,
8068            },
8069        );
8070        fragments.insert(
8071            "static".to_string(),
8072            FragmentList {
8073                fragments: vec![
8074                    WebUIFragment::raw("<div expanded=\""),
8075                    WebUIFragment::signal("item.expanded", false),
8076                    WebUIFragment::raw("\" class=\""),
8077                    WebUIFragment::signal("testScenario", false),
8078                    WebUIFragment::raw("\"><span>"),
8079                    WebUIFragment::signal("item.name", false),
8080                    WebUIFragment::raw("</span>"),
8081                    WebUIFragment::for_loop("item", "item.children", "static"),
8082                    WebUIFragment::raw("</div>"),
8083                ],
8084                contains_boundary: false,
8085            },
8086        );
8087        let protocol = WebUIProtocol::new(fragments);
8088        let state = test_json!({
8089            "testScenario": "RecursiveTemplatesWithGlobalState",
8090            "items": [
8091                {"name": "A", "expanded": "false", "children": []},
8092                {"name": "B", "expanded": "true", "children": [
8093                    {"name": "C", "expanded": "false"},
8094                    {"name": "D", "expanded": "false"}
8095                ]},
8096                {"name": "E", "expanded": "false"}
8097            ]
8098        });
8099        let mut writer = TestWriter::new();
8100        handle(
8101            &protocol,
8102            &state,
8103            &RenderOptions::new("index.html", "/"),
8104            &mut writer,
8105        )
8106        .unwrap();
8107        assert_eq!(
8108            writer.get_content(),
8109            "<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>"
8110        );
8111    }
8112
8113    // ── Advanced state management tests ──────────────────────────────
8114
8115    #[test]
8116    fn test_component_in_for_no_local_access() {
8117        let mut fragments = HashMap::new();
8118        fragments.insert(
8119            "index.html".to_string(),
8120            FragmentList {
8121                fragments: vec![
8122                    WebUIFragment::raw("<div>"),
8123                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8124                    WebUIFragment::raw("</div>"),
8125                ],
8126                contains_boundary: false,
8127            },
8128        );
8129        fragments.insert(
8130            "templateComponent".to_string(),
8131            FragmentList {
8132                fragments: vec![
8133                    WebUIFragment::raw("<component-tag>"),
8134                    WebUIFragment::component("my-component"),
8135                    WebUIFragment::raw("</component-tag>"),
8136                ],
8137                contains_boundary: false,
8138            },
8139        );
8140        fragments.insert(
8141            "my-component".to_string(),
8142            FragmentList {
8143                fragments: vec![
8144                    WebUIFragment::raw("<span>"),
8145                    WebUIFragment::signal("name", false),
8146                    WebUIFragment::raw("</span>"),
8147                ],
8148                contains_boundary: false,
8149            },
8150        );
8151        let protocol = WebUIProtocol::new(fragments);
8152        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8153        let mut writer = TestWriter::new();
8154        handle(
8155            &protocol,
8156            &state,
8157            &RenderOptions::new("index.html", "/"),
8158            &mut writer,
8159        )
8160        .unwrap();
8161        assert_eq!(
8162            writer.get_content(),
8163            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8164        );
8165    }
8166
8167    #[test]
8168    fn test_nested_for_hierarchical_state() {
8169        let mut fragments = HashMap::new();
8170        fragments.insert(
8171            "index.html".to_string(),
8172            FragmentList {
8173                fragments: vec![
8174                    WebUIFragment::raw("<div>"),
8175                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8176                    WebUIFragment::raw("</div>"),
8177                ],
8178                contains_boundary: false,
8179            },
8180        );
8181        fragments.insert(
8182            "outerTemplate".to_string(),
8183            FragmentList {
8184                fragments: vec![
8185                    WebUIFragment::raw("<section>"),
8186                    WebUIFragment::signal("globalPrefix", false),
8187                    WebUIFragment::signal("outerItem.outerLabel", false),
8188                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8189                    WebUIFragment::raw("</section>"),
8190                ],
8191                contains_boundary: false,
8192            },
8193        );
8194        fragments.insert(
8195            "innerTemplate".to_string(),
8196            FragmentList {
8197                fragments: vec![
8198                    WebUIFragment::raw("<p>"),
8199                    WebUIFragment::signal("globalPrefix", false),
8200                    WebUIFragment::signal("outerItem.outerLabel", false),
8201                    WebUIFragment::raw(": "),
8202                    WebUIFragment::signal("innerItem.innerLabel", false),
8203                    WebUIFragment::raw("</p>"),
8204                ],
8205                contains_boundary: false,
8206            },
8207        );
8208        let protocol = WebUIProtocol::new(fragments);
8209        let state = test_json!({
8210            "globalPrefix": "Prefix: ",
8211            "outerItems": [
8212                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8213                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8214            ]
8215        });
8216        let mut writer = TestWriter::new();
8217        handle(
8218            &protocol,
8219            &state,
8220            &RenderOptions::new("index.html", "/"),
8221            &mut writer,
8222        )
8223        .unwrap();
8224        assert_eq!(
8225            writer.get_content(),
8226            "<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>"
8227        );
8228    }
8229
8230    #[test]
8231    fn test_component_in_for_global_only() {
8232        let mut fragments = HashMap::new();
8233        fragments.insert(
8234            "index.html".to_string(),
8235            FragmentList {
8236                fragments: vec![
8237                    WebUIFragment::raw("<div>"),
8238                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8239                    WebUIFragment::raw("</div>"),
8240                ],
8241                contains_boundary: false,
8242            },
8243        );
8244        fragments.insert(
8245            "templateComponent".to_string(),
8246            FragmentList {
8247                fragments: vec![
8248                    WebUIFragment::raw("<component-tag>"),
8249                    WebUIFragment::component("my-component"),
8250                    WebUIFragment::raw("</component-tag>"),
8251                ],
8252                contains_boundary: false,
8253            },
8254        );
8255        fragments.insert(
8256            "my-component".to_string(),
8257            FragmentList {
8258                fragments: vec![
8259                    WebUIFragment::raw("<span>"),
8260                    WebUIFragment::signal("name", false),
8261                    WebUIFragment::raw("-"),
8262                    WebUIFragment::signal("globalSuffix", false),
8263                    WebUIFragment::raw("</span>"),
8264                ],
8265                contains_boundary: false,
8266            },
8267        );
8268        let protocol = WebUIProtocol::new(fragments);
8269        let state =
8270            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8271        let mut writer = TestWriter::new();
8272        handle(
8273            &protocol,
8274            &state,
8275            &RenderOptions::new("index.html", "/"),
8276            &mut writer,
8277        )
8278        .unwrap();
8279        assert_eq!(
8280            writer.get_content(),
8281            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8282        );
8283    }
8284
8285    #[test]
8286    fn test_component_no_item_moniker() {
8287        let mut fragments = HashMap::new();
8288        fragments.insert(
8289            "index.html".to_string(),
8290            FragmentList {
8291                fragments: vec![
8292                    WebUIFragment::raw("<div>"),
8293                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8294                    WebUIFragment::raw("</div>"),
8295                ],
8296                contains_boundary: false,
8297            },
8298        );
8299        fragments.insert(
8300            "templateComponent".to_string(),
8301            FragmentList {
8302                fragments: vec![
8303                    WebUIFragment::raw("<component-tag>"),
8304                    WebUIFragment::component("my-component"),
8305                    WebUIFragment::raw("</component-tag>"),
8306                ],
8307                contains_boundary: false,
8308            },
8309        );
8310        fragments.insert(
8311            "my-component".to_string(),
8312            FragmentList {
8313                fragments: vec![
8314                    WebUIFragment::raw("<span>"),
8315                    WebUIFragment::signal("item.name", false),
8316                    WebUIFragment::raw("-"),
8317                    WebUIFragment::signal("globalSuffix", false),
8318                    WebUIFragment::raw("</span>"),
8319                ],
8320                contains_boundary: false,
8321            },
8322        );
8323        let protocol = WebUIProtocol::new(fragments);
8324        let state =
8325            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8326        let mut writer = TestWriter::new();
8327        handle(
8328            &protocol,
8329            &state,
8330            &RenderOptions::new("index.html", "/"),
8331            &mut writer,
8332        )
8333        .unwrap();
8334        assert_eq!(
8335            writer.get_content(),
8336            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8337        );
8338    }
8339
8340    #[test]
8341    fn test_for_nonqualified_uses_global() {
8342        let mut fragments = HashMap::new();
8343        fragments.insert(
8344            "index.html".to_string(),
8345            FragmentList {
8346                fragments: vec![
8347                    WebUIFragment::raw("<div>"),
8348                    WebUIFragment::for_loop("item", "items", "template1"),
8349                    WebUIFragment::raw("</div>"),
8350                ],
8351                contains_boundary: false,
8352            },
8353        );
8354        fragments.insert(
8355            "template1".to_string(),
8356            FragmentList {
8357                fragments: vec![
8358                    WebUIFragment::raw("<span>"),
8359                    WebUIFragment::signal("name", false),
8360                    WebUIFragment::raw("</span>"),
8361                ],
8362                contains_boundary: false,
8363            },
8364        );
8365        let protocol = WebUIProtocol::new(fragments);
8366        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8367        let mut writer = TestWriter::new();
8368        handle(
8369            &protocol,
8370            &state,
8371            &RenderOptions::new("index.html", "/"),
8372            &mut writer,
8373        )
8374        .unwrap();
8375        assert_eq!(
8376            writer.get_content(),
8377            "<div><span>GlobalName</span><span>GlobalName</span></div>"
8378        );
8379    }
8380
8381    #[test]
8382    fn test_nested_for_if_interleaved() {
8383        let mut fragments = HashMap::new();
8384        fragments.insert(
8385            "index.html".to_string(),
8386            FragmentList {
8387                fragments: vec![
8388                    WebUIFragment::raw("<div>"),
8389                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8390                    WebUIFragment::raw("</div>"),
8391                ],
8392                contains_boundary: false,
8393            },
8394        );
8395        fragments.insert(
8396            "outerTemplate".to_string(),
8397            FragmentList {
8398                fragments: vec![
8399                    WebUIFragment::raw("<section>"),
8400                    WebUIFragment::signal("globalPrefix", false),
8401                    WebUIFragment::signal("outerItem.outerLabel", false),
8402                    WebUIFragment::if_cond(
8403                        ConditionExpr::identifier("outerItem.include"),
8404                        "ifTemplate",
8405                    ),
8406                    WebUIFragment::raw("</section>"),
8407                ],
8408                contains_boundary: false,
8409            },
8410        );
8411        fragments.insert(
8412            "ifTemplate".to_string(),
8413            FragmentList {
8414                fragments: vec![
8415                    WebUIFragment::raw("<div>"),
8416                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8417                    WebUIFragment::raw("</div>"),
8418                ],
8419                contains_boundary: false,
8420            },
8421        );
8422        fragments.insert(
8423            "innerTemplate".to_string(),
8424            FragmentList {
8425                fragments: vec![
8426                    WebUIFragment::raw("<p>"),
8427                    WebUIFragment::signal("globalSuffix", false),
8428                    WebUIFragment::raw(": "),
8429                    WebUIFragment::signal("innerItem.innerLabel", false),
8430                    WebUIFragment::raw("</p>"),
8431                ],
8432                contains_boundary: false,
8433            },
8434        );
8435        let protocol = WebUIProtocol::new(fragments);
8436        let state = test_json!({
8437            "globalPrefix": "Prefix: ",
8438            "globalSuffix": "Suffix",
8439            "outerItems": [
8440                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8441                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8442                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8443            ]
8444        });
8445        let mut writer = TestWriter::new();
8446        handle(
8447            &protocol,
8448            &state,
8449            &RenderOptions::new("index.html", "/"),
8450            &mut writer,
8451        )
8452        .unwrap();
8453        assert_eq!(
8454            writer.get_content(),
8455            "<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>"
8456        );
8457    }
8458
8459    #[test]
8460    fn test_nested_for_if_outer_state() {
8461        let mut fragments = HashMap::new();
8462        fragments.insert(
8463            "index.html".to_string(),
8464            FragmentList {
8465                fragments: vec![
8466                    WebUIFragment::raw("<div>"),
8467                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8468                    WebUIFragment::raw("</div>"),
8469                ],
8470                contains_boundary: false,
8471            },
8472        );
8473        fragments.insert(
8474            "outerTemplate".to_string(),
8475            FragmentList {
8476                fragments: vec![
8477                    WebUIFragment::raw("<section>"),
8478                    WebUIFragment::signal("globalPrefix", false),
8479                    WebUIFragment::signal("outerItem.label", false),
8480                    WebUIFragment::for_loop(
8481                        "middleItem",
8482                        "outerItem.middleItems",
8483                        "middleTemplate",
8484                    ),
8485                    WebUIFragment::raw("</section>"),
8486                ],
8487                contains_boundary: false,
8488            },
8489        );
8490        fragments.insert(
8491            "middleTemplate".to_string(),
8492            FragmentList {
8493                fragments: vec![
8494                    WebUIFragment::raw("<div>"),
8495                    WebUIFragment::if_cond(
8496                        ConditionExpr::identifier("outerItem.active"),
8497                        "ifTemplate",
8498                    ),
8499                    WebUIFragment::raw("</div>"),
8500                ],
8501                contains_boundary: false,
8502            },
8503        );
8504        fragments.insert(
8505            "ifTemplate".to_string(),
8506            FragmentList {
8507                fragments: vec![
8508                    WebUIFragment::raw("<p>"),
8509                    WebUIFragment::signal("middleItem.value", false),
8510                    WebUIFragment::raw("</p>"),
8511                ],
8512                contains_boundary: false,
8513            },
8514        );
8515        let protocol = WebUIProtocol::new(fragments);
8516        let state = test_json!({
8517            "globalPrefix": "GP-",
8518            "outerItems": [
8519                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8520                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8521                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8522            ]
8523        });
8524        let mut writer = TestWriter::new();
8525        handle(
8526            &protocol,
8527            &state,
8528            &RenderOptions::new("index.html", "/"),
8529            &mut writer,
8530        )
8531        .unwrap();
8532        assert_eq!(
8533            writer.get_content(),
8534            "<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>"
8535        );
8536    }
8537
8538    #[test]
8539    fn test_nested_for_if_inner_state() {
8540        let mut fragments = HashMap::new();
8541        fragments.insert(
8542            "index.html".to_string(),
8543            FragmentList {
8544                fragments: vec![
8545                    WebUIFragment::raw("<div>"),
8546                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8547                    WebUIFragment::raw("</div>"),
8548                ],
8549                contains_boundary: false,
8550            },
8551        );
8552        fragments.insert(
8553            "outerTemplate".to_string(),
8554            FragmentList {
8555                fragments: vec![
8556                    WebUIFragment::raw("<section>"),
8557                    WebUIFragment::signal("outerItem.label", false),
8558                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8559                    WebUIFragment::raw("</section>"),
8560                ],
8561                contains_boundary: false,
8562            },
8563        );
8564        fragments.insert(
8565            "innerTemplate".to_string(),
8566            FragmentList {
8567                fragments: vec![
8568                    WebUIFragment::raw("<article>"),
8569                    WebUIFragment::if_cond(
8570                        ConditionExpr::identifier("innerItem.show"),
8571                        "ifTemplate",
8572                    ),
8573                    WebUIFragment::raw("</article>"),
8574                ],
8575                contains_boundary: false,
8576            },
8577        );
8578        fragments.insert(
8579            "ifTemplate".to_string(),
8580            FragmentList {
8581                fragments: vec![
8582                    WebUIFragment::raw("<p>"),
8583                    WebUIFragment::signal("innerItem.detail", false),
8584                    WebUIFragment::raw("</p>"),
8585                ],
8586                contains_boundary: false,
8587            },
8588        );
8589        let protocol = WebUIProtocol::new(fragments);
8590        let state = test_json!({
8591            "outerItems": [
8592                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8593                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
8594            ]
8595        });
8596        let mut writer = TestWriter::new();
8597        handle(
8598            &protocol,
8599            &state,
8600            &RenderOptions::new("index.html", "/"),
8601            &mut writer,
8602        )
8603        .unwrap();
8604        assert_eq!(
8605            writer.get_content(),
8606            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8607        );
8608    }
8609
8610    #[test]
8611    fn test_for_merge_local_global_monikers() {
8612        let mut fragments = HashMap::new();
8613        fragments.insert(
8614            "index.html".to_string(),
8615            FragmentList {
8616                fragments: vec![
8617                    WebUIFragment::raw("<div>"),
8618                    WebUIFragment::for_loop("item", "items", "template1"),
8619                    WebUIFragment::raw("</div>"),
8620                ],
8621                contains_boundary: false,
8622            },
8623        );
8624        fragments.insert(
8625            "template1".to_string(),
8626            FragmentList {
8627                fragments: vec![
8628                    WebUIFragment::raw("<span>"),
8629                    WebUIFragment::signal("item.name", false),
8630                    WebUIFragment::raw("-"),
8631                    WebUIFragment::signal("item.globalValue", false),
8632                    WebUIFragment::raw("-"),
8633                    WebUIFragment::signal("item.localOnly", false),
8634                    WebUIFragment::raw("-"),
8635                    WebUIFragment::signal("item.otherVal", false),
8636                    WebUIFragment::raw("</span>"),
8637                ],
8638                contains_boundary: false,
8639            },
8640        );
8641        let protocol = WebUIProtocol::new(fragments);
8642        let state = test_json!({
8643            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8644            "items": [
8645                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8646                {"name": "Local2", "localOnly": "Only2"}
8647            ]
8648        });
8649        let mut writer = TestWriter::new();
8650        handle(
8651            &protocol,
8652            &state,
8653            &RenderOptions::new("index.html", "/"),
8654            &mut writer,
8655        )
8656        .unwrap();
8657        assert_eq!(
8658            writer.get_content(),
8659            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8660        );
8661    }
8662
8663    #[test]
8664    fn test_component_in_for_global_moniker_shadow() {
8665        let mut fragments = HashMap::new();
8666        fragments.insert(
8667            "index.html".to_string(),
8668            FragmentList {
8669                fragments: vec![
8670                    WebUIFragment::raw("<div>"),
8671                    WebUIFragment::for_loop("item", "items", "templateComponent"),
8672                    WebUIFragment::raw("</div>"),
8673                ],
8674                contains_boundary: false,
8675            },
8676        );
8677        fragments.insert(
8678            "templateComponent".to_string(),
8679            FragmentList {
8680                fragments: vec![
8681                    WebUIFragment::raw("<component-tag>"),
8682                    WebUIFragment::component("my-component"),
8683                    WebUIFragment::raw("</component-tag>"),
8684                ],
8685                contains_boundary: false,
8686            },
8687        );
8688        fragments.insert(
8689            "my-component".to_string(),
8690            FragmentList {
8691                fragments: vec![
8692                    WebUIFragment::raw("<span>"),
8693                    WebUIFragment::signal("name", false),
8694                    WebUIFragment::raw("-"),
8695                    WebUIFragment::signal("item.globalValue", false),
8696                    WebUIFragment::raw("-"),
8697                    WebUIFragment::signal("localOnly", false),
8698                    WebUIFragment::raw("-"),
8699                    WebUIFragment::signal("item.otherVal", false),
8700                    WebUIFragment::raw("</span>"),
8701                ],
8702                contains_boundary: false,
8703            },
8704        );
8705        let protocol = WebUIProtocol::new(fragments);
8706        let state = test_json!({
8707            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8708            "items": [
8709                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8710                {"name": "Local2", "localOnly": "Only2"}
8711            ]
8712        });
8713        let mut writer = TestWriter::new();
8714        handle(
8715            &protocol,
8716            &state,
8717            &RenderOptions::new("index.html", "/"),
8718            &mut writer,
8719        )
8720        .unwrap();
8721        assert_eq!(
8722            writer.get_content(),
8723            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8724        );
8725    }
8726
8727    #[test]
8728    fn test_if_in_nested_for_local_flag() {
8729        let mut fragments = HashMap::new();
8730        fragments.insert(
8731            "index.html".to_string(),
8732            FragmentList {
8733                fragments: vec![
8734                    WebUIFragment::raw("<div>"),
8735                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8736                    WebUIFragment::raw("</div>"),
8737                ],
8738                contains_boundary: false,
8739            },
8740        );
8741        fragments.insert(
8742            "outerTemplate".to_string(),
8743            FragmentList {
8744                fragments: vec![
8745                    WebUIFragment::raw("<section>"),
8746                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8747                    WebUIFragment::raw("</section>"),
8748                ],
8749                contains_boundary: false,
8750            },
8751        );
8752        fragments.insert(
8753            "innerTemplate".to_string(),
8754            FragmentList {
8755                fragments: vec![WebUIFragment::if_cond(
8756                    ConditionExpr::identifier("inner_item.flag"),
8757                    "ifInner",
8758                )],
8759                contains_boundary: false,
8760            },
8761        );
8762        fragments.insert(
8763            "ifInner".to_string(),
8764            FragmentList {
8765                fragments: vec![
8766                    WebUIFragment::raw("<p>"),
8767                    WebUIFragment::signal("inner_item.value", false),
8768                    WebUIFragment::raw("</p>"),
8769                ],
8770                contains_boundary: false,
8771            },
8772        );
8773        let protocol = WebUIProtocol::new(fragments);
8774        let state = test_json!({
8775            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8776            "inner_item": {"flag": false}
8777        });
8778        let mut writer = TestWriter::new();
8779        handle(
8780            &protocol,
8781            &state,
8782            &RenderOptions::new("index.html", "/"),
8783            &mut writer,
8784        )
8785        .unwrap();
8786        assert_eq!(
8787            writer.get_content(),
8788            "<div><section><p>X</p></section></div>"
8789        );
8790    }
8791
8792    #[test]
8793    fn test_if_in_nested_for_global_fallback() {
8794        let mut fragments = HashMap::new();
8795        fragments.insert(
8796            "index.html".to_string(),
8797            FragmentList {
8798                fragments: vec![
8799                    WebUIFragment::raw("<div>"),
8800                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8801                    WebUIFragment::raw("</div>"),
8802                ],
8803                contains_boundary: false,
8804            },
8805        );
8806        fragments.insert(
8807            "outerTemplate".to_string(),
8808            FragmentList {
8809                fragments: vec![
8810                    WebUIFragment::raw("<section>"),
8811                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8812                    WebUIFragment::raw("</section>"),
8813                ],
8814                contains_boundary: false,
8815            },
8816        );
8817        fragments.insert(
8818            "innerTemplate".to_string(),
8819            FragmentList {
8820                fragments: vec![WebUIFragment::if_cond(
8821                    ConditionExpr::identifier("inner_item.flag"),
8822                    "ifInner",
8823                )],
8824                contains_boundary: false,
8825            },
8826        );
8827        fragments.insert(
8828            "ifInner".to_string(),
8829            FragmentList {
8830                fragments: vec![
8831                    WebUIFragment::raw("<p>"),
8832                    WebUIFragment::signal("inner_item.value", false),
8833                    WebUIFragment::raw("</p>"),
8834                ],
8835                contains_boundary: false,
8836            },
8837        );
8838        let protocol = WebUIProtocol::new(fragments);
8839        let state = test_json!({
8840            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8841            "inner_item": {"flag": true}
8842        });
8843        let mut writer = TestWriter::new();
8844        handle(
8845            &protocol,
8846            &state,
8847            &RenderOptions::new("index.html", "/"),
8848            &mut writer,
8849        )
8850        .unwrap();
8851        assert_eq!(
8852            writer.get_content(),
8853            "<div><section><p>X</p><p>Y</p></section></div>"
8854        );
8855    }
8856
8857    #[test]
8858    fn test_if_mixed_for_monikers() {
8859        let mut fragments = HashMap::new();
8860        fragments.insert(
8861            "index.html".to_string(),
8862            FragmentList {
8863                fragments: vec![
8864                    WebUIFragment::raw("<div>"),
8865                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
8866                    WebUIFragment::raw("</div>"),
8867                ],
8868                contains_boundary: false,
8869            },
8870        );
8871        fragments.insert(
8872            "outerTemplate".to_string(),
8873            FragmentList {
8874                fragments: vec![
8875                    WebUIFragment::raw("<section>"),
8876                    WebUIFragment::signal("outer.outerLabel", false),
8877                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
8878                    WebUIFragment::raw("</section>"),
8879                ],
8880                contains_boundary: false,
8881            },
8882        );
8883        fragments.insert(
8884            "innerTemplate".to_string(),
8885            FragmentList {
8886                fragments: vec![WebUIFragment::if_cond(
8887                    ConditionExpr::compound(
8888                        ConditionExpr::identifier("outer.active"),
8889                        LogicalOperator::And,
8890                        ConditionExpr::predicate(
8891                            "inner.value",
8892                            ComparisonOperator::GreaterThan,
8893                            "globalLimit",
8894                        ),
8895                    ),
8896                    "ifInner",
8897                )],
8898                contains_boundary: false,
8899            },
8900        );
8901        fragments.insert(
8902            "ifInner".to_string(),
8903            FragmentList {
8904                fragments: vec![
8905                    WebUIFragment::raw("<p>"),
8906                    WebUIFragment::signal("inner.value", false),
8907                    WebUIFragment::raw("</p>"),
8908                ],
8909                contains_boundary: false,
8910            },
8911        );
8912        let protocol = WebUIProtocol::new(fragments);
8913        let state = test_json!({
8914            "globalLimit": 10,
8915            "list": {"outerItems": [
8916                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
8917                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
8918                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
8919            ]}
8920        });
8921        let mut writer = TestWriter::new();
8922        handle(
8923            &protocol,
8924            &state,
8925            &RenderOptions::new("index.html", "/"),
8926            &mut writer,
8927        )
8928        .unwrap();
8929        assert_eq!(
8930            writer.get_content(),
8931            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
8932        );
8933    }
8934
8935    // ── Route-aware rendering tests ─────────────────────────────────────
8936
8937    fn make_route_protocol() -> WebUIProtocol {
8938        use webui_protocol::WebUiFragmentRoute;
8939
8940        let mut fragments = HashMap::new();
8941
8942        // Entry page with two routes
8943        fragments.insert(
8944            "index.html".to_string(),
8945            FragmentList {
8946                fragments: vec![
8947                    WebUIFragment::raw("<h1>Shell</h1>"),
8948                    WebUIFragment::route_from(WebUiFragmentRoute {
8949                        path: "/".into(),
8950                        fragment_id: "dash-page".into(),
8951                        exact: true,
8952                        keep_alive: false,
8953                        ..Default::default()
8954                    }),
8955                    WebUIFragment::route_from(WebUiFragmentRoute {
8956                        path: "/contacts/:id".into(),
8957                        fragment_id: "detail-page".into(),
8958                        exact: true,
8959                        keep_alive: true,
8960                        ..Default::default()
8961                    }),
8962                ],
8963
8964                contains_boundary: false,
8965            },
8966        );
8967
8968        // Dashboard page component
8969        fragments.insert(
8970            "dash-page".to_string(),
8971            FragmentList {
8972                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
8973                contains_boundary: false,
8974            },
8975        );
8976
8977        // Detail page component
8978        fragments.insert(
8979            "detail-page".to_string(),
8980            FragmentList {
8981                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
8982                contains_boundary: false,
8983            },
8984        );
8985
8986        WebUIProtocol::new(fragments)
8987    }
8988
8989    fn make_nested_route_protocol() -> WebUIProtocol {
8990        use webui_protocol::WebUiFragmentRoute;
8991
8992        let mut fragments = HashMap::new();
8993
8994        fragments.insert(
8995            "index.html".to_string(),
8996            FragmentList {
8997                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8998                    path: "/".into(),
8999                    fragment_id: "app-shell".into(),
9000                    exact: false,
9001                    children: vec![WebUiFragmentRoute {
9002                        path: "sections/:id".into(),
9003                        fragment_id: "section-comp".into(),
9004                        exact: false,
9005                        children: vec![WebUiFragmentRoute {
9006                            path: "topics/:topicId".into(),
9007                            fragment_id: "topic-comp".into(),
9008                            exact: true,
9009                            children: vec![],
9010                            keep_alive: false,
9011                            ..Default::default()
9012                        }],
9013                        keep_alive: false,
9014                        ..Default::default()
9015                    }],
9016                    keep_alive: false,
9017                    ..Default::default()
9018                })],
9019
9020                contains_boundary: false,
9021            },
9022        );
9023
9024        fragments.insert(
9025            "app-shell".to_string(),
9026            FragmentList {
9027                fragments: vec![
9028                    WebUIFragment::raw("<h1>Shell</h1>"),
9029                    WebUIFragment::outlet(),
9030                ],
9031                contains_boundary: false,
9032            },
9033        );
9034
9035        fragments.insert(
9036            "section-comp".to_string(),
9037            FragmentList {
9038                fragments: vec![
9039                    WebUIFragment::raw("<h2>Section</h2>"),
9040                    WebUIFragment::outlet(),
9041                ],
9042                contains_boundary: false,
9043            },
9044        );
9045
9046        fragments.insert(
9047            "topic-comp".to_string(),
9048            FragmentList {
9049                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9050                contains_boundary: false,
9051            },
9052        );
9053
9054        WebUIProtocol::new(fragments)
9055    }
9056
9057    #[test]
9058    fn test_route_renders_shell_always() {
9059        let protocol = make_route_protocol();
9060        let state = test_json!({});
9061        let mut writer = TestWriter::new();
9062        handle(
9063            &protocol,
9064            &state,
9065            &RenderOptions::new("index.html", "/"),
9066            &mut writer,
9067        )
9068        .unwrap();
9069        let html = writer.get_content();
9070
9071        // Shell content always renders regardless of route matching
9072        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9073        // Dashboard matches "/" so it should be active
9074        assert!(html.contains(" active>"), "matched route should be active");
9075        // Detail should be hidden and empty
9076        assert!(
9077            html.contains("style=\"display:none\""),
9078            "non-matched routes should be hidden"
9079        );
9080    }
9081
9082    #[test]
9083    fn test_route_matched_renders_visible() {
9084        let protocol = make_route_protocol();
9085        let state = test_json!({});
9086        let mut writer = TestWriter::new();
9087        handle(
9088            &protocol,
9089            &state,
9090            &RenderOptions::new("index.html", "/"),
9091            &mut writer,
9092        )
9093        .unwrap();
9094        let html = writer.get_content();
9095
9096        // Dashboard route should be visible (active, no display:none)
9097        assert!(
9098            html.contains("<webui-route path=\"/\""),
9099            "dashboard route should exist"
9100        );
9101        assert!(
9102            html.contains("active>") && html.contains("<dash-page"),
9103            "matched route should be active with component tag: {html}"
9104        );
9105        assert!(
9106            html.contains("<p>Dashboard</p>"),
9107            "matched route should have content"
9108        );
9109    }
9110
9111    #[test]
9112    fn test_route_non_matched_renders_hidden_empty() {
9113        let protocol = make_route_protocol();
9114        let state = test_json!({});
9115        let mut writer = TestWriter::new();
9116        handle(
9117            &protocol,
9118            &state,
9119            &RenderOptions::new("index.html", "/"),
9120            &mut writer,
9121        )
9122        .unwrap();
9123        let html = writer.get_content();
9124
9125        // Detail route should be hidden and empty (no content rendered)
9126        assert!(
9127            html.contains("<webui-route path=\"/contacts/:id\""),
9128            "detail route element should exist"
9129        );
9130        // The non-matched route should have display:none and no inner content
9131        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9132        let after_detail = &html[detail_start..];
9133        assert!(
9134            after_detail.contains("style=\"display:none\">")
9135                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9136            "non-matched route should be hidden: {after_detail}"
9137        );
9138        // Should NOT contain the component's rendered content
9139        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9140        let detail_body = &after_detail[..detail_end];
9141        assert!(
9142            !detail_body.contains("<detail-page"),
9143            "non-matched route should not render component content: {detail_body}"
9144        );
9145    }
9146
9147    #[test]
9148    fn test_route_parameterized_match() {
9149        let protocol = make_route_protocol();
9150        let state = test_json!({});
9151        let mut writer = TestWriter::new();
9152        handle(
9153            &protocol,
9154            &state,
9155            &RenderOptions::new("index.html", "/contacts/42"),
9156            &mut writer,
9157        )
9158        .unwrap();
9159        let html = writer.get_content();
9160
9161        // Detail route matches /contacts/42
9162        assert!(
9163            html.contains("active>") && html.contains("<detail-page"),
9164            "detail route should be active: {html}"
9165        );
9166        assert!(html.contains("<p>Detail</p>"), "detail should have content");
9167        // Dashboard should be hidden + empty
9168        let dash_start = html
9169            .find("component=\"dash-page\"")
9170            .expect("dashboard route");
9171        let after_dash = &html[dash_start..];
9172        assert!(
9173            after_dash.contains("style=\"display:none\">"),
9174            "dashboard should be hidden when detail matches: {after_dash}"
9175        );
9176        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9177        let dash_body = &after_dash[..dash_end];
9178        assert!(
9179            !dash_body.contains("<dash-page"),
9180            "dashboard should not render component content: {dash_body}"
9181        );
9182    }
9183
9184    #[test]
9185    fn test_route_no_match_all_hidden_empty() {
9186        let protocol = make_route_protocol();
9187        let state = test_json!({});
9188        let mut writer = TestWriter::new();
9189        handle(
9190            &protocol,
9191            &state,
9192            &RenderOptions::new("index.html", "/nonexistent"),
9193            &mut writer,
9194        )
9195        .unwrap();
9196        let html = writer.get_content();
9197
9198        // Shell content should still render
9199        assert!(html.contains("<h1>Shell</h1>"));
9200        // All routes should be hidden + empty (nothing matched)
9201        assert!(
9202            !html.contains("<p>Dashboard</p>"),
9203            "no route content when nothing matches"
9204        );
9205        assert!(
9206            !html.contains("<p>Detail</p>"),
9207            "no route content when nothing matches"
9208        );
9209    }
9210
9211    #[test]
9212    fn test_route_component_attr_emitted() {
9213        let protocol = make_route_protocol();
9214        let state = test_json!({});
9215        let mut writer = TestWriter::new();
9216        handle(
9217            &protocol,
9218            &state,
9219            &RenderOptions::new("index.html", "/"),
9220            &mut writer,
9221        )
9222        .unwrap();
9223        let html = writer.get_content();
9224        // component attribute should be emitted on webui-route
9225        assert!(
9226            html.contains("component=\"dash-page\""),
9227            "component attr should be on webui-route: {html}"
9228        );
9229        assert!(
9230            html.contains("component=\"detail-page\""),
9231            "component attr should be on webui-route: {html}"
9232        );
9233        assert!(
9234            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9235            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9236        );
9237    }
9238
9239    #[test]
9240    fn test_no_plugin_no_state_attributes() {
9241        let protocol = make_route_protocol();
9242        let state = test_json!({
9243            "title": "Fish & Chips",
9244            "cartOpen": true,
9245            "items": [{"name": "A&B"}]
9246        });
9247        let mut writer = TestWriter::new();
9248
9249        handle(
9250            &protocol,
9251            &state,
9252            &RenderOptions::new("index.html", "/"),
9253            &mut writer,
9254        )
9255        .unwrap();
9256
9257        let html = writer.get_content();
9258        // Without a plugin, no state attributes at all
9259        assert!(
9260            !html.contains("data-state"),
9261            "no data-state without plugin: {html}"
9262        );
9263        assert!(
9264            !html.contains(r#"title="Fish"#),
9265            "no scalar attrs without plugin: {html}"
9266        );
9267    }
9268
9269    #[test]
9270    fn test_nested_routes_render_webui_route_as_light_dom() {
9271        let protocol = make_nested_route_protocol();
9272        let state = test_json!({"title": "Test"});
9273        let handler = WebUIHandler::new();
9274        let mut writer = TestWriter::new();
9275
9276        handler
9277            .handle(
9278                &protocol,
9279                &state,
9280                &RenderOptions::new("index.html", "/sections/frontend"),
9281                &mut writer,
9282            )
9283            .expect("render failed");
9284
9285        let html = writer.get_content();
9286
9287        assert!(
9288            html.contains("component=\"app-shell\"") && html.contains("active>"),
9289            "root route should be active: {html}"
9290        );
9291        // webui-route should NOT have shadow DOM — it's a light DOM structural element
9292        assert!(
9293            !html.contains("<template shadowrootmode"),
9294            "webui-route should be light DOM (no shadow template): {html}"
9295        );
9296    }
9297
9298    #[test]
9299    fn test_nested_routes_render_outlet_as_light_dom() {
9300        let protocol = make_nested_route_protocol();
9301        let state = test_json!({"title": "Test"});
9302        let handler = WebUIHandler::new();
9303        let mut writer = TestWriter::new();
9304
9305        handler
9306            .handle(
9307                &protocol,
9308                &state,
9309                &RenderOptions::new("index.html", "/sections/frontend"),
9310                &mut writer,
9311            )
9312            .expect("render failed");
9313
9314        let html = writer.get_content();
9315
9316        // No <webui-outlet> wrapper — routes render directly at outlet position
9317        assert!(
9318            !html.contains("<webui-outlet>"),
9319            "should not contain webui-outlet wrapper: {html}"
9320        );
9321        // Route elements should be in the output directly
9322        assert!(
9323            html.contains("<webui-route"),
9324            "should contain webui-route elements: {html}"
9325        );
9326    }
9327
9328    #[test]
9329    fn test_nested_routes_match_child_at_outlet() {
9330        let protocol = make_nested_route_protocol();
9331        let state = test_json!({"title": "Test"});
9332        let handler = WebUIHandler::new();
9333        let mut writer = TestWriter::new();
9334
9335        handler
9336            .handle(
9337                &protocol,
9338                &state,
9339                &RenderOptions::new("index.html", "/sections/frontend"),
9340                &mut writer,
9341            )
9342            .expect("render failed");
9343
9344        let html = writer.get_content();
9345
9346        assert!(
9347            html.contains("component=\"section-comp\"") && html.contains("active>"),
9348            "section route should be active: {html}"
9349        );
9350        assert!(
9351            html.contains("<h2>Section</h2>"),
9352            "section content should be present: {html}"
9353        );
9354    }
9355
9356    #[test]
9357    fn test_nested_routes_three_levels_deep() {
9358        let protocol = make_nested_route_protocol();
9359        let state = test_json!({"title": "Test"});
9360        let handler = WebUIHandler::new();
9361        let mut writer = TestWriter::new();
9362
9363        handler
9364            .handle(
9365                &protocol,
9366                &state,
9367                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9368                &mut writer,
9369            )
9370            .expect("render failed");
9371
9372        let html = writer.get_content();
9373
9374        assert!(
9375            html.contains("component=\"app-shell\"") && html.contains("active>"),
9376            "root active: {html}"
9377        );
9378        assert!(
9379            html.contains("component=\"section-comp\"") && html.contains("active>"),
9380            "section active: {html}"
9381        );
9382        assert!(
9383            html.contains("component=\"topic-comp\"")
9384                && html.contains("exact")
9385                && html.contains("active>"),
9386            "topic active: {html}"
9387        );
9388        assert!(
9389            html.contains("<p>Topic content</p>"),
9390            "leaf content present: {html}"
9391        );
9392    }
9393
9394    #[test]
9395    fn test_nested_routes_nonmatched_siblings_hidden() {
9396        let protocol = make_nested_route_protocol();
9397        let state = test_json!({"title": "Test"});
9398        let handler = WebUIHandler::new();
9399        let mut writer = TestWriter::new();
9400
9401        handler
9402            .handle(
9403                &protocol,
9404                &state,
9405                &RenderOptions::new("index.html", "/sections/frontend"),
9406                &mut writer,
9407            )
9408            .expect("render failed");
9409
9410        let html = writer.get_content();
9411
9412        assert!(
9413            html.contains(r#"component="topic-comp" exact style="display:none">"#),
9414            "topic should be hidden: {html}"
9415        );
9416    }
9417
9418    #[test]
9419    fn test_nested_routes_root_only() {
9420        let protocol = make_nested_route_protocol();
9421        let state = test_json!({"title": "Test"});
9422        let handler = WebUIHandler::new();
9423        let mut writer = TestWriter::new();
9424
9425        handler
9426            .handle(
9427                &protocol,
9428                &state,
9429                &RenderOptions::new("index.html", "/"),
9430                &mut writer,
9431            )
9432            .expect("render failed");
9433
9434        let html = writer.get_content();
9435
9436        assert!(
9437            html.contains("component=\"app-shell\"") && html.contains("active>"),
9438            "root active at /: {html}"
9439        );
9440        assert!(
9441            html.contains("<h1>Shell</h1>"),
9442            "shell renders at /: {html}"
9443        );
9444        assert!(
9445            html.contains(r#"component="section-comp" style="display:none">"#),
9446            "section hidden at /: {html}"
9447        );
9448    }
9449
9450    // ── CSS Module dedup tests ───────────────────────────────────────
9451
9452    #[test]
9453    fn test_css_module_emitted_once_inline_in_component() {
9454        // CSS module definition emitted once in the component's light DOM
9455        // on first render, not in <head> and not on second instance.
9456        let template = r#"<p><slot></slot></p>"#;
9457
9458        let mut fragments = HashMap::new();
9459        fragments.insert(
9460            "index.html".to_string(),
9461            FragmentList {
9462                fragments: vec![
9463                    WebUIFragment::raw("<html><head>".to_string()),
9464                    structural_fragment("head_end"),
9465                    WebUIFragment::raw("</head><body><div>".to_string()),
9466                    WebUIFragment::component("my-card"),
9467                    WebUIFragment::raw("A".to_string()),
9468                    WebUIFragment::component("my-card"),
9469                    WebUIFragment::raw("B</div>".to_string()),
9470                    structural_fragment("body_end"),
9471                    WebUIFragment::raw("</body></html>".to_string()),
9472                ],
9473                contains_boundary: false,
9474            },
9475        );
9476        fragments.insert(
9477            "my-card".to_string(),
9478            FragmentList {
9479                fragments: vec![WebUIFragment::raw(template.to_string())],
9480                contains_boundary: false,
9481            },
9482        );
9483
9484        let mut protocol = WebUIProtocol::new(fragments);
9485        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9486        protocol
9487            .components
9488            .entry("my-card".to_string())
9489            .or_default()
9490            .css = "p{color:red}".to_string();
9491        protocol.populate_style_closures(&["index.html"]);
9492        let state = test_json!({});
9493        let mut writer = TestWriter::new();
9494
9495        handle(
9496            &protocol,
9497            &state,
9498            &RenderOptions::new("index.html", "/"),
9499            &mut writer,
9500        )
9501        .unwrap();
9502
9503        let html = writer.get_content();
9504
9505        // CSS module importmap should appear exactly once
9506        let count = html.matches(r#"<script type="importmap""#).count();
9507        assert_eq!(
9508            count, 1,
9509            "CSS module importmap should be emitted once, got {count} in: {html}"
9510        );
9511        assert!(
9512            html.contains(r#""my-card":"data:text/css,"#),
9513            "Importmap must register my-card under a data: URI: {html}"
9514        );
9515
9516        // Template content should appear twice (once per component instance)
9517        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9518        assert_eq!(
9519            tmpl_count, 2,
9520            "Template should render twice, got {tmpl_count} in: {html}"
9521        );
9522
9523        // CSS module should be in <body> (inline), not in <head>
9524        let css_pos = html
9525            .find(r#"<script type="importmap""#)
9526            .expect("CSS module importmap missing");
9527        let body_pos = html.find("<body>").expect("<body> missing");
9528        assert!(
9529            css_pos > body_pos,
9530            "CSS module should be inline in component, not in <head>: {html}"
9531        );
9532    }
9533
9534    #[test]
9535    fn test_component_without_css_renders_normally() {
9536        // Components without CSS module prefix pass through unchanged
9537        let mut fragments = HashMap::new();
9538        fragments.insert(
9539            "index.html".to_string(),
9540            FragmentList {
9541                fragments: vec![WebUIFragment::component("my-card")],
9542                contains_boundary: false,
9543            },
9544        );
9545        fragments.insert(
9546            "my-card".to_string(),
9547            FragmentList {
9548                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9549                contains_boundary: false,
9550            },
9551        );
9552
9553        let protocol = WebUIProtocol::new(fragments);
9554        let state = test_json!({});
9555        let mut writer = TestWriter::new();
9556
9557        handle(
9558            &protocol,
9559            &state,
9560            &RenderOptions::new("index.html", "/"),
9561            &mut writer,
9562        )
9563        .unwrap();
9564
9565        let html = writer.get_content();
9566        assert!(
9567            html.contains("<p>hello</p>"),
9568            "Non-module component should render normally: {html}"
9569        );
9570    }
9571
9572    #[test]
9573    fn test_non_module_strategy_no_css_in_head() {
9574        // When component_css is empty (Link/Style strategies), no
9575        // CSS module importmap tags should appear in <head>.
9576        let template = r#"<p>hello</p>"#;
9577
9578        let mut fragments = HashMap::new();
9579        fragments.insert(
9580            "index.html".to_string(),
9581            FragmentList {
9582                fragments: vec![
9583                    WebUIFragment::raw("<html><head>".to_string()),
9584                    structural_fragment("head_end"),
9585                    WebUIFragment::raw("</head><body>".to_string()),
9586                    WebUIFragment::component("my-card"),
9587                    WebUIFragment::raw("</body></html>".to_string()),
9588                ],
9589                contains_boundary: false,
9590            },
9591        );
9592        fragments.insert(
9593            "my-card".to_string(),
9594            FragmentList {
9595                fragments: vec![WebUIFragment::raw(template.to_string())],
9596                contains_boundary: false,
9597            },
9598        );
9599
9600        // No component css populated — simulates Link/Style strategy
9601        let protocol = WebUIProtocol::new(fragments);
9602        let state = test_json!({});
9603        let mut writer = TestWriter::new();
9604
9605        handle(
9606            &protocol,
9607            &state,
9608            &RenderOptions::new("index.html", "/"),
9609            &mut writer,
9610        )
9611        .unwrap();
9612
9613        let html = writer.get_content();
9614
9615        assert!(
9616            !html.contains(r#"<style type="module""#),
9617            "Non-module strategy should not emit CSS module tags in <head>: {html}"
9618        );
9619        assert!(
9620            !html.contains(r#"<script type="importmap""#),
9621            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9622        );
9623        assert!(
9624            html.contains("<p>hello</p>"),
9625            "Component should still render: {html}"
9626        );
9627    }
9628
9629    #[test]
9630    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9631        let mut fragments = HashMap::new();
9632        fragments.insert(
9633            "index.html".to_string(),
9634            FragmentList {
9635                fragments: vec![
9636                    WebUIFragment::raw("<html><head>".to_string()),
9637                    structural_fragment("head_end"),
9638                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9639                    WebUIFragment::component("my-card"),
9640                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9641                ],
9642                contains_boundary: false,
9643            },
9644        );
9645        fragments.insert(
9646            "my-card".to_string(),
9647            FragmentList {
9648                fragments: vec![WebUIFragment::raw(
9649                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9650                        .to_string(),
9651                )],
9652                        contains_boundary: false,
9653},
9654        );
9655
9656        let protocol = WebUIProtocol::new(fragments);
9657        let state = test_json!({});
9658        let mut writer = TestWriter::new();
9659
9660        handle(
9661            &protocol,
9662            &state,
9663            &RenderOptions::new("index.html", "/"),
9664            &mut writer,
9665        )
9666        .unwrap();
9667
9668        let html = writer.get_content();
9669
9670        assert!(
9671            html.contains("<style>.card{color:red}</style>"),
9672            "Style strategy should embed inline CSS in shadow template: {html}"
9673        );
9674        assert!(
9675            !html.contains(r#"<style type="module""#),
9676            "Style strategy should not emit legacy module CSS in <head>: {html}"
9677        );
9678        assert!(
9679            !html.contains(r#"<script type="importmap""#),
9680            "Style strategy should not emit CSS module importmaps in <head>: {html}"
9681        );
9682    }
9683
9684    #[test]
9685    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9686        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
9687        // in <head>. No preload tag — the stylesheet itself fetches.
9688        let mut fragments = HashMap::new();
9689        fragments.insert(
9690            "index.html".to_string(),
9691            FragmentList {
9692                fragments: vec![
9693                    WebUIFragment::raw("<html><head>".to_string()),
9694                    structural_fragment("head_end"),
9695                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9696                    WebUIFragment::component("my-card"),
9697                    WebUIFragment::raw("</my-card>".to_string()),
9698                    structural_fragment("body_end"),
9699                    WebUIFragment::raw("</body></html>".to_string()),
9700                ],
9701                contains_boundary: false,
9702            },
9703        );
9704        fragments.insert(
9705            "my-card".to_string(),
9706            FragmentList {
9707                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9708                contains_boundary: false,
9709            },
9710        );
9711
9712        let mut protocol = WebUIProtocol::new(fragments);
9713        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9714
9715        let comp = protocol
9716            .components
9717            .entry("my-card".to_string())
9718            .or_default();
9719        comp.css_href = "my-card.css".to_string();
9720        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9721        protocol.populate_style_closures(&["index.html"]);
9722
9723        let state = test_json!({});
9724        let mut writer = TestWriter::new();
9725
9726        handle(
9727            &protocol,
9728            &state,
9729            &RenderOptions::new("index.html", "/"),
9730            &mut writer,
9731        )
9732        .unwrap();
9733
9734        let html = writer.get_content();
9735
9736        let head_end = html.find("</head>").expect("</head> missing");
9737        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9738        assert!(
9739            link_pos.is_some_and(|p| p < head_end),
9740            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9741        );
9742        assert!(
9743            !html.contains(r#"<link rel="preload""#),
9744            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9745        );
9746        assert!(
9747            !html.contains(r#"<style type="module""#),
9748            "Link strategy should not emit legacy module CSS: {html}"
9749        );
9750        assert!(
9751            !html.contains(r#"<script type="importmap""#),
9752            "Link strategy should not emit CSS module importmaps: {html}"
9753        );
9754    }
9755
9756    #[test]
9757    fn link_strategy_preloads_static_shadow_css_in_head() {
9758        let mut fragments = HashMap::new();
9759        fragments.insert(
9760            "index.html".to_string(),
9761            FragmentList {
9762                fragments: vec![
9763                    WebUIFragment::raw("<html><head>".to_string()),
9764                    structural_fragment("head_end"),
9765                    WebUIFragment::raw("</head><body><my-card>".to_string()),
9766                    WebUIFragment::component("my-card"),
9767                    WebUIFragment::raw("</my-card></body></html>".to_string()),
9768                ],
9769                contains_boundary: false,
9770            },
9771        );
9772        fragments.insert(
9773            "o-loading-state".to_string(),
9774            FragmentList {
9775                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9776                contains_boundary: false,
9777            },
9778        );
9779        fragments.insert(
9780            "my-card".to_string(),
9781            FragmentList {
9782                fragments: vec![
9783                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9784                    structural_fragment("shadow_styles:my-card"),
9785                    WebUIFragment::raw("<div>card</div></template>".to_string()),
9786                ],
9787                contains_boundary: false,
9788            },
9789        );
9790
9791        let mut protocol = WebUIProtocol::new(fragments);
9792        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9793        let component = protocol
9794            .components
9795            .entry("my-card".to_string())
9796            .or_default();
9797        component.css_href = "my-card.css".to_string();
9798        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9799        component.uses_shadow_dom = true;
9800        protocol.populate_style_closures(&["index.html"]);
9801
9802        let mut writer = TestWriter::new();
9803        handle(
9804            &protocol,
9805            &test_json!({}),
9806            &RenderOptions::new("index.html", "/"),
9807            &mut writer,
9808        )
9809        .unwrap();
9810
9811        let html = writer.get_content();
9812        let head_end = html.find("</head>").expect("</head> missing");
9813        let preload = html
9814            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9815            .expect("Shadow preload missing");
9816        let stylesheet = html
9817            .find(r#"<link rel="stylesheet" href="my-card.css""#)
9818            .expect("Shadow stylesheet missing");
9819        assert!(preload < head_end);
9820        assert!(stylesheet > head_end);
9821        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9822    }
9823
9824    #[test]
9825    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9826        let fragments = HashMap::from([
9827            (
9828                "index.html".to_string(),
9829                FragmentList {
9830                    fragments: vec![
9831                        WebUIFragment::raw("<html><head>".to_string()),
9832                        structural_fragment("head_end"),
9833                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
9834                        WebUIFragment::component("outer-box"),
9835                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
9836                    ],
9837                    contains_boundary: false,
9838                },
9839            ),
9840            (
9841                "outer-box".to_string(),
9842                FragmentList {
9843                    fragments: vec![
9844                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9845                        structural_fragment("shadow_styles:outer-box"),
9846                        WebUIFragment::raw("<light-card>".to_string()),
9847                        WebUIFragment::component("light-card"),
9848                        WebUIFragment::raw("</light-card><light-card>".to_string()),
9849                        WebUIFragment::component("light-card"),
9850                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
9851                        WebUIFragment::component("inner-box"),
9852                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
9853                        WebUIFragment::component("light-card"),
9854                        WebUIFragment::raw("</light-card></template>".to_string()),
9855                    ],
9856                    contains_boundary: false,
9857                },
9858            ),
9859            (
9860                "inner-box".to_string(),
9861                FragmentList {
9862                    fragments: vec![
9863                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9864                        structural_fragment("shadow_styles:inner-box"),
9865                        WebUIFragment::raw("<light-card>".to_string()),
9866                        WebUIFragment::component("light-card"),
9867                        WebUIFragment::raw("</light-card></template>".to_string()),
9868                    ],
9869                    contains_boundary: false,
9870                },
9871            ),
9872            (
9873                "light-card".to_string(),
9874                FragmentList {
9875                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
9876                    contains_boundary: false,
9877                },
9878            ),
9879        ]);
9880        let mut protocol = WebUIProtocol::new(fragments);
9881        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
9882        for (tag, css, uses_shadow_dom) in [
9883            ("outer-box", ".outer{}", true),
9884            ("inner-box", ".inner{}", true),
9885            ("light-card", ".card{}", false),
9886        ] {
9887            let component = protocol.components.entry(tag.to_string()).or_default();
9888            component.css = css.to_string();
9889            component.uses_shadow_dom = uses_shadow_dom;
9890        }
9891        protocol.populate_style_closures(&["index.html"]);
9892
9893        let mut writer = TestWriter::new();
9894        handle(
9895            &protocol,
9896            &test_json!({}),
9897            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
9898            &mut writer,
9899        )
9900        .unwrap();
9901        let html = writer.get_content();
9902
9903        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
9904        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
9905        assert_eq!(
9906            html.matches("data-webui-resource=\"light-card\"").count(),
9907            2,
9908            "the Light resource installs once in each Shadow tree: {html}"
9909        );
9910        assert_eq!(
9911            html.matches("data-webui-resource=").count(),
9912            4,
9913            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
9914        );
9915        assert_eq!(
9916            html.matches("nonce=\"css-nonce\"").count(),
9917            4,
9918            "each inline style resource carries the nonce"
9919        );
9920        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
9921        let inner_end = html.find("</inner-box>").expect("inner host close");
9922        let caller_continuation = &html[inner_end..];
9923        let trailing_card = caller_continuation
9924            .find("<p>card</p>")
9925            .expect("caller continues after nested Shadow root");
9926        assert!(trailing_card > 0);
9927        assert!(
9928            !caller_continuation.contains("data-webui-resource=\"light-card\""),
9929            "returning from the nested Shadow root must restore the outer frame's delivered set"
9930        );
9931    }
9932
9933    #[test]
9934    fn routed_document_styles_are_hoisted_for_the_active_chain() {
9935        let route = WebUiFragmentRoute {
9936            path: "/".to_string(),
9937            fragment_id: "app-shell".to_string(),
9938            children: vec![
9939                WebUiFragmentRoute {
9940                    path: String::new(),
9941                    fragment_id: "dashboard-page".to_string(),
9942                    exact: true,
9943                    ..Default::default()
9944                },
9945                WebUiFragmentRoute {
9946                    path: "details".to_string(),
9947                    fragment_id: "detail-page".to_string(),
9948                    exact: true,
9949                    ..Default::default()
9950                },
9951            ],
9952            ..Default::default()
9953        };
9954        let fragments = HashMap::from([
9955            (
9956                "index.html".to_string(),
9957                FragmentList {
9958                    fragments: vec![
9959                        WebUIFragment::raw("<html><head>"),
9960                        structural_fragment("head_start"),
9961                        structural_fragment("head_end"),
9962                        WebUIFragment::raw("</head><body>"),
9963                        structural_fragment("body_start"),
9964                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
9965                        WebUIFragment::route_from(route),
9966                        WebUIFragment::boundary_end(0),
9967                        structural_fragment("body_end"),
9968                        WebUIFragment::raw("</body></html>"),
9969                    ],
9970                    contains_boundary: true,
9971                },
9972            ),
9973            (
9974                "app-shell".to_string(),
9975                FragmentList {
9976                    fragments: vec![
9977                        WebUIFragment::raw("<header>App</header>"),
9978                        WebUIFragment::outlet(),
9979                    ],
9980                    contains_boundary: false,
9981                },
9982            ),
9983            (
9984                "dashboard-page".to_string(),
9985                FragmentList {
9986                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
9987                    contains_boundary: false,
9988                },
9989            ),
9990            (
9991                "detail-page".to_string(),
9992                FragmentList {
9993                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
9994                    contains_boundary: false,
9995                },
9996            ),
9997        ]);
9998        let mut protocol = WebUIProtocol::new(fragments);
9999        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10000        for tag in ["app-shell", "dashboard-page", "detail-page"] {
10001            let component = protocol.components.entry(tag.to_string()).or_default();
10002            component.css_href = format!("/{tag}.css");
10003            component.template_json = r#"{"h":""}"#.to_string();
10004        }
10005        protocol.populate_style_closures(&["index.html"]);
10006
10007        let mut writer = TestWriter::new();
10008        handle(
10009            &protocol,
10010            &test_json!({}),
10011            &RenderOptions::new("index.html", "/"),
10012            &mut writer,
10013        )
10014        .unwrap();
10015        let html = writer.get_content();
10016
10017        assert_eq!(
10018            html.matches(r#"data-webui-resource="app-shell""#).count(),
10019            1,
10020            "the matched route shell must install once: {html}"
10021        );
10022        assert_eq!(
10023            html.matches(r#"data-webui-resource="dashboard-page""#)
10024                .count(),
10025            1,
10026            "the matched child route must install once: {html}"
10027        );
10028        assert!(
10029            !html.contains(r#"data-webui-resource="detail-page""#),
10030            "inactive route CSS must not be delivered: {html}"
10031        );
10032        let head_end = html.find("</head>").expect("head close");
10033        let head = &html[..head_end];
10034        for (resource, href) in [
10035            ("app-shell", "/app-shell.css"),
10036            ("dashboard-page", "/dashboard-page.css"),
10037        ] {
10038            let encoded_href = crate::html_encode::encode_safe(href);
10039            assert!(
10040                head.contains(&format!(
10041                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10042                )),
10043                "Document-targeted route CSS must be render-blocking in head: {html}"
10044            );
10045            assert!(
10046                !head.contains(&format!(
10047                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
10048                )),
10049                "a hoisted stylesheet must not also be preloaded: {html}"
10050            );
10051        }
10052        assert!(
10053            !head.contains("detail-page.css"),
10054            "inactive route CSS must not be preloaded: {html}"
10055        );
10056        let app_style = html
10057            .find(r#"data-webui-resource="app-shell""#)
10058            .expect("app style");
10059        let dashboard_style = html
10060            .find(r#"data-webui-resource="dashboard-page""#)
10061            .expect("dashboard style");
10062        let app_host = html.find("<app-shell").expect("app route host");
10063        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10064        assert!(
10065            app_style < dashboard_style
10066                && dashboard_style < head_end
10067                && head_end < app_host
10068                && app_host < dashboard_host,
10069            "matched Document route closures must be hoisted in chain order: {html}"
10070        );
10071
10072        let protocol = Protocol::new(protocol);
10073        let mut streamed = FlushTestWriter::default();
10074        WebUIHandler::new()
10075            .render_streaming(
10076                &protocol,
10077                &test_json!({}),
10078                &RenderOptions::new("index.html", "/"),
10079                &mut streamed,
10080            )
10081            .unwrap();
10082        let streamed_head_end = streamed
10083            .output
10084            .find("</head>")
10085            .expect("streamed head close");
10086        for resource in ["app-shell", "dashboard-page"] {
10087            let marker = format!(r#"data-webui-resource="{resource}""#);
10088            let position = streamed.output.find(&marker).expect("streamed route style");
10089            assert!(
10090                position < streamed_head_end,
10091                "streaming must hoist {resource} into head: {}",
10092                streamed.output
10093            );
10094            assert_eq!(
10095                streamed.output.matches(&marker).count(),
10096                1,
10097                "streaming must not emit {resource} twice: {}",
10098                streamed.output
10099            );
10100        }
10101    }
10102
10103    #[test]
10104    fn bundled_routed_document_stylesheet_is_hoisted_once() {
10105        let route = WebUiFragmentRoute {
10106            path: "/".to_string(),
10107            fragment_id: "dashboard-page".to_string(),
10108            exact: true,
10109            ..Default::default()
10110        };
10111        let mut protocol = WebUIProtocol::new(HashMap::from([
10112            (
10113                "index.html".to_string(),
10114                FragmentList {
10115                    fragments: vec![
10116                        WebUIFragment::raw("<html><head>"),
10117                        structural_fragment("head_start"),
10118                        structural_fragment("head_end"),
10119                        WebUIFragment::raw("</head><body>"),
10120                        WebUIFragment::route_from(route),
10121                        structural_fragment("body_end"),
10122                        WebUIFragment::raw("</body></html>"),
10123                    ],
10124                    contains_boundary: false,
10125                },
10126            ),
10127            (
10128                "dashboard-page".to_string(),
10129                FragmentList {
10130                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10131                    contains_boundary: false,
10132                },
10133            ),
10134        ]));
10135        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10136        for tag in ["dashboard-page", "summary-card", "activity-list"] {
10137            protocol
10138                .components
10139                .entry(tag.to_string())
10140                .or_default()
10141                .css_href = format!("/{tag}.css");
10142        }
10143        protocol.style_closures.insert(
10144            "index.html".to_string(),
10145            webui_protocol::ComponentStyleClosure::default(),
10146        );
10147        protocol.style_closures.insert(
10148            "dashboard-page".to_string(),
10149            webui_protocol::ComponentStyleClosure {
10150                component_tags: vec![
10151                    "dashboard-page".to_string(),
10152                    "summary-card".to_string(),
10153                    "activity-list".to_string(),
10154                ],
10155                style_chunks: vec![0],
10156            },
10157        );
10158        protocol.style_chunks.push(webui_protocol::StyleChunk {
10159            name: "_chunk-dashboard-page-3".to_string(),
10160            css: String::new(),
10161            css_href: "/_chunk-dashboard-page-3.css".to_string(),
10162            component_tags: vec![
10163                "dashboard-page".to_string(),
10164                "summary-card".to_string(),
10165                "activity-list".to_string(),
10166            ],
10167        });
10168
10169        let mut writer = TestWriter::new();
10170        handle(
10171            &protocol,
10172            &test_json!({}),
10173            &RenderOptions::new("index.html", "/"),
10174            &mut writer,
10175        )
10176        .unwrap();
10177        let html = writer.get_content();
10178        let head_end = html.find("</head>").expect("head close");
10179        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10180        let style = html.find(marker).expect("bundled route stylesheet");
10181
10182        assert!(
10183            style < head_end,
10184            "bundled route CSS must be in head: {html}"
10185        );
10186        assert_eq!(
10187            html.matches(marker).count(),
10188            1,
10189            "the route host must not emit the hoisted chunk again: {html}"
10190        );
10191        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10192        assert!(
10193            !html.contains(&format!(
10194                r#"<link rel="preload" as="style" href="{bundled_href}">"#
10195            )),
10196            "the applied bundled stylesheet must not also be preloaded: {html}"
10197        );
10198    }
10199
10200    #[test]
10201    fn routed_document_inline_styles_are_hoisted() {
10202        for strategy in [
10203            webui_protocol::CssStrategy::Style,
10204            webui_protocol::CssStrategy::Module,
10205        ] {
10206            let route = WebUiFragmentRoute {
10207                path: "/".to_string(),
10208                fragment_id: "dashboard-page".to_string(),
10209                exact: true,
10210                ..Default::default()
10211            };
10212            let mut protocol = WebUIProtocol::new(HashMap::from([
10213                (
10214                    "index.html".to_string(),
10215                    FragmentList {
10216                        fragments: vec![
10217                            WebUIFragment::raw("<html><head>"),
10218                            structural_fragment("head_start"),
10219                            structural_fragment("head_end"),
10220                            WebUIFragment::raw("</head><body>"),
10221                            WebUIFragment::route_from(route),
10222                            structural_fragment("body_end"),
10223                            WebUIFragment::raw("</body></html>"),
10224                        ],
10225                        contains_boundary: false,
10226                    },
10227                ),
10228                (
10229                    "dashboard-page".to_string(),
10230                    FragmentList {
10231                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10232                        contains_boundary: false,
10233                    },
10234                ),
10235            ]));
10236            protocol.set_css_strategy(strategy);
10237            protocol
10238                .components
10239                .entry("dashboard-page".to_string())
10240                .or_default()
10241                .css = ".dashboard{display:grid}".to_string();
10242            protocol.populate_style_closures(&["index.html"]);
10243
10244            let mut writer = TestWriter::new();
10245            handle(
10246                &protocol,
10247                &test_json!({}),
10248                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10249                &mut writer,
10250            )
10251            .unwrap();
10252            let html = writer.get_content();
10253            let head_end = html.find("</head>").expect("head close");
10254            let marker = r#"data-webui-resource="dashboard-page""#;
10255            let style = html.find(marker).expect("route style fallback");
10256            let host = html.find("<dashboard-page").expect("dashboard route host");
10257            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10258                "module"
10259            } else {
10260                "style"
10261            };
10262            let style_marker = format!(
10263                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10264            );
10265
10266            assert!(
10267                style < head_end && head_end < host,
10268                "{strategy:?} route CSS must be applied in head before its host: {html}"
10269            );
10270            assert_eq!(
10271                html.matches(&style_marker).count(),
10272                1,
10273                "{strategy:?} route CSS must not be emitted twice: {html}"
10274            );
10275        }
10276    }
10277
10278    #[test]
10279    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10280        let route = WebUiFragmentRoute {
10281            path: "/".to_string(),
10282            fragment_id: "dashboard-page".to_string(),
10283            exact: true,
10284            ..Default::default()
10285        };
10286        let mut protocol = WebUIProtocol::new(HashMap::from([
10287            (
10288                "index.html".to_string(),
10289                FragmentList {
10290                    fragments: vec![
10291                        WebUIFragment::raw("<html><head>"),
10292                        structural_fragment("head_start"),
10293                        structural_fragment("head_end"),
10294                        WebUIFragment::raw("</head><body><outer-box>"),
10295                        WebUIFragment::component("outer-box"),
10296                        WebUIFragment::raw("</outer-box></body></html>"),
10297                    ],
10298                    contains_boundary: false,
10299                },
10300            ),
10301            (
10302                "outer-box".to_string(),
10303                FragmentList {
10304                    fragments: vec![
10305                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10306                        structural_fragment("shadow_styles:outer-box"),
10307                        WebUIFragment::route_from(route),
10308                        WebUIFragment::raw("</template>"),
10309                    ],
10310                    contains_boundary: false,
10311                },
10312            ),
10313            (
10314                "dashboard-page".to_string(),
10315                FragmentList {
10316                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10317                    contains_boundary: false,
10318                },
10319            ),
10320        ]));
10321        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10322        for tag in ["outer-box", "dashboard-page"] {
10323            protocol
10324                .components
10325                .entry(tag.to_string())
10326                .or_default()
10327                .css_href = format!("/{tag}.css");
10328        }
10329        protocol
10330            .components
10331            .get_mut("outer-box")
10332            .expect("outer-box component")
10333            .uses_shadow_dom = true;
10334        protocol.populate_style_closures(&["index.html"]);
10335
10336        let mut writer = TestWriter::new();
10337        handle(
10338            &protocol,
10339            &test_json!({}),
10340            &RenderOptions::new("index.html", "/"),
10341            &mut writer,
10342        )
10343        .unwrap();
10344        let html = writer.get_content();
10345        let head_end = html.find("</head>").expect("head close");
10346        let head = &html[..head_end];
10347        let dashboard_style = html
10348            .find(r#"data-webui-resource="dashboard-page""#)
10349            .expect("tree-local dashboard stylesheet");
10350        let shadow_start = html
10351            .find("<template shadowrootmode=\"open\">")
10352            .expect("Shadow template");
10353        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10354        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10355
10356        assert!(
10357            head.contains(&format!(
10358                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10359            )),
10360            "Shadow-targeted route CSS must start loading from head: {html}"
10361        );
10362        assert!(
10363            !head.contains(r#"data-webui-resource="dashboard-page""#),
10364            "Shadow-targeted route CSS must not be applied to Document: {html}"
10365        );
10366        assert!(
10367            shadow_start < dashboard_style && dashboard_style < dashboard_host,
10368            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10369        );
10370    }
10371
10372    #[test]
10373    fn isolated_shadow_entry_does_not_emit_document_preloads() {
10374        let route = WebUiFragmentRoute {
10375            path: "/".to_string(),
10376            fragment_id: "dashboard-page".to_string(),
10377            exact: true,
10378            ..Default::default()
10379        };
10380        let mut protocol = WebUIProtocol::new(HashMap::from([
10381            (
10382                "outer-box".to_string(),
10383                FragmentList {
10384                    fragments: vec![
10385                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10386                        structural_fragment("shadow_styles:outer-box"),
10387                        WebUIFragment::route_from(route),
10388                        WebUIFragment::raw("</template>"),
10389                    ],
10390                    contains_boundary: false,
10391                },
10392            ),
10393            (
10394                "dashboard-page".to_string(),
10395                FragmentList {
10396                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10397                    contains_boundary: false,
10398                },
10399            ),
10400        ]));
10401        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10402        for tag in ["outer-box", "dashboard-page"] {
10403            protocol
10404                .components
10405                .entry(tag.to_string())
10406                .or_default()
10407                .css_href = format!("/{tag}.css");
10408        }
10409        protocol
10410            .components
10411            .get_mut("outer-box")
10412            .expect("outer-box component")
10413            .uses_shadow_dom = true;
10414        protocol.populate_style_closures(&["outer-box"]);
10415
10416        let mut writer = TestWriter::new();
10417        handle(
10418            &protocol,
10419            &test_json!({}),
10420            &RenderOptions::new("outer-box", "/"),
10421            &mut writer,
10422        )
10423        .unwrap();
10424        let html = writer.get_content();
10425
10426        assert!(
10427            html.starts_with("<template shadowrootmode=\"open\">"),
10428            "isolated component output must not be prefixed with document metadata: {html}"
10429        );
10430        assert!(
10431            !html.contains(r#"<link rel="preload""#),
10432            "an isolated Shadow entry has no Document head for preloads: {html}"
10433        );
10434        assert!(
10435            html.contains(r#"data-webui-resource="dashboard-page""#),
10436            "the route stylesheet must still install inside the ShadowRoot: {html}"
10437        );
10438    }
10439
10440    #[test]
10441    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10442        let route = WebUiFragmentRoute {
10443            path: "/".to_string(),
10444            fragment_id: "app-shell".to_string(),
10445            children: vec![
10446                WebUiFragmentRoute {
10447                    path: String::new(),
10448                    fragment_id: "dashboard-page".to_string(),
10449                    exact: true,
10450                    ..Default::default()
10451                },
10452                WebUiFragmentRoute {
10453                    path: "details".to_string(),
10454                    fragment_id: "detail-page".to_string(),
10455                    exact: true,
10456                    ..Default::default()
10457                },
10458            ],
10459            ..Default::default()
10460        };
10461        let fragments = HashMap::from([
10462            (
10463                "index.html".to_string(),
10464                FragmentList {
10465                    fragments: vec![
10466                        WebUIFragment::raw("<html><head>"),
10467                        structural_fragment("head_start"),
10468                        structural_fragment("head_end"),
10469                        WebUIFragment::raw("</head><body>"),
10470                        structural_fragment("body_start"),
10471                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10472                        WebUIFragment::route_from(route),
10473                        WebUIFragment::boundary_end(0),
10474                        structural_fragment("body_end"),
10475                        WebUIFragment::raw("</body></html>"),
10476                    ],
10477                    contains_boundary: true,
10478                },
10479            ),
10480            (
10481                "app-shell".to_string(),
10482                FragmentList {
10483                    fragments: vec![
10484                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10485                        structural_fragment("shadow_styles:app-shell"),
10486                        WebUIFragment::raw("<shared-card"),
10487                        structural_fragment("streaming_root:shared-card"),
10488                        WebUIFragment::raw(">"),
10489                        WebUIFragment::component("shared-card"),
10490                        WebUIFragment::raw("</shared-card>"),
10491                        WebUIFragment::outlet(),
10492                        WebUIFragment::raw("</template>"),
10493                    ],
10494                    contains_boundary: false,
10495                },
10496            ),
10497            (
10498                "dashboard-page".to_string(),
10499                FragmentList {
10500                    fragments: vec![
10501                        WebUIFragment::raw("<shared-card"),
10502                        structural_fragment("streaming_root:shared-card"),
10503                        WebUIFragment::raw(">"),
10504                        WebUIFragment::component("shared-card"),
10505                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10506                    ],
10507                    contains_boundary: false,
10508                },
10509            ),
10510            (
10511                "detail-page".to_string(),
10512                FragmentList {
10513                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10514                    contains_boundary: false,
10515                },
10516            ),
10517            (
10518                "shared-card".to_string(),
10519                FragmentList {
10520                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10521                    contains_boundary: false,
10522                },
10523            ),
10524        ]);
10525        let mut protocol = WebUIProtocol::new(fragments);
10526        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10527        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10528            let component = protocol.components.entry(tag.to_string()).or_default();
10529            component.css = format!(".{tag}{{display:block}}");
10530            component.template_json = r#"{"h":""}"#.to_string();
10531        }
10532        protocol
10533            .components
10534            .get_mut("app-shell")
10535            .expect("app shell component")
10536            .uses_shadow_dom = true;
10537        protocol.populate_style_closures(&["index.html"]);
10538
10539        let mut writer = TestWriter::new();
10540        handle(
10541            &protocol,
10542            &test_json!({}),
10543            &RenderOptions::new("index.html", "/"),
10544            &mut writer,
10545        )
10546        .unwrap();
10547        let html = writer.get_content();
10548
10549        for resource in ["app-shell", "shared-card", "dashboard-page"] {
10550            assert_eq!(
10551                html.matches(&format!(r#"data-webui-resource="{resource}""#))
10552                    .count(),
10553                1,
10554                "{resource} must install once in the active ShadowRoot: {html}"
10555            );
10556        }
10557        assert!(
10558            !html.contains(r#"data-webui-resource="detail-page""#),
10559            "inactive route CSS must not enter the ShadowRoot: {html}"
10560        );
10561        let shadow_start = html
10562            .find("<template shadowrootmode=\"open\">")
10563            .expect("Shadow template");
10564        let dashboard_style = html
10565            .find(r#"data-webui-resource="dashboard-page""#)
10566            .expect("dashboard style");
10567        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10568        let shadow_end = html.find("</template>").expect("Shadow template close");
10569        assert!(
10570            shadow_start < dashboard_style
10571                && dashboard_style < dashboard_host
10572                && dashboard_host < shadow_end,
10573            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10574        );
10575
10576        let protocol = Protocol::new(protocol);
10577        let mut streamed = FlushTestWriter::default();
10578        WebUIHandler::new()
10579            .render_streaming(
10580                &protocol,
10581                &test_json!({}),
10582                &RenderOptions::new("index.html", "/"),
10583                &mut streamed,
10584            )
10585            .unwrap();
10586        for resource in ["app-shell", "shared-card"] {
10587            assert_eq!(
10588                streamed
10589                    .output
10590                    .matches(&format!(r#"data-webui-resource="{resource}""#))
10591                    .count(),
10592                1,
10593                "streaming must preserve tree-local delivery for {resource}: {}",
10594                streamed.output
10595            );
10596        }
10597        assert!(
10598            !streamed
10599                .output
10600                .contains(r#"data-webui-resource="dashboard-page""#),
10601            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10602            streamed.output
10603        );
10604        assert!(
10605            streamed
10606                .output
10607                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10608                && streamed
10609                    .output
10610                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10611            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10612            streamed.output
10613        );
10614        assert!(
10615            !streamed
10616                .output
10617                .contains(r#"data-webui-resource="detail-page""#),
10618            "streaming must omit inactive route CSS: {}",
10619            streamed.output
10620        );
10621    }
10622
10623    #[test]
10624    fn document_styles_follow_doctype_before_headless_document_content() {
10625        for entry_fragments in [
10626            vec![
10627                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10628                structural_fragment("body_start"),
10629                WebUIFragment::raw("<my-card>"),
10630                WebUIFragment::component("my-card"),
10631                WebUIFragment::raw("</my-card></body></html>"),
10632            ],
10633            vec![
10634                WebUIFragment::raw("<!doctype html><html><my-card>"),
10635                WebUIFragment::component("my-card"),
10636                WebUIFragment::raw("</my-card></html>"),
10637            ],
10638        ] {
10639            let mut protocol = WebUIProtocol::new(HashMap::from([
10640                (
10641                    "index.html".to_string(),
10642                    FragmentList {
10643                        fragments: entry_fragments,
10644                        contains_boundary: false,
10645                    },
10646                ),
10647                (
10648                    "my-card".to_string(),
10649                    FragmentList {
10650                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
10651                        contains_boundary: false,
10652                    },
10653                ),
10654            ]));
10655            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10656            let component = protocol
10657                .components
10658                .entry("my-card".to_string())
10659                .or_default();
10660            component.css = ".card{color:red}".to_string();
10661            component.uses_shadow_dom = false;
10662            protocol.populate_style_closures(&["index.html"]);
10663
10664            let mut writer = TestWriter::new();
10665            handle(
10666                &protocol,
10667                &test_json!({}),
10668                &RenderOptions::new("index.html", "/"),
10669                &mut writer,
10670            )
10671            .expect("render");
10672            let html = writer.get_content();
10673
10674            assert!(
10675                html.to_ascii_lowercase().starts_with("<!doctype html>"),
10676                "the doctype must remain the first document token: {html}"
10677            );
10678            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10679            let document = html.find("<html").expect("document root");
10680            let component = html.find("<my-card>").expect("component host");
10681            assert!(
10682                style < document && style < component,
10683                "headless document styles must precede document content: {html}"
10684            );
10685        }
10686    }
10687
10688    #[test]
10689    fn authored_shadow_component_can_render_as_entry_fragment() {
10690        let mut protocol = WebUIProtocol::new(HashMap::from([(
10691            "my-card".to_string(),
10692            FragmentList {
10693                fragments: vec![
10694                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10695                    structural_fragment("shadow_styles:my-card"),
10696                    WebUIFragment::raw("<p>card</p></template>"),
10697                ],
10698                contains_boundary: false,
10699            },
10700        )]));
10701        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10702        let component = protocol
10703            .components
10704            .entry("my-card".to_string())
10705            .or_default();
10706        component.css = ".card{color:red}".to_string();
10707        component.uses_shadow_dom = true;
10708        protocol.populate_style_closures(&["my-card"]);
10709
10710        let mut writer = TestWriter::new();
10711        handle(
10712            &protocol,
10713            &test_json!({}),
10714            &RenderOptions::new("my-card", "/"),
10715            &mut writer,
10716        )
10717        .expect("render");
10718        let html = writer.get_content();
10719
10720        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10721        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10722        assert!(html.ends_with("<p>card</p></template>"));
10723    }
10724
10725    #[test]
10726    fn component_style_closures_escape_mixed_case_style_end_tags() {
10727        for strategy in [
10728            webui_protocol::CssStrategy::Style,
10729            webui_protocol::CssStrategy::Module,
10730        ] {
10731            let mut protocol = WebUIProtocol::new(HashMap::from([
10732                (
10733                    "index.html".to_string(),
10734                    FragmentList {
10735                        fragments: vec![
10736                            WebUIFragment::raw("<html><head>"),
10737                            structural_fragment("head_end"),
10738                            WebUIFragment::raw("</head><body><safe-card>"),
10739                            WebUIFragment::component("safe-card"),
10740                            WebUIFragment::raw("</safe-card></body></html>"),
10741                        ],
10742                        contains_boundary: false,
10743                    },
10744                ),
10745                (
10746                    "safe-card".to_string(),
10747                    FragmentList {
10748                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10749                        contains_boundary: false,
10750                    },
10751                ),
10752            ]));
10753            protocol.set_css_strategy(strategy);
10754            protocol.components.insert(
10755                "safe-card".to_string(),
10756                webui_protocol::ComponentData {
10757                    css: ".safe{content:'</StYlE>'}".to_string(),
10758                    ..Default::default()
10759                },
10760            );
10761            protocol.populate_style_closures(&["index.html"]);
10762            let mut writer = TestWriter::new();
10763
10764            handle(
10765                &protocol,
10766                &test_json!({}),
10767                &RenderOptions::new("index.html", "/"),
10768                &mut writer,
10769            )
10770            .unwrap();
10771
10772            let html = writer.get_content();
10773            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10774            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10775        }
10776    }
10777
10778    #[test]
10779    fn shadow_closure_supports_link_and_module_ssr_paths() {
10780        for strategy in [
10781            webui_protocol::CssStrategy::Link,
10782            webui_protocol::CssStrategy::Module,
10783        ] {
10784            let mut protocol = WebUIProtocol::new(HashMap::from([
10785                (
10786                    "index.html".to_string(),
10787                    FragmentList {
10788                        fragments: vec![
10789                            WebUIFragment::raw("<html><head>".to_string()),
10790                            structural_fragment("head_end"),
10791                            WebUIFragment::raw("</head><body><my-card>".to_string()),
10792                            WebUIFragment::component("my-card"),
10793                            WebUIFragment::raw("</my-card></body></html>".to_string()),
10794                        ],
10795                        contains_boundary: false,
10796                    },
10797                ),
10798                (
10799                    "my-card".to_string(),
10800                    FragmentList {
10801                        fragments: vec![
10802                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10803                            structural_fragment("shadow_styles:my-card"),
10804                            WebUIFragment::raw("<p>card</p></template>".to_string()),
10805                        ],
10806                        contains_boundary: false,
10807                    },
10808                ),
10809            ]));
10810            protocol.set_css_strategy(strategy);
10811            let component = protocol
10812                .components
10813                .entry("my-card".to_string())
10814                .or_default();
10815            component.css = ".card{color:red}".to_string();
10816            component.css_href = "/my-card.css".to_string();
10817            component.uses_shadow_dom = true;
10818            protocol.populate_style_closures(&["index.html"]);
10819
10820            let mut writer = TestWriter::new();
10821            handle(
10822                &protocol,
10823                &test_json!({}),
10824                &RenderOptions::new("index.html", "/"),
10825                &mut writer,
10826            )
10827            .unwrap();
10828            let html = writer.get_content();
10829            match strategy {
10830                webui_protocol::CssStrategy::Link => {
10831                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10832                    assert!(html.contains(&format!(
10833                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10834                    )));
10835                }
10836                webui_protocol::CssStrategy::Module => {
10837                    assert!(html.contains(
10838                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10839                    ));
10840                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10841                }
10842                webui_protocol::CssStrategy::Style => unreachable!(),
10843            }
10844        }
10845    }
10846
10847    #[test]
10848    fn test_module_preloads_emit_in_head_in_compiler_order() {
10849        // The whole value of these hints is ordering: preloads are issued in
10850        // document order over one connection, so the largest chunk must go
10851        // first. The compiler sorts; the handler must not reorder or dedupe.
10852        let mut fragments = HashMap::new();
10853        fragments.insert(
10854            "index.html".to_string(),
10855            FragmentList {
10856                fragments: vec![
10857                    WebUIFragment::raw(
10858                        r#"<html><head><script type="module" async src="/index.js"></script>"#
10859                            .to_string(),
10860                    ),
10861                    structural_fragment("head_end"),
10862                    WebUIFragment::raw("</head><body>".to_string()),
10863                    structural_fragment("body_end"),
10864                    WebUIFragment::raw("</body></html>".to_string()),
10865                ],
10866                contains_boundary: false,
10867            },
10868        );
10869
10870        let mut protocol = WebUIProtocol::new(fragments);
10871        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
10872
10873        let state = test_json!({});
10874        let mut writer = TestWriter::new();
10875        handle(
10876            &protocol,
10877            &state,
10878            &RenderOptions::new("index.html", "/"),
10879            &mut writer,
10880        )
10881        .unwrap();
10882
10883        let html = writer.get_content();
10884        let head_end = html.find("</head>").expect("</head> missing");
10885        let head = &html[..head_end];
10886        assert!(
10887            head.contains(
10888                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
10889            ),
10890            "hints must appear in <head> in the compiler's order: {html}"
10891        );
10892    }
10893
10894    #[test]
10895    fn test_no_module_preloads_emits_nothing() {
10896        let mut fragments = HashMap::new();
10897        fragments.insert(
10898            "index.html".to_string(),
10899            FragmentList {
10900                fragments: vec![
10901                    WebUIFragment::raw("<html><head>".to_string()),
10902                    structural_fragment("head_end"),
10903                    WebUIFragment::raw("</head><body>".to_string()),
10904                    structural_fragment("body_end"),
10905                    WebUIFragment::raw("</body></html>".to_string()),
10906                ],
10907                contains_boundary: false,
10908            },
10909        );
10910        let protocol = WebUIProtocol::new(fragments);
10911
10912        let state = test_json!({});
10913        let mut writer = TestWriter::new();
10914        handle(
10915            &protocol,
10916            &state,
10917            &RenderOptions::new("index.html", "/"),
10918            &mut writer,
10919        )
10920        .unwrap();
10921
10922        assert!(
10923            !writer.get_content().contains("modulepreload"),
10924            "a build without hints must be byte-identical to before"
10925        );
10926    }
10927
10928    #[test]
10929    fn test_link_strategy_head_links_follow_document_order() {
10930        // Regression for #381: Link-strategy <head> CSS <link> tags must be
10931        // emitted in document/traversal order, not alphabetical tag order.
10932        // Document order here is <z-widget> then <a-widget>; an alphabetical
10933        // sort would (incorrectly) place a-widget first.
10934        let mut fragments = HashMap::new();
10935        fragments.insert(
10936            "index.html".to_string(),
10937            FragmentList {
10938                fragments: vec![
10939                    WebUIFragment::raw("<html><head>".to_string()),
10940                    structural_fragment("head_end"),
10941                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
10942                    WebUIFragment::component("z-widget"),
10943                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
10944                    WebUIFragment::component("a-widget"),
10945                    WebUIFragment::raw("</a-widget>".to_string()),
10946                    structural_fragment("body_end"),
10947                    WebUIFragment::raw("</body></html>".to_string()),
10948                ],
10949                contains_boundary: false,
10950            },
10951        );
10952        fragments.insert(
10953            "z-widget".to_string(),
10954            FragmentList {
10955                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
10956                contains_boundary: false,
10957            },
10958        );
10959        fragments.insert(
10960            "a-widget".to_string(),
10961            FragmentList {
10962                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
10963                contains_boundary: false,
10964            },
10965        );
10966
10967        let mut protocol = WebUIProtocol::new(fragments);
10968        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10969
10970        let z = protocol
10971            .components
10972            .entry("z-widget".to_string())
10973            .or_default();
10974        z.css_href = "z-widget.css".to_string();
10975        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
10976
10977        let a = protocol
10978            .components
10979            .entry("a-widget".to_string())
10980            .or_default();
10981        a.css_href = "a-widget.css".to_string();
10982        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
10983        protocol.populate_style_closures(&["index.html"]);
10984
10985        let state = test_json!({});
10986        let mut writer = TestWriter::new();
10987
10988        handle(
10989            &protocol,
10990            &state,
10991            &RenderOptions::new("index.html", "/"),
10992            &mut writer,
10993        )
10994        .unwrap();
10995
10996        let html = writer.get_content();
10997        let head_end = html.find("</head>").expect("</head> missing");
10998        let head_section = &html[..head_end];
10999
11000        let z_pos = head_section
11001            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11002            .expect("z-widget stylesheet link missing from <head>");
11003        let a_pos = head_section
11004            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11005            .expect("a-widget stylesheet link missing from <head>");
11006
11007        assert!(
11008            z_pos < a_pos,
11009            "CSS <link> tags must follow document order (z-widget before \
11010             a-widget), not alphabetical order: {html}"
11011        );
11012    }
11013
11014    #[test]
11015    fn test_css_module_emitted_in_component_light_dom() {
11016        // CSS module <style> tags are emitted inline in the component's light DOM,
11017        // not in <head>. This keeps SSR output lean — only rendered components
11018        // get their style definitions.
11019        let mut fragments = HashMap::new();
11020        fragments.insert(
11021            "index.html".to_string(),
11022            FragmentList {
11023                fragments: vec![
11024                    WebUIFragment::raw("<html><head>".to_string()),
11025                    structural_fragment("head_end"),
11026                    WebUIFragment::raw("</head><body><my-card>".to_string()),
11027                    WebUIFragment::component("my-card"),
11028                    WebUIFragment::raw("</my-card>".to_string()),
11029                    WebUIFragment::raw("</body></html>".to_string()),
11030                ],
11031                contains_boundary: false,
11032            },
11033        );
11034        fragments.insert(
11035            "my-card".to_string(),
11036            FragmentList {
11037                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11038                contains_boundary: false,
11039            },
11040        );
11041
11042        let mut protocol = WebUIProtocol::new(fragments);
11043        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11044        protocol
11045            .components
11046            .entry("my-card".to_string())
11047            .or_default()
11048            .css = "p{color:red}".to_string();
11049        protocol.populate_style_closures(&["index.html"]);
11050        let state = test_json!({});
11051        let mut writer = TestWriter::new();
11052
11053        handle(
11054            &protocol,
11055            &state,
11056            &RenderOptions::new("index.html", "/"),
11057            &mut writer,
11058        )
11059        .unwrap();
11060
11061        let html = writer.get_content();
11062
11063        // CSS module importmap must be INSIDE the component tag (light DOM)
11064        let tag_open = html.find("<my-card>").expect("<my-card> missing");
11065        let css_pos = html
11066            .find(r#"<script type="importmap""#)
11067            .expect("CSS module importmap missing");
11068        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11069        assert!(
11070            css_pos > tag_open && css_pos < tag_close,
11071            "CSS module should be inside component light DOM: {html}"
11072        );
11073
11074        // <head> should NOT contain module styles
11075        let head_end = html.find("</head>").expect("</head> missing");
11076        assert!(
11077            css_pos > head_end,
11078            "CSS module should not be in <head>: {html}"
11079        );
11080    }
11081
11082    #[test]
11083    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11084        let mut protocol = WebUIProtocol::default();
11085        protocol.fragments = HashMap::from([
11086            (
11087                "index.html".to_string(),
11088                FragmentList {
11089                    fragments: vec![
11090                        WebUIFragment::raw("<legacy-card>"),
11091                        WebUIFragment::component("legacy-card"),
11092                        WebUIFragment::raw("</legacy-card>"),
11093                    ],
11094                    contains_boundary: false,
11095                },
11096            ),
11097            (
11098                "legacy-card".to_string(),
11099                FragmentList {
11100                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11101                    contains_boundary: false,
11102                },
11103            ),
11104        ]);
11105        protocol.components.insert(
11106            "legacy-card".to_string(),
11107            webui_protocol::ComponentData {
11108                css: ".legacy{color:red}".to_string(),
11109                ..Default::default()
11110            },
11111        );
11112        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11113        let mut writer = TestWriter::new();
11114
11115        let error = handle(
11116            &protocol,
11117            &test_json!({}),
11118            &RenderOptions::new("index.html", "/"),
11119            &mut writer,
11120        )
11121        .expect_err("styled protocols require closure metadata");
11122        assert!(error
11123            .to_string()
11124            .contains("component style closure metadata is required"));
11125    }
11126
11127    #[test]
11128    fn test_css_module_emitted_for_route_components() {
11129        // Route components get CSS modules emitted inline in their light DOM.
11130        let template = r#"<h1>Dashboard</h1>"#;
11131
11132        let mut fragments = HashMap::new();
11133        fragments.insert(
11134            "index.html".to_string(),
11135            FragmentList {
11136                fragments: vec![
11137                    WebUIFragment::raw("<html><head>".to_string()),
11138                    structural_fragment("head_end"),
11139                    WebUIFragment::raw("</head><body>".to_string()),
11140                    WebUIFragment::route("/", "dash-page"),
11141                    structural_fragment("body_end"),
11142                    WebUIFragment::raw("</body></html>".to_string()),
11143                ],
11144                contains_boundary: false,
11145            },
11146        );
11147        fragments.insert(
11148            "dash-page".to_string(),
11149            FragmentList {
11150                fragments: vec![WebUIFragment::raw(template.to_string())],
11151                contains_boundary: false,
11152            },
11153        );
11154
11155        let mut protocol = WebUIProtocol::new(fragments);
11156        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11157        let comp = protocol
11158            .components
11159            .entry("dash-page".to_string())
11160            .or_default();
11161        comp.css = "h1{font-size:2rem}".to_string();
11162        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11163        protocol.populate_style_closures(&["index.html"]);
11164        let state = test_json!({});
11165        let mut writer = TestWriter::new();
11166
11167        handle(
11168            &protocol,
11169            &state,
11170            &RenderOptions::new("index.html", "/"),
11171            &mut writer,
11172        )
11173        .unwrap();
11174
11175        let html = writer.get_content();
11176
11177        assert!(
11178            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11179            "Route component should have CSS module importmap with data: URI: {html}"
11180        );
11181        assert!(
11182            html.contains("<h1>Dashboard</h1>"),
11183            "Route component should render content: {html}"
11184        );
11185        let fallback = html
11186            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11187            .expect("route Module fallback");
11188        let host_open = html.find("<dash-page>").expect("route host");
11189        let content = html.find("<h1>Dashboard</h1>").expect("route content");
11190        assert!(
11191            fallback < host_open && host_open < content,
11192            "the Module fallback must precede the generated Light route host: {html}"
11193        );
11194        // The importmap belongs to the component's own Light DOM, never to the
11195        // route element. The router only treats `<link>`/`<style>` markers as
11196        // route-owned styles, so an importmap emitted as a direct route child
11197        // would be cleared on navigation and mistaken for the mounted
11198        // component. Keep it inside the host so that can never happen.
11199        let importmap = html
11200            .find(r#"<script type="importmap""#)
11201            .expect("route CSS module importmap");
11202        assert!(
11203            host_open < importmap,
11204            "the CSS module importmap must live inside the route host, not as a route child: {html}"
11205        );
11206    }
11207
11208    #[test]
11209    fn test_head_css_link_skipped_for_components_without_css() {
11210        // Regression: components without CSS files must not get <link> tags
11211        // in <head>, otherwise the browser requests a 404.
11212        let mut fragments = HashMap::new();
11213        fragments.insert(
11214            "index.html".to_string(),
11215            FragmentList {
11216                fragments: vec![
11217                    WebUIFragment::raw("<html><head>".to_string()),
11218                    structural_fragment("head_end"),
11219                    WebUIFragment::raw("</head><body>".to_string()),
11220                    WebUIFragment::component("has-css"),
11221                    WebUIFragment::component("no-css"),
11222                    structural_fragment("body_end"),
11223                    WebUIFragment::raw("</body></html>".to_string()),
11224                ],
11225                contains_boundary: false,
11226            },
11227        );
11228        fragments.insert(
11229            "has-css".to_string(),
11230            FragmentList {
11231                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11232                contains_boundary: false,
11233            },
11234        );
11235        fragments.insert(
11236            "no-css".to_string(),
11237            FragmentList {
11238                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11239                contains_boundary: false,
11240            },
11241        );
11242
11243        let mut protocol = WebUIProtocol::new(fragments);
11244        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11245
11246        // Only has-css has an external stylesheet (Link strategy)
11247        protocol
11248            .components
11249            .entry("has-css".to_string())
11250            .or_default()
11251            .css_href = "has-css.css".to_string();
11252        protocol
11253            .components
11254            .entry("no-css".to_string())
11255            .or_default()
11256            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11257        protocol.populate_style_closures(&["index.html"]);
11258
11259        let state = test_json!({});
11260        let mut writer = TestWriter::new();
11261
11262        let handler = WebUIHandler::with_plugin(|| {
11263            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11264        });
11265        handler
11266            .handle(
11267                &protocol,
11268                &state,
11269                &RenderOptions::new("index.html", "/"),
11270                &mut writer,
11271            )
11272            .unwrap();
11273
11274        let html = writer.get_content();
11275        assert!(
11276            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11277            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11278        );
11279        assert!(
11280            !html.contains("no-css.css"),
11281            "Component without CSS must NOT get a <link> in <head>: {html}"
11282        );
11283    }
11284
11285    #[test]
11286    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11287        // Simulates a page where app-shell renders cart-panel, but cart-panel
11288        // contains an <if> block with product-card inside. When the condition
11289        // is false (empty cart), product-card is NOT rendered — but it IS
11290        // reachable from the fragment graph. Its template metadata and CSS module
11291        // definition must be in the output so the client can mount it when
11292        // the <if> flips true. However, its bit must NOT be set in the
11293        // inventory — the inventory tracks what was actually rendered.
11294        let mut fragments = HashMap::new();
11295        fragments.insert(
11296            "index.html".to_string(),
11297            FragmentList {
11298                fragments: vec![
11299                    WebUIFragment::raw("<html><head>".to_string()),
11300                    structural_fragment("head_end"),
11301                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11302                    WebUIFragment::component("app-shell"),
11303                    WebUIFragment::raw("</app-shell>".to_string()),
11304                    structural_fragment("body_end"),
11305                    WebUIFragment::raw("</body></html>".to_string()),
11306                ],
11307                contains_boundary: false,
11308            },
11309        );
11310        // app-shell contains a cart panel
11311        fragments.insert(
11312            "app-shell".to_string(),
11313            FragmentList {
11314                fragments: vec![
11315                    WebUIFragment::raw("<div>Shell</div>".to_string()),
11316                    WebUIFragment::component("cart-panel"),
11317                ],
11318                contains_boundary: false,
11319            },
11320        );
11321        // cart-panel has an <if> block containing product-card
11322        fragments.insert(
11323            "cart-panel".to_string(),
11324            FragmentList {
11325                fragments: vec![
11326                    WebUIFragment::raw("<aside>".to_string()),
11327                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11328                    WebUIFragment::raw("</aside>".to_string()),
11329                ],
11330                contains_boundary: false,
11331            },
11332        );
11333        // cart-items (if block body) contains product-card
11334        fragments.insert(
11335            "cart-items".to_string(),
11336            FragmentList {
11337                fragments: vec![WebUIFragment::component("product-card")],
11338                contains_boundary: false,
11339            },
11340        );
11341        fragments.insert(
11342            "product-card".to_string(),
11343            FragmentList {
11344                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11345                contains_boundary: false,
11346            },
11347        );
11348
11349        let mut protocol = WebUIProtocol::new(fragments);
11350        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11351        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11352        for name in ["app-shell", "cart-panel", "product-card"] {
11353            let comp = protocol.components.entry(name.to_string()).or_default();
11354            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11355            comp.css = format!(".{name}{{display:block}}");
11356            if name == "cart-panel" {
11357                comp.hydration_mode = StateProjectionMode::Keys as i32;
11358                comp.hydration_keys = vec!["hasItems".to_string()];
11359            }
11360            if name == "product-card" {
11361                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11362            }
11363        }
11364        protocol.populate_style_closures(&["index.html"]);
11365
11366        // Render with hasItems=false — product-card should NOT be rendered
11367        let state = test_json!({ "hasItems": false });
11368        let mut writer = TestWriter::new();
11369
11370        let handler = WebUIHandler::with_plugin(|| {
11371            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11372        });
11373        handler
11374            .handle(
11375                &protocol,
11376                &state,
11377                &RenderOptions::new("index.html", "/"),
11378                &mut writer,
11379            )
11380            .unwrap();
11381
11382        let html = writer.get_content();
11383
11384        assert!(
11385            html.contains(r#"<script type="application/json" id="webui-data">"#),
11386            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11387        );
11388        assert!(
11389            html.contains(r#""state":{"hasItems":false}"#),
11390            "SSR state should live in the JSON data block: {html}"
11391        );
11392        assert!(
11393            html.contains(r#""inventory":"#),
11394            "SSR inventory should live in the JSON data block: {html}"
11395        );
11396        assert!(
11397            !html.contains("window.__webui={\""),
11398            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11399        );
11400        assert!(
11401            !html.contains(r#"document.getElementById("webui-data")"#),
11402            "SSR must not parse webui-data; client packages own that lazy load: {html}"
11403        );
11404        assert!(
11405            !html.contains("window.__webui=w;"),
11406            "executable bootstrap must not replace existing window.__webui registrations: {html}"
11407        );
11408        assert!(
11409            !html.contains("w.templateFns={\""),
11410            "template function emission must not replace existing templateFns registrations: {html}"
11411        );
11412        assert!(
11413            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11414            "template functions should merge into the flat templateFns registry: {html}"
11415        );
11416
11417        // product-card template IS in the output — it's a known component
11418        // whose template must be available for client-side <if> activation.
11419        assert!(
11420            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11421            "product-card template should be emitted even when unrendered: {html}"
11422        );
11423
11424        // product-card CSS module IS in the output — reachable components need
11425        // their stylesheet definitions for client-side <if> activation.
11426        assert!(
11427            html.contains(r#""product-card":"data:text/css,"#),
11428            "reachable product-card CSS module importmap should be emitted: {html}"
11429        );
11430
11431        // app-shell and cart-panel SHOULD be in the output (they were rendered)
11432        assert!(
11433            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11434            "rendered app-shell template should be emitted: {html}"
11435        );
11436        assert!(
11437            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11438            "rendered cart-panel template should be emitted: {html}"
11439        );
11440    }
11441
11442    #[test]
11443    fn component_asset_manifest_falls_back_to_body_start() {
11444        let fragments = HashMap::from([(
11445            "index.html".to_string(),
11446            FragmentList {
11447                fragments: vec![
11448                    WebUIFragment::raw("<body>".to_string()),
11449                    structural_fragment("body_start"),
11450                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11451                ],
11452                contains_boundary: false,
11453            },
11454        )]);
11455        let mut protocol = WebUIProtocol::new(fragments);
11456        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11457            root: "lazy-panel".to_string(),
11458            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11459        }];
11460        let mut writer = TestWriter::new();
11461
11462        handle(
11463            &protocol,
11464            &Value::Null,
11465            &RenderOptions::new("index.html", "/"),
11466            &mut writer,
11467        )
11468        .unwrap();
11469
11470        assert_eq!(
11471            writer.get_content(),
11472            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11473        );
11474    }
11475
11476    #[test]
11477    fn component_asset_styles_emit_once_when_body_precedes_head() {
11478        let fragments = HashMap::from([(
11479            "index.html".to_string(),
11480            FragmentList {
11481                fragments: vec![
11482                    WebUIFragment::raw("<body>".to_string()),
11483                    structural_fragment("body_start"),
11484                    WebUIFragment::raw("</body><head>".to_string()),
11485                    structural_fragment("head_end"),
11486                    WebUIFragment::raw("</head>".to_string()),
11487                ],
11488                contains_boundary: false,
11489            },
11490        )]);
11491        let mut protocol = WebUIProtocol::new(fragments);
11492        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11493            root: "lazy-panel".to_string(),
11494            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11495        }];
11496        let mut writer = TestWriter::new();
11497
11498        handle(
11499            &protocol,
11500            &Value::Null,
11501            &RenderOptions::new("index.html", "/"),
11502            &mut writer,
11503        )
11504        .unwrap();
11505
11506        assert_eq!(
11507            writer
11508                .get_content()
11509                .matches(r#"id="webui-component-assets""#)
11510                .count(),
11511            1
11512        );
11513    }
11514
11515    #[test]
11516    fn light_component_asset_styles_apply_at_head_end() {
11517        let fragments = HashMap::from([(
11518            "index.html".to_string(),
11519            FragmentList {
11520                fragments: vec![
11521                    WebUIFragment::raw("<html><head>".to_string()),
11522                    structural_fragment("head_end"),
11523                    WebUIFragment::raw("</head><body></body></html>".to_string()),
11524                ],
11525                contains_boundary: false,
11526            },
11527        )]);
11528        let mut protocol = WebUIProtocol::new(fragments);
11529        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11530        for root in ["lazy-panel", "secondary-panel"] {
11531            protocol.components.insert(
11532                root.to_string(),
11533                webui_protocol::ComponentData {
11534                    uses_shadow_dom: false,
11535                    ..Default::default()
11536                },
11537            );
11538        }
11539        protocol.component_asset_style_preloads = vec![
11540            ComponentAssetStylePreload {
11541                root: "lazy-panel".to_string(),
11542                style_hrefs: vec![
11543                    "/assets/lazy-panel.css".to_string(),
11544                    "/assets/shared.css".to_string(),
11545                ],
11546            },
11547            ComponentAssetStylePreload {
11548                root: "secondary-panel".to_string(),
11549                style_hrefs: vec![
11550                    "/assets/secondary-panel.css".to_string(),
11551                    "/assets/shared.css".to_string(),
11552                ],
11553            },
11554        ];
11555        let mut writer = TestWriter::new();
11556
11557        handle(
11558            &protocol,
11559            &Value::Null,
11560            &RenderOptions::new("index.html", "/"),
11561            &mut writer,
11562        )
11563        .unwrap();
11564
11565        let html = writer.get_content();
11566        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11567        assert_eq!(html.matches("/assets/shared.css").count(), 1);
11568        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11569        let links_end = html
11570            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11571            .expect("asset stylesheet must be emitted");
11572        let head_end = html.find("</head>").expect("head must close");
11573        assert!(links_end < head_end);
11574    }
11575
11576    // ── CSP nonce on CSS module importmap ───────────────────────────
11577    //
11578    // When `RenderOptions::with_nonce(...)` is set, every inline
11579    // `<script type="importmap">` definition emitted during SSR for a
11580    // component CSS module must include `nonce="VALUE"` so strict CSP
11581    // `script-src 'nonce-...'` policies allow it. The without-nonce case
11582    // is already covered by other CSS module tests (e.g.
11583    // `test_css_module_emitted_for_route_components`).
11584
11585    #[test]
11586    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11587        // Per-component first-render path (`emit_css_module`).
11588        let template = r#"<h1>Dashboard</h1>"#;
11589
11590        let mut fragments = HashMap::new();
11591        fragments.insert(
11592            "index.html".to_string(),
11593            FragmentList {
11594                fragments: vec![
11595                    WebUIFragment::raw("<html><head>".to_string()),
11596                    structural_fragment("head_end"),
11597                    WebUIFragment::raw("</head><body>".to_string()),
11598                    WebUIFragment::route("/", "dash-page"),
11599                    structural_fragment("body_end"),
11600                    WebUIFragment::raw("</body></html>".to_string()),
11601                ],
11602                contains_boundary: false,
11603            },
11604        );
11605        fragments.insert(
11606            "dash-page".to_string(),
11607            FragmentList {
11608                fragments: vec![WebUIFragment::raw(template.to_string())],
11609                contains_boundary: false,
11610            },
11611        );
11612
11613        let mut protocol = WebUIProtocol::new(fragments);
11614        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11615        let comp = protocol
11616            .components
11617            .entry("dash-page".to_string())
11618            .or_default();
11619        comp.css = "h1{font-size:2rem}".to_string();
11620        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11621        protocol.populate_style_closures(&["index.html"]);
11622        let state = test_json!({});
11623        let mut writer = TestWriter::new();
11624
11625        handle(
11626            &protocol,
11627            &state,
11628            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11629            &mut writer,
11630        )
11631        .unwrap();
11632
11633        let html = writer.get_content();
11634
11635        assert!(
11636            html.contains(
11637                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11638            ),
11639            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11640        );
11641    }
11642
11643    #[test]
11644    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11645        // Body-end emission path for reachable-but-unrendered components
11646        // (the second site touched by the patch). Triggered via a false
11647        // `<if>` block under hydration; requires the WebUI plugin so the
11648        // body_end hook executes.
11649        let mut fragments = HashMap::new();
11650        fragments.insert(
11651            "index.html".to_string(),
11652            FragmentList {
11653                fragments: vec![
11654                    WebUIFragment::raw("<html><head>".to_string()),
11655                    structural_fragment("head_end"),
11656                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
11657                    WebUIFragment::component("app-shell"),
11658                    WebUIFragment::raw("</app-shell>".to_string()),
11659                    structural_fragment("body_end"),
11660                    WebUIFragment::raw("</body></html>".to_string()),
11661                ],
11662                contains_boundary: false,
11663            },
11664        );
11665        fragments.insert(
11666            "app-shell".to_string(),
11667            FragmentList {
11668                fragments: vec![WebUIFragment::if_cond(
11669                    ConditionExpr::identifier("hasItems"),
11670                    "cart-items",
11671                )],
11672                contains_boundary: false,
11673            },
11674        );
11675        fragments.insert(
11676            "cart-items".to_string(),
11677            FragmentList {
11678                fragments: vec![WebUIFragment::component("product-card")],
11679                contains_boundary: false,
11680            },
11681        );
11682        fragments.insert(
11683            "product-card".to_string(),
11684            FragmentList {
11685                fragments: vec![WebUIFragment::raw("<div>Card</div>".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        for name in ["app-shell", "product-card"] {
11693            let comp = protocol.components.entry(name.to_string()).or_default();
11694            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11695            comp.css = format!(".{name}{{display:block}}");
11696        }
11697        protocol.populate_style_closures(&["index.html"]);
11698
11699        // Render with hasItems=false so product-card is reachable but not
11700        // rendered, forcing its CSS module emission through the body_end path.
11701        let state = test_json!({ "hasItems": false });
11702        let mut writer = TestWriter::new();
11703
11704        let handler = WebUIHandler::with_plugin(|| {
11705            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11706        });
11707        handler
11708            .handle(
11709                &protocol,
11710                &state,
11711                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11712                &mut writer,
11713            )
11714            .unwrap();
11715
11716        let html = writer.get_content();
11717
11718        assert!(
11719            html.contains(
11720                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11721            ),
11722            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11723        );
11724    }
11725
11726    #[test]
11727    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11728        let mut fragments = HashMap::new();
11729        fragments.insert(
11730            "index.html".to_string(),
11731            FragmentList {
11732                fragments: vec![
11733                    WebUIFragment::raw("<html><body><style>".to_string()),
11734                    WebUIFragment::signal("tokens.light", true),
11735                    WebUIFragment::raw("</style>".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::raw("<span>shell</span>".to_string())],
11746                contains_boundary: false,
11747            },
11748        );
11749        let index_fragments = fragments
11750            .get_mut("index.html")
11751            .expect("index fixture should exist");
11752        index_fragments
11753            .fragments
11754            .insert(1, WebUIFragment::component("app-shell"));
11755        let mut protocol = WebUIProtocol::new(fragments);
11756        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11757        // Only `name` is a hydration key. `tokens` is a server-only field
11758        // (used above to resolve SSR CSS variables) and is NOT in the component
11759        // hydration keys,
11760        // so projection MUST keep it out of the client state block.
11761        protocol.components.insert(
11762            "app-shell".to_string(),
11763            webui_protocol::ComponentData {
11764                hydration_mode: StateProjectionMode::Keys as i32,
11765                hydration_keys: vec!["name".to_string()],
11766                ..Default::default()
11767            },
11768        );
11769        let state = test_json!({
11770            "name": "Alice",
11771            "tokens": {
11772                "light": "--color-brand: red;"
11773            }
11774        });
11775        let mut writer = TestWriter::new();
11776        let handler = WebUIHandler::with_plugin(|| {
11777            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11778        });
11779        handler.handle(
11780            &protocol,
11781            &state,
11782            &RenderOptions::new("index.html", "/"),
11783            &mut writer,
11784        )?;
11785        let output = writer.get_content();
11786
11787        // SSR still reads `tokens` to resolve the inline <style>...
11788        assert!(output.contains("--color-brand: red;"));
11789        // ...but only the hydration key reaches the client state.
11790        assert!(output.contains(r#""name":"Alice""#));
11791        assert!(!output.contains(r#""tokens""#));
11792        Ok(())
11793    }
11794
11795    #[test]
11796    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11797        let mut fragments = HashMap::new();
11798        fragments.insert(
11799            "index.html".to_string(),
11800            FragmentList {
11801                fragments: vec![
11802                    WebUIFragment::raw("<html><body>"),
11803                    structural_fragment("body_end"),
11804                    WebUIFragment::raw("</body></html>"),
11805                ],
11806                contains_boundary: false,
11807            },
11808        );
11809        let protocol = WebUIProtocol::new(fragments);
11810        let state = test_json!({
11811            "client": "visible",
11812            "serverOnly": "also preserved",
11813        });
11814        let handler = WebUIHandler::with_plugin(|| {
11815            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11816        });
11817        let mut writer = TestWriter::new();
11818        handler.handle(
11819            &protocol,
11820            &state,
11821            &RenderOptions::new("index.html", "/"),
11822            &mut writer,
11823        )?;
11824        let output = writer.get_content();
11825        assert!(output.contains(r#""client":"visible""#));
11826        assert!(output.contains(r#""serverOnly":"also preserved""#));
11827        Ok(())
11828    }
11829
11830    #[test]
11831    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11832        let mut fragments = HashMap::new();
11833        fragments.insert(
11834            "index.html".to_string(),
11835            FragmentList {
11836                fragments: vec![
11837                    WebUIFragment::raw("<html><body>"),
11838                    WebUIFragment::component("app-shell"),
11839                    structural_fragment("body_end"),
11840                    WebUIFragment::raw("</body></html>"),
11841                ],
11842                contains_boundary: false,
11843            },
11844        );
11845        fragments.insert(
11846            "app-shell".to_string(),
11847            FragmentList {
11848                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11849                contains_boundary: false,
11850            },
11851        );
11852        let mut protocol = WebUIProtocol::new(fragments);
11853        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11854        protocol.components.insert(
11855            "app-shell".to_string(),
11856            webui_protocol::ComponentData {
11857                hydration_mode: StateProjectionMode::All as i32,
11858                ..Default::default()
11859            },
11860        );
11861        let state = test_json!({
11862            "known": "value",
11863            "possiblyInherited": "must not be dropped",
11864        });
11865        let handler = WebUIHandler::with_plugin(|| {
11866            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11867        });
11868        let mut writer = TestWriter::new();
11869        handler.handle(
11870            &protocol,
11871            &state,
11872            &RenderOptions::new("index.html", "/"),
11873            &mut writer,
11874        )?;
11875        let output = writer.get_content();
11876        assert!(output.contains(r#""known":"value""#));
11877        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
11878        Ok(())
11879    }
11880
11881    #[test]
11882    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
11883        let mut fragments = HashMap::new();
11884        fragments.insert(
11885            "index.html".to_string(),
11886            FragmentList {
11887                fragments: vec![
11888                    WebUIFragment::raw("<html><body>"),
11889                    WebUIFragment::component("app-shell"),
11890                    structural_fragment("body_end"),
11891                    WebUIFragment::raw("</body></html>"),
11892                ],
11893                contains_boundary: false,
11894            },
11895        );
11896        fragments.insert(
11897            "app-shell".to_string(),
11898            FragmentList {
11899                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11900                contains_boundary: false,
11901            },
11902        );
11903        let mut protocol = WebUIProtocol::new(fragments);
11904        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11905        let state = test_json!({
11906            "known": "value",
11907            "serverOnly": "must not be dropped",
11908        });
11909        let handler = WebUIHandler::with_plugin(|| {
11910            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11911        });
11912        let mut writer = TestWriter::new();
11913        handler.handle(
11914            &protocol,
11915            &state,
11916            &RenderOptions::new("index.html", "/"),
11917            &mut writer,
11918        )?;
11919        let output = writer.get_content();
11920        assert!(output.contains(r#""known":"value""#));
11921        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11922        Ok(())
11923    }
11924
11925    #[test]
11926    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
11927        let mut fragments = HashMap::new();
11928        fragments.insert(
11929            "index.html".to_string(),
11930            FragmentList {
11931                fragments: vec![
11932                    WebUIFragment::raw("<html><body>"),
11933                    WebUIFragment::component("app-shell"),
11934                    structural_fragment("body_end"),
11935                    WebUIFragment::raw("</body></html>"),
11936                ],
11937                contains_boundary: false,
11938            },
11939        );
11940        fragments.insert(
11941            "app-shell".to_string(),
11942            FragmentList {
11943                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11944                contains_boundary: false,
11945            },
11946        );
11947        let mut protocol = WebUIProtocol::new(fragments);
11948        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11949        protocol.components.insert(
11950            "app-shell".to_string(),
11951            webui_protocol::ComponentData {
11952                hydration_mode: i32::MAX,
11953                ..Default::default()
11954            },
11955        );
11956        let state = test_json!({
11957            "known": "value",
11958            "serverOnly": "must not be dropped",
11959        });
11960        let handler = WebUIHandler::with_plugin(|| {
11961            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11962        });
11963        let mut writer = TestWriter::new();
11964        handler.handle(
11965            &protocol,
11966            &state,
11967            &RenderOptions::new("index.html", "/"),
11968            &mut writer,
11969        )?;
11970        let output = writer.get_content();
11971        assert!(output.contains(r#""known":"value""#));
11972        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11973        Ok(())
11974    }
11975
11976    #[test]
11977    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
11978        let mut protocol = WebUIProtocol::new(HashMap::new());
11979        protocol.components.insert(
11980            "app-shell".to_string(),
11981            webui_protocol::ComponentData {
11982                navigation_keys: vec!["selected".to_string()],
11983                ..Default::default()
11984            },
11985        );
11986
11987        match collect_navigation_state(&protocol, ["app-shell"]) {
11988            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
11989            StateSelection::Full | StateSelection::KeyIds(_) => {
11990                panic!("legacy navigation keys should remain owned and projected")
11991            }
11992        }
11993    }
11994
11995    #[test]
11996    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
11997        let mut protocol = WebUIProtocol::new(HashMap::new());
11998        protocol.components.insert(
11999            "app-shell".to_string(),
12000            webui_protocol::ComponentData::default(),
12001        );
12002
12003        assert!(matches!(
12004            collect_navigation_state(&protocol, ["app-shell"]),
12005            StateSelection::Full
12006        ));
12007    }
12008
12009    #[test]
12010    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12011        let mut fragments = HashMap::new();
12012        fragments.insert(
12013            "index.html".to_string(),
12014            FragmentList {
12015                fragments: vec![
12016                    WebUIFragment::raw("<html><body>".to_string()),
12017                    structural_fragment("body_end"),
12018                    WebUIFragment::raw("</body></html>".to_string()),
12019                ],
12020                contains_boundary: false,
12021            },
12022        );
12023        let mut protocol = WebUIProtocol::new(fragments);
12024        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12025        let state = test_json!({
12026            "title": "Legacy state",
12027            "serverOnly": "preserved",
12028        });
12029        let handler = WebUIHandler::with_plugin(|| {
12030            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12031        });
12032
12033        let mut writer = TestWriter::new();
12034        handler.handle(
12035            &protocol,
12036            &state,
12037            &RenderOptions::new("index.html", "/"),
12038            &mut writer,
12039        )?;
12040        assert!(writer.get_content().contains(r#""state":{}"#));
12041        assert!(!writer.get_content().contains("Legacy state"));
12042        assert!(!writer.get_content().contains("preserved"));
12043        Ok(())
12044    }
12045
12046    #[test]
12047    fn scriptless_component_state_is_navigation_only() -> Result<()> {
12048        let mut fragments = HashMap::new();
12049        fragments.insert(
12050            "index.html".to_string(),
12051            FragmentList {
12052                fragments: vec![
12053                    WebUIFragment::raw("<html><body>"),
12054                    WebUIFragment::component("items-page"),
12055                    structural_fragment("body_end"),
12056                    WebUIFragment::raw("</body></html>"),
12057                ],
12058                contains_boundary: false,
12059            },
12060        );
12061        fragments.insert(
12062            "items-page".to_string(),
12063            FragmentList {
12064                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12065                contains_boundary: false,
12066            },
12067        );
12068        let mut protocol = WebUIProtocol::new(fragments);
12069        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12070        protocol.components.insert(
12071            "items-page".to_string(),
12072            webui_protocol::ComponentData {
12073                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12074                navigation_mode: Some(StateProjectionMode::Keys as i32),
12075                navigation_keys: vec!["items".into()],
12076                ..Default::default()
12077            },
12078        );
12079        let state = test_json!({
12080            "items": ["STATE_SENTINEL"],
12081            "serverOnly": "SECRET_SENTINEL",
12082        });
12083        let handler = WebUIHandler::with_plugin(|| {
12084            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12085        });
12086        let mut writer = TestWriter::new();
12087
12088        handler.handle(
12089            &protocol,
12090            &state,
12091            &RenderOptions::new("index.html", "/"),
12092            &mut writer,
12093        )?;
12094
12095        let output = writer.get_content();
12096        assert!(output.contains(r#""state":{}"#));
12097        assert!(!output.contains("STATE_SENTINEL"));
12098        assert!(!output.contains("SECRET_SENTINEL"));
12099        Ok(())
12100    }
12101
12102    #[test]
12103    fn write_selected_state_projects_and_escapes() {
12104        // `keep` is in the sorted key set and its value contains a `</` that
12105        // must be escaped; `drop` is absent and must be projected out.
12106        let state = test_json!({
12107            "drop": "secret",
12108            "keep": "</script><b>"
12109        });
12110        let keys = ["keep"];
12111        let selection = StateSelection::Keys(keys.to_vec());
12112        let mut sink = TestWriter::new();
12113        let mut scratch = Vec::new();
12114        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12115        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12116    }
12117
12118    #[test]
12119    fn write_selected_state_non_object_projection_emits_empty_object() {
12120        let state = test_json!("scalar state has nothing hydratable");
12121        let keys: [&str; 0] = [];
12122        let selection = StateSelection::Keys(keys.to_vec());
12123        let mut sink = TestWriter::new();
12124        let mut scratch = Vec::new();
12125        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12126        assert_eq!(sink.get_content(), "{}");
12127    }
12128
12129    #[test]
12130    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12131        let state = test_json!({
12132            "keptA": 1,
12133            "keptB": 2,
12134            "serverOnlyA": 3,
12135            "serverOnlyB": 4,
12136        });
12137        let keys = ["keptA", "keptA", "keptB", "missing"];
12138        let selection = StateSelection::Keys(keys.to_vec());
12139        let mut sink = TestWriter::new();
12140        let mut scratch = Vec::new();
12141        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12142        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12143    }
12144
12145    #[test]
12146    fn write_selected_state_map_first_matches_schema_first_output() {
12147        let state = test_json!({
12148            "keptA": 1,
12149            "keptB": 2,
12150        });
12151        let keys = ["keptA", "keptB", "missingA", "missingB"];
12152        let selection = StateSelection::Keys(keys.to_vec());
12153        let mut sink = TestWriter::new();
12154        let mut scratch = Vec::new();
12155        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12156        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12157    }
12158
12159    #[test]
12160    fn write_selected_state_full_preserves_and_escapes_state() {
12161        let state = test_json!({
12162            "serverOnly": "</script><b>",
12163            "value": 42,
12164        });
12165        let mut sink = TestWriter::new();
12166        let mut scratch = Vec::new();
12167        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12168        assert_eq!(
12169            sink.get_content(),
12170            r#"{"serverOnly":"<\/script><b>","value":42}"#
12171        );
12172    }
12173
12174    #[test]
12175    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12176        let preloads = vec![ComponentAssetStylePreload {
12177            root: "lazy-panel".to_string(),
12178            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12179        }];
12180
12181        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12182
12183        assert_eq!(
12184            manifest,
12185            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12186        );
12187    }
12188
12189    #[test]
12190    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12191        // Simulates several checkpoint bootstraps serialized in one render: a
12192        // single request-local buffer must grow once for the largest payload and
12193        // then be reused for subsequent payloads without reallocating. This is
12194        // the deterministic seam behind the streaming path's allocation gate —
12195        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
12196        let mut sink = TestWriter::new();
12197        let mut scratch: Vec<u8> = Vec::new();
12198        // No allocation until serialization actually needs it.
12199        assert_eq!(scratch.capacity(), 0);
12200
12201        // The largest payload first establishes the high-water capacity.
12202        let mut large = serde_json::Map::new();
12203        for index in 0..64 {
12204            large.insert(
12205                format!("component_{index:04}"),
12206                Value::String("x".repeat(48)),
12207            );
12208        }
12209        let large = Value::Object(large);
12210        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12211        let high_water = scratch.capacity();
12212        assert!(high_water > 0);
12213
12214        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
12215        // never grows (no per-checkpoint allocation) and never shrinks.
12216        for seq in 0..8 {
12217            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12218            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12219            assert_eq!(
12220                scratch.capacity(),
12221                high_water,
12222                "scratch reallocated on checkpoint {seq}"
12223            );
12224        }
12225    }
12226
12227    #[test]
12228    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12229        let mut fragments = HashMap::new();
12230        fragments.insert(
12231            "index.html".to_string(),
12232            FragmentList {
12233                fragments: vec![
12234                    WebUIFragment::raw("<html><body>"),
12235                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12236                        path: "/".to_string(),
12237                        fragment_id: "home-page".to_string(),
12238                        exact: true,
12239                        ..Default::default()
12240                    }),
12241                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12242                        path: "/admin".to_string(),
12243                        fragment_id: "admin-page".to_string(),
12244                        exact: true,
12245                        ..Default::default()
12246                    }),
12247                    structural_fragment("body_end"),
12248                    WebUIFragment::raw("</body></html>"),
12249                ],
12250
12251                contains_boundary: false,
12252            },
12253        );
12254        fragments.insert(
12255            "home-page".to_string(),
12256            FragmentList {
12257                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12258                contains_boundary: false,
12259            },
12260        );
12261        fragments.insert(
12262            "admin-page".to_string(),
12263            FragmentList {
12264                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12265                contains_boundary: false,
12266            },
12267        );
12268
12269        let mut protocol = WebUIProtocol::new(fragments);
12270        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12271        protocol.components.insert(
12272            "home-page".to_string(),
12273            webui_protocol::ComponentData {
12274                template_json: "{}".to_string(),
12275                hydration_mode: StateProjectionMode::Keys as i32,
12276                hydration_keys: vec!["homeTitle".to_string()],
12277                ..Default::default()
12278            },
12279        );
12280        protocol.components.insert(
12281            "admin-page".to_string(),
12282            webui_protocol::ComponentData {
12283                template_json: "{}".to_string(),
12284                hydration_mode: StateProjectionMode::Keys as i32,
12285                hydration_keys: vec!["adminToken".to_string()],
12286                ..Default::default()
12287            },
12288        );
12289        let state = test_json!({
12290            "homeTitle": "Welcome",
12291            "adminToken": "TOP_SECRET_SENTINEL",
12292        });
12293        let handler = WebUIHandler::with_plugin(|| {
12294            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12295        });
12296        let mut writer = TestWriter::new();
12297        handler.handle(
12298            &protocol,
12299            &state,
12300            &RenderOptions::new("index.html", "/"),
12301            &mut writer,
12302        )?;
12303        let output = writer.get_content();
12304        assert!(output.contains(r#""homeTitle":"Welcome""#));
12305        assert!(!output.contains("TOP_SECRET_SENTINEL"));
12306        assert!(!output.contains(r#""adminToken""#));
12307        Ok(())
12308    }
12309
12310    #[test]
12311    fn test_component_attr_name_aria() {
12312        // component_attr_name correctly maps ARIA attributes via the shared table
12313        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12314        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12315        assert_eq!(
12316            component_attr_name("aria-activedescendant"),
12317            "ariaActiveDescendant"
12318        );
12319        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12320        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12321    }
12322
12323    #[test]
12324    fn test_component_attr_name_html_global() {
12325        assert_eq!(component_attr_name("readonly"), "readOnly");
12326        assert_eq!(component_attr_name("tabindex"), "tabIndex");
12327        assert_eq!(component_attr_name("accesskey"), "accessKey");
12328        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12329        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12330        assert_eq!(component_attr_name("inputmode"), "inputMode");
12331        assert_eq!(component_attr_name("maxlength"), "maxLength");
12332        assert_eq!(component_attr_name("minlength"), "minLength");
12333        assert_eq!(component_attr_name("novalidate"), "noValidate");
12334        assert_eq!(component_attr_name("formaction"), "formAction");
12335        assert_eq!(component_attr_name("ismap"), "isMap");
12336        assert_eq!(component_attr_name("usemap"), "useMap");
12337    }
12338
12339    #[test]
12340    fn test_component_attr_name_strips_colon() {
12341        assert_eq!(component_attr_name(":readonly"), "readOnly");
12342        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12343        assert_eq!(component_attr_name(":data-title"), "dataTitle");
12344    }
12345
12346    #[test]
12347    fn test_component_attr_name_regular() {
12348        assert_eq!(component_attr_name("data-title"), "dataTitle");
12349        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12350        assert_eq!(component_attr_name("simple"), "simple");
12351    }
12352
12353    // ── allowed_query SSR emission tests ─────────────────────────────
12354
12355    fn make_query_route_protocol() -> WebUIProtocol {
12356        use webui_protocol::WebUiFragmentRoute;
12357
12358        let mut fragments = HashMap::new();
12359
12360        fragments.insert(
12361            "index.html".to_string(),
12362            FragmentList {
12363                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12364                    path: "/".into(),
12365                    fragment_id: "app-shell".into(),
12366                    exact: false,
12367                    children: vec![
12368                        WebUiFragmentRoute {
12369                            path: "compose".into(),
12370                            fragment_id: "compose-page".into(),
12371                            exact: true,
12372                            allowed_query: "action,to,subject".into(),
12373                            keep_alive: false,
12374                            ..Default::default()
12375                        },
12376                        WebUiFragmentRoute {
12377                            path: "settings".into(),
12378                            fragment_id: "settings-page".into(),
12379                            exact: true,
12380                            keep_alive: false,
12381                            ..Default::default()
12382                        },
12383                    ],
12384                    keep_alive: false,
12385                    ..Default::default()
12386                })],
12387
12388                contains_boundary: false,
12389            },
12390        );
12391
12392        fragments.insert(
12393            "app-shell".to_string(),
12394            FragmentList {
12395                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12396                contains_boundary: false,
12397            },
12398        );
12399        fragments.insert(
12400            "compose-page".to_string(),
12401            FragmentList {
12402                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12403                contains_boundary: false,
12404            },
12405        );
12406        fragments.insert(
12407            "settings-page".to_string(),
12408            FragmentList {
12409                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12410                contains_boundary: false,
12411            },
12412        );
12413
12414        WebUIProtocol::new(fragments)
12415    }
12416
12417    #[test]
12418    fn test_matched_route_omits_query_attr_from_dom() {
12419        let protocol = make_query_route_protocol();
12420        let state = test_json!({});
12421        let handler = WebUIHandler::new();
12422        let mut writer = TestWriter::new();
12423
12424        handler
12425            .handle(
12426                &protocol,
12427                &state,
12428                &RenderOptions::new("index.html", "/compose"),
12429                &mut writer,
12430            )
12431            .expect("render failed");
12432
12433        let html = writer.get_content();
12434        // query attr is no longer in DOM — it's in the SSR chain JSON instead
12435        assert!(
12436            !html.contains(r#"query="action,to,subject""#),
12437            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12438        );
12439    }
12440
12441    #[test]
12442    fn test_nonmatched_route_omits_query_attr_from_dom() {
12443        let protocol = make_query_route_protocol();
12444        let state = test_json!({});
12445        let handler = WebUIHandler::new();
12446        let mut writer = TestWriter::new();
12447
12448        handler
12449            .handle(
12450                &protocol,
12451                &state,
12452                &RenderOptions::new("index.html", "/settings"),
12453                &mut writer,
12454            )
12455            .expect("render failed");
12456
12457        let html = writer.get_content();
12458        // query attr should not be on hidden siblings either
12459        assert!(
12460            !html.contains(r#"query="#),
12461            "hidden route should not have query attr: {html}"
12462        );
12463    }
12464
12465    #[test]
12466    fn test_route_without_query_has_no_query_attr() {
12467        let protocol = make_query_route_protocol();
12468        let state = test_json!({});
12469        let handler = WebUIHandler::new();
12470        let mut writer = TestWriter::new();
12471
12472        handler
12473            .handle(
12474                &protocol,
12475                &state,
12476                &RenderOptions::new("index.html", "/settings"),
12477                &mut writer,
12478            )
12479            .expect("render failed");
12480
12481        let html = writer.get_content();
12482        // Find the settings route element and verify it has no query attr
12483        let settings_idx = html
12484            .find(r#"component="settings-page""#)
12485            .expect("settings route should exist");
12486        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12487        assert!(
12488            !settings_tag.contains("query="),
12489            "route without allowed_query should not emit query attr: {settings_tag}"
12490        );
12491    }
12492
12493    // ── Per-render head_inject / body_inject (replaces the byte-scanner
12494    //    InjectingStreamingWriter approach with structural signal-based
12495    //    injection) ───────────────────────────────────────────────────
12496
12497    fn build_head_body_protocol() -> WebUIProtocol {
12498        let mut fragments = HashMap::new();
12499        fragments.insert(
12500            "index.html".to_string(),
12501            FragmentList {
12502                fragments: vec![
12503                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12504                    structural_fragment("head_end"),
12505                    WebUIFragment::raw("</head><body>hello".to_string()),
12506                    structural_fragment("body_end"),
12507                    WebUIFragment::raw("</body></html>".to_string()),
12508                ],
12509                contains_boundary: false,
12510            },
12511        );
12512        WebUIProtocol::new(fragments)
12513    }
12514
12515    #[test]
12516    fn head_inject_emits_at_head_end_boundary() {
12517        let protocol = build_head_body_protocol();
12518        let state = test_json!({});
12519        let mut writer = TestWriter::new();
12520        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12521        handle(&protocol, &state, &opts, &mut writer).unwrap();
12522        let html = writer.get_content();
12523        // The inject must appear immediately before `</head>`.
12524        let inject_idx = html
12525            .find("<link rel=preload>")
12526            .expect("inject HTML missing");
12527        let head_close_idx = html.find("</head>").expect("</head> missing");
12528        assert!(
12529            inject_idx < head_close_idx,
12530            "head_inject must appear before </head>: {html}"
12531        );
12532        // No duplicate.
12533        assert_eq!(html.matches("<link rel=preload>").count(), 1);
12534    }
12535
12536    #[test]
12537    fn component_render_policy_css_emits_once_in_head_with_nonce() {
12538        let mut protocol = build_head_body_protocol();
12539        protocol.component_render_css = concat!(
12540            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12541            "contain-intrinsic-block-size:auto 18rem;}"
12542        )
12543        .to_string();
12544        let state = test_json!({});
12545        let mut writer = TestWriter::new();
12546        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12547        handle(&protocol, &state, &opts, &mut writer).unwrap();
12548        let html = writer.get_content();
12549
12550        let style = concat!(
12551            r#"<style data-webui-render-policy nonce="test-nonce">"#,
12552            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12553            "contain-intrinsic-block-size:auto 18rem;}</style>"
12554        );
12555        let style_index = html.find(style).expect("render policy style missing");
12556        let head_close = html.find("</head>").expect("</head> missing");
12557        assert!(style_index < head_close);
12558        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12559    }
12560
12561    #[test]
12562    fn body_inject_emits_at_body_end_boundary() {
12563        let protocol = build_head_body_protocol();
12564        let state = test_json!({});
12565        let mut writer = TestWriter::new();
12566        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12567        handle(&protocol, &state, &opts, &mut writer).unwrap();
12568        let html = writer.get_content();
12569        let inject_idx = html
12570            .find("<script>lr</script>")
12571            .expect("inject HTML missing");
12572        let body_close_idx = html.find("</body>").expect("</body> missing");
12573        assert!(
12574            inject_idx < body_close_idx,
12575            "body_inject must appear before </body>: {html}"
12576        );
12577        assert_eq!(html.matches("<script>lr</script>").count(), 1);
12578    }
12579
12580    // ── Reserved `$webui` state inject namespace ──────────────────────
12581
12582    /// Protocol carrying all three structural boundaries, so `body_start`
12583    /// placement can be asserted alongside `head_end` / `body_end`.
12584    fn build_all_boundaries_protocol() -> WebUIProtocol {
12585        let mut fragments = HashMap::new();
12586        fragments.insert(
12587            "index.html".to_string(),
12588            FragmentList {
12589                fragments: vec![
12590                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12591                    structural_fragment("head_end"),
12592                    WebUIFragment::raw("</head><body>".to_string()),
12593                    structural_fragment("body_start"),
12594                    WebUIFragment::raw("hello".to_string()),
12595                    structural_fragment("body_end"),
12596                    WebUIFragment::raw("</body></html>".to_string()),
12597                ],
12598                contains_boundary: false,
12599            },
12600        );
12601        WebUIProtocol::new(fragments)
12602    }
12603
12604    fn state_inject_options<'a>() -> RenderOptions<'a> {
12605        RenderOptions::new("index.html", "/")
12606    }
12607
12608    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12609        let mut writer = TestWriter::new();
12610        handle(protocol, state, options, &mut writer).unwrap();
12611        writer.get_content().to_string()
12612    }
12613
12614    #[test]
12615    fn state_inject_emits_at_every_structural_boundary() {
12616        let protocol = build_all_boundaries_protocol();
12617        let state = test_json!({
12618            "$webui": {
12619                "headEnd": "<meta name=he>",
12620                "bodyStart": "<span id=bs></span>",
12621                "bodyEnd": "<script>be</script>",
12622            }
12623        });
12624        let html = render_with(&protocol, &state, &state_inject_options());
12625
12626        let head_end = html.find("<meta name=he>").expect("headEnd missing");
12627        let head_close = html.find("</head>").expect("</head> missing");
12628        let body_open = html.find("<body>").expect("<body> missing");
12629        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12630        let hello = html.find("hello").expect("body content missing");
12631        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12632        let body_close = html.find("</body>").expect("</body> missing");
12633
12634        assert!(
12635            head_end < head_close,
12636            "headEnd must precede </head>: {html}"
12637        );
12638        assert!(
12639            body_open < body_start && body_start < hello,
12640            "bodyStart must sit immediately after <body>: {html}"
12641        );
12642        assert!(
12643            hello < body_end && body_end < body_close,
12644            "bodyEnd must precede </body>: {html}"
12645        );
12646
12647        for needle in [
12648            "<meta name=he>",
12649            "<span id=bs></span>",
12650            "<script>be</script>",
12651        ] {
12652            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12653        }
12654    }
12655
12656    #[test]
12657    fn state_inject_follows_render_options_inject() {
12658        let protocol = build_all_boundaries_protocol();
12659        let state = test_json!({
12660            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12661        });
12662        let options = RenderOptions::new("index.html", "/")
12663            .with_head_inject("<!--opt-he-->")
12664            .with_body_inject("<!--opt-be-->");
12665        let html = render_with(&protocol, &state, &options);
12666
12667        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12668        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12669        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12670        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12671
12672        assert!(
12673            opt_he < state_he,
12674            "RenderOptions head_inject must precede the state-supplied value: {html}"
12675        );
12676        assert!(
12677            opt_be < state_be,
12678            "RenderOptions body_inject must precede the state-supplied value: {html}"
12679        );
12680    }
12681
12682    #[test]
12683    fn malformed_state_inject_values_are_inert() {
12684        let protocol = build_all_boundaries_protocol();
12685        // Absent key, wrong container type, and per-member null / empty /
12686        // non-string values must all render without output and without error.
12687        for state in [
12688            test_json!({}),
12689            test_json!({ "$webui": "not-an-object" }),
12690            test_json!({ "$webui": [] }),
12691            test_json!({ "$webui": null }),
12692            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12693            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12694        ] {
12695            let html = render_with(&protocol, &state, &state_inject_options());
12696            assert!(
12697                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12698                "malformed reserved state must render the document unchanged: {html}"
12699            );
12700            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12701            assert!(!html.contains("42"), "non-string member leaked: {html}");
12702        }
12703    }
12704
12705    #[test]
12706    fn state_inject_never_reaches_the_hydration_payload() {
12707        let protocol = build_all_boundaries_protocol();
12708        let state = test_json!({
12709            "visible": "keep",
12710            "$webui": { "bodyEnd": "<script>secret</script>" }
12711        });
12712        let handler = WebUIHandler::with_plugin(|| {
12713            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12714        });
12715        let runtime = Protocol::new(protocol.clone());
12716        let mut writer = TestWriter::new();
12717        handler
12718            .render(&runtime, &state, &state_inject_options(), &mut writer)
12719            .unwrap();
12720        let html = writer.get_content();
12721
12722        let data_start = html
12723            .find(r#"<script type="application/json" id="webui-data""#)
12724            .expect("hydration block missing");
12725        let data_end = html[data_start..]
12726            .find("</script>")
12727            .map(|offset| data_start + offset)
12728            .expect("hydration block never closes");
12729        let payload = &html[data_start..data_end];
12730        assert!(
12731            !payload.contains("$webui"),
12732            "reserved key must be stripped from the hydration payload: {payload}"
12733        );
12734        assert!(
12735            payload.contains("visible"),
12736            "ordinary state must survive the filter: {payload}"
12737        );
12738        // Per the documented precedence the injected HTML is emitted after
12739        // the built-in hydration block, and still before `</body>`.
12740        let inject = html
12741            .find("<script>secret</script>")
12742            .expect("inject missing");
12743        let body_close = html.find("</body>").expect("</body> missing");
12744        assert!(
12745            data_end < inject && inject < body_close,
12746            "state bodyEnd must follow the hydration block and precede </body>: {html}"
12747        );
12748    }
12749
12750    #[test]
12751    fn projected_hydration_strips_reserved_state_inject_key() {
12752        let mut fragments = HashMap::new();
12753        fragments.insert(
12754            "index.html".to_string(),
12755            FragmentList {
12756                fragments: vec![
12757                    WebUIFragment::raw("<html><body>"),
12758                    WebUIFragment::component("app-shell"),
12759                    structural_fragment("body_end"),
12760                    WebUIFragment::raw("</body></html>"),
12761                ],
12762                contains_boundary: false,
12763            },
12764        );
12765        fragments.insert(
12766            "app-shell".to_string(),
12767            FragmentList {
12768                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12769                contains_boundary: false,
12770            },
12771        );
12772        let mut document = WebUIProtocol::new(fragments);
12773        document.initial_state_strategy = InitialStateStrategy::Components as i32;
12774        document.components.insert(
12775            "app-shell".to_string(),
12776            webui_protocol::ComponentData {
12777                hydration_mode: StateProjectionMode::Keys as i32,
12778                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12779                ..Default::default()
12780            },
12781        );
12782        let state = test_json!({
12783            "$webui": { "bodyEnd": "<script>secret</script>" },
12784            "serverOnly": "drop",
12785            "visible": "keep",
12786        });
12787        let handler = WebUIHandler::with_plugin(|| {
12788            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12789        });
12790        let mut writer = TestWriter::new();
12791
12792        handler
12793            .render(
12794                &Protocol::new(document),
12795                &state,
12796                &RenderOptions::new("index.html", "/"),
12797                &mut writer,
12798            )
12799            .unwrap();
12800        let html = writer.get_content();
12801        let data_start = html
12802            .find(r#"<script type="application/json" id="webui-data""#)
12803            .expect("hydration block missing");
12804        let data_end = html[data_start..]
12805            .find("</script>")
12806            .map(|offset| data_start + offset)
12807            .expect("hydration block never closes");
12808        let payload = &html[data_start..data_end];
12809
12810        assert!(
12811            !payload.contains(STATE_INJECT_KEY),
12812            "reserved key leaked: {payload}"
12813        );
12814        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12815        assert!(!payload.contains("serverOnly"), "{payload}");
12816        assert!(html.contains("<script>secret</script>"), "{html}");
12817    }
12818
12819    #[test]
12820    fn write_selected_state_strips_reserved_key_from_full_state() {
12821        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12822        let mut sink = TestWriter::new();
12823        let mut scratch = Vec::new();
12824        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12825        let json = sink.get_content();
12826        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12827        assert!(
12828            json.contains("\"a\":1") && json.contains("\"z\":2"),
12829            "{json}"
12830        );
12831    }
12832
12833    #[test]
12834    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12835        // The streaming record projects through interned key IDs, so the
12836        // reserved inject key must be filtered on that path too.
12837        let mut protocol = WebUIProtocol::new(HashMap::new());
12838        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12839        protocol.fragments.insert(
12840            "keep-card".to_string(),
12841            webui_protocol::FragmentList::default(),
12842        );
12843        protocol.components.insert(
12844            "keep-card".to_string(),
12845            webui_protocol::ComponentData {
12846                hydration_mode: StateProjectionMode::Keys as i32,
12847                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12848                ..Default::default()
12849            },
12850        );
12851        let protocol = Protocol::new(protocol);
12852        let index = protocol.component_reachability();
12853        let component = protocol.component_index()["keep-card"];
12854        let mut ids = Vec::new();
12855        assert!(!collect_hydration_key_ids_into(
12856            protocol.protocol(),
12857            index,
12858            [component],
12859            &mut ids
12860        ));
12861
12862        let state = test_json!({
12863            "$webui": { "bodyEnd": "<b>x</b>" },
12864            "keep": 1,
12865        });
12866        let mut sink = TestWriter::new();
12867        let mut scratch = Vec::new();
12868
12869        write_selected_state(
12870            &mut sink,
12871            &mut scratch,
12872            &state,
12873            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
12874        )
12875        .unwrap();
12876
12877        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
12878    }
12879
12880    #[test]
12881    fn write_selected_state_full_is_unchanged_without_reserved_key() {
12882        let state = test_json!({ "a": 1, "z": 2 });
12883        let mut sink = TestWriter::new();
12884        let mut scratch = Vec::new();
12885        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12886        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
12887    }
12888
12889    #[test]
12890    fn body_start_hook_dedupes_on_malformed_protocol() {
12891        let mut fragments = HashMap::new();
12892        fragments.insert(
12893            "index.html".to_string(),
12894            FragmentList {
12895                fragments: vec![
12896                    WebUIFragment::raw("<html><head></head><body>".to_string()),
12897                    structural_fragment("body_start"),
12898                    structural_fragment("body_start"),
12899                    WebUIFragment::raw("</body></html>".to_string()),
12900                ],
12901                contains_boundary: false,
12902            },
12903        );
12904        let protocol = WebUIProtocol::new(fragments);
12905        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
12906        let html = render_with(&protocol, &state, &state_inject_options());
12907        assert_eq!(
12908            html.matches("<i>once</i>").count(),
12909            1,
12910            "duplicate body_start signals must not duplicate the inject: {html}"
12911        );
12912    }
12913
12914    #[test]
12915    fn injects_are_no_op_when_unset() {
12916        let protocol = build_head_body_protocol();
12917        let state = test_json!({});
12918        let mut writer = TestWriter::new();
12919        handle(
12920            &protocol,
12921            &state,
12922            &RenderOptions::new("index.html", "/"),
12923            &mut writer,
12924        )
12925        .unwrap();
12926        let html = writer.get_content();
12927        assert!(!html.contains("<link rel=preload>"));
12928        assert!(!html.contains("<script>lr</script>"));
12929    }
12930
12931    #[test]
12932    fn empty_inject_string_treated_as_unset() {
12933        let protocol = build_head_body_protocol();
12934        let state = test_json!({});
12935        let mut writer = TestWriter::new();
12936        let opts = RenderOptions::new("index.html", "/")
12937            .with_head_inject("")
12938            .with_body_inject("");
12939        handle(&protocol, &state, &opts, &mut writer).unwrap();
12940        // No injection happens — empty strings are normalised to None
12941        // by the builder, so the output is identical to the no-options case.
12942        let html = writer.get_content();
12943        assert!(html.contains("</head>"));
12944        assert!(html.contains("</body>"));
12945    }
12946
12947    #[test]
12948    fn inject_html_is_passed_through_verbatim() {
12949        // The handler does NOT escape the inject string — hosts pass
12950        // raw HTML they trust. This test pins that contract: a `<` in
12951        // the inject is emitted as-is, not encoded as `&lt;`.
12952        let protocol = build_head_body_protocol();
12953        let state = test_json!({});
12954        let mut writer = TestWriter::new();
12955        let opts =
12956            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
12957        handle(&protocol, &state, &opts, &mut writer).unwrap();
12958        assert!(writer.get_content().contains("<script>var x=1;</script>"));
12959    }
12960
12961    /// Both injects fire and appear at the correct structural
12962    /// positions. Critically, this is robust against `</head>` /
12963    /// `</body>` literals appearing elsewhere in the document — the
12964    /// signal-based emitter cannot mis-fire on byte patterns inside
12965    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
12966    /// previous byte-scanner could).
12967    #[test]
12968    fn injects_robust_against_marker_literals_in_content() {
12969        let mut fragments = HashMap::new();
12970        // The body intentionally contains `</body>` and `</head>`
12971        // literals before the actual structural close — these came
12972        // from a (hypothetical) iframe srcdoc or comment.
12973        fragments.insert(
12974            "index.html".to_string(),
12975            FragmentList {
12976                fragments: vec![
12977                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12978                    structural_fragment("head_end"),
12979                    WebUIFragment::raw(
12980                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
12981                    ),
12982                    structural_fragment("body_end"),
12983                    WebUIFragment::raw("</body></html>".to_string()),
12984                ],
12985                contains_boundary: false,
12986            },
12987        );
12988        let protocol = WebUIProtocol::new(fragments);
12989        let state = test_json!({});
12990        let mut writer = TestWriter::new();
12991        let opts = RenderOptions::new("index.html", "/")
12992            .with_head_inject("<HEAD-INJ>")
12993            .with_body_inject("<BODY-INJ>");
12994        handle(&protocol, &state, &opts, &mut writer).unwrap();
12995        let html = writer.get_content();
12996        // The head inject sits between `<title>x</title>` and the
12997        // first `</head>` — the structural one, not the comment one.
12998        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
12999        let head_close_idx = html.find("</head>").expect("</head> missing");
13000        assert!(head_inj_idx < head_close_idx);
13001        // The body inject sits before the structural `</body>` — NOT
13002        // before the `</body>` literal in the comment (which would
13003        // require the inject to appear inside `<p>hi</p>` somewhere).
13004        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13005        // Find the LAST `</body>` (the structural one).
13006        let body_close_idx = html.rfind("</body>").expect("</body> missing");
13007        assert!(
13008            body_inj_idx < body_close_idx,
13009            "body_inject must precede the structural </body>: {html}"
13010        );
13011        // And the comment is preserved verbatim.
13012        assert!(html.contains("<!-- </body> </head> -->"));
13013    }
13014
13015    /// Coverage-14: both `head_inject` AND `body_inject` set in the
13016    /// same render. Each fires at the correct structural boundary and
13017    /// neither leaks into the other's region.
13018    #[test]
13019    fn both_injects_fire_at_correct_boundaries() {
13020        let protocol = build_head_body_protocol();
13021        let state = test_json!({});
13022        let mut writer = TestWriter::new();
13023        let opts = RenderOptions::new("index.html", "/")
13024            .with_head_inject("<META-HEAD>")
13025            .with_body_inject("<SCRIPT-BODY>");
13026        handle(&protocol, &state, &opts, &mut writer).unwrap();
13027        let html = writer.get_content();
13028        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13029        let head_close = html.find("</head>").expect("</head> missing");
13030        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13031        let body_close = html.find("</body>").expect("</body> missing");
13032        assert!(head_idx < head_close, "head_inject before </head>");
13033        assert!(head_close < body_idx, "body_inject after </head>");
13034        assert!(body_idx < body_close, "body_inject before </body>");
13035        assert_eq!(html.matches("<META-HEAD>").count(), 1);
13036        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13037    }
13038
13039    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
13040    /// emitting `head_end` and `body_end` more than once must NOT
13041    /// duplicate the host inject HTML. Without the dedup guard,
13042    /// double-emission would amplify Security-2 (a 1 MiB inject ×
13043    /// 1000 duplicate signals = 1 GiB output).
13044    #[test]
13045    fn injects_dedupe_against_duplicate_signals() {
13046        let mut fragments = HashMap::new();
13047        fragments.insert(
13048            "index.html".to_string(),
13049            FragmentList {
13050                fragments: vec![
13051                    WebUIFragment::raw("<html><head>".to_string()),
13052                    structural_fragment("head_end"),
13053                    structural_fragment("head_end"), // duplicate
13054                    structural_fragment("head_end"), // triplicate
13055                    WebUIFragment::raw("</head><body>".to_string()),
13056                    structural_fragment("body_end"),
13057                    structural_fragment("body_end"), // duplicate
13058                    WebUIFragment::raw("</body></html>".to_string()),
13059                ],
13060                contains_boundary: false,
13061            },
13062        );
13063        let protocol = WebUIProtocol::new(fragments);
13064        let state = test_json!({});
13065        let mut writer = TestWriter::new();
13066        let opts = RenderOptions::new("index.html", "/")
13067            .with_head_inject("<HINJ>")
13068            .with_body_inject("<BINJ>");
13069        handle(&protocol, &state, &opts, &mut writer).unwrap();
13070        let html = writer.get_content();
13071        assert_eq!(
13072            html.matches("<HINJ>").count(),
13073            1,
13074            "head_inject must emit exactly once even with duplicate head_end signals"
13075        );
13076        assert_eq!(
13077            html.matches("<BINJ>").count(),
13078            1,
13079            "body_inject must emit exactly once even with duplicate body_end signals"
13080        );
13081    }
13082
13083    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
13084    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
13085    /// never fire). Verifies the injects are no-ops, not panics.
13086    #[test]
13087    fn injects_no_op_when_no_head_or_body_signals() {
13088        let mut fragments = HashMap::new();
13089        fragments.insert(
13090            "index.html".to_string(),
13091            FragmentList {
13092                fragments: vec![WebUIFragment::raw(
13093                    "<my-component>hi</my-component>".to_string(),
13094                )],
13095                contains_boundary: false,
13096            },
13097        );
13098        let protocol = WebUIProtocol::new(fragments);
13099        let state = test_json!({});
13100        let mut writer = TestWriter::new();
13101        let opts = RenderOptions::new("index.html", "/")
13102            .with_head_inject("<HINJ>")
13103            .with_body_inject("<BINJ>");
13104        handle(&protocol, &state, &opts, &mut writer).unwrap();
13105        let html = writer.get_content();
13106        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13107        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13108        assert!(html.contains("<my-component>"));
13109    }
13110
13111    /// Coverage-19: the handler's `&self` is shared across threads.
13112    /// Two concurrent renders with different inject values must NOT
13113    /// cross-contaminate (each thread sees only its own inject).
13114    /// Per-render mutable state lives on the `WebUIProcessContext`,
13115    /// which is stack-allocated per call.
13116    #[test]
13117    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13118        let protocol = std::sync::Arc::new(build_head_body_protocol());
13119        let state = std::sync::Arc::new(test_json!({}));
13120        let handler = std::sync::Arc::new(WebUIHandler::new());
13121
13122        const N_THREADS: usize = 16;
13123        let mut handles = Vec::with_capacity(N_THREADS);
13124        for tid in 0..N_THREADS {
13125            let h = std::sync::Arc::clone(&handler);
13126            let p = std::sync::Arc::clone(&protocol);
13127            let s = std::sync::Arc::clone(&state);
13128            handles.push(std::thread::spawn(move || {
13129                let head = format!("<HEAD-T{tid}>");
13130                let body = format!("<BODY-T{tid}>");
13131                let mut writer = TestWriter::new();
13132                let opts = RenderOptions::new("index.html", "/")
13133                    .with_head_inject(&head)
13134                    .with_body_inject(&body);
13135                h.handle(&p, &s, &opts, &mut writer).unwrap();
13136                let html = writer.get_content();
13137                // Must contain my own injects exactly once.
13138                assert_eq!(html.matches(&head).count(), 1);
13139                assert_eq!(html.matches(&body).count(), 1);
13140                // Must NOT contain any other thread's inject.
13141                for other in 0..N_THREADS {
13142                    if other == tid {
13143                        continue;
13144                    }
13145                    let other_head = format!("<HEAD-T{other}>");
13146                    let other_body = format!("<BODY-T{other}>");
13147                    assert!(
13148                        !html.contains(&other_head),
13149                        "tid {tid} saw {other}'s head_inject"
13150                    );
13151                    assert!(
13152                        !html.contains(&other_body),
13153                        "tid {tid} saw {other}'s body_inject"
13154                    );
13155                }
13156            }));
13157        }
13158        for h in handles {
13159            h.join().expect("worker panicked");
13160        }
13161    }
13162
13163    /// Coverage-17: a large (1 MiB) head_inject must round-trip
13164    /// correctly without panic, truncation, or excessive overhead.
13165    /// (No size cap is enforced by the handler — the host owns the
13166    /// safety contract; see `with_head_inject` doc comment.)
13167    #[test]
13168    fn large_inject_roundtrips_without_truncation() {
13169        let protocol = build_head_body_protocol();
13170        let state = test_json!({});
13171        let mut writer = TestWriter::new();
13172        let big = "x".repeat(1024 * 1024);
13173        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13174        handle(&protocol, &state, &opts, &mut writer).unwrap();
13175        let html = writer.get_content();
13176        assert!(
13177            html.contains(&big),
13178            "large head_inject must be present verbatim ({} bytes)",
13179            big.len()
13180        );
13181        // Sanity: only one copy.
13182        assert_eq!(html.matches(&big).count(), 1);
13183    }
13184
13185    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
13186    /// matching the empty-string semantics of `with_head_inject` /
13187    /// `with_body_inject`. An empty content attribute is browser-
13188    /// ignored noise.
13189    #[test]
13190    fn empty_nonce_treated_as_unset() {
13191        let protocol = build_head_body_protocol();
13192        let state = test_json!({});
13193        let mut writer = TestWriter::new();
13194        let opts = RenderOptions::new("index.html", "/").with_nonce("");
13195        handle(&protocol, &state, &opts, &mut writer).unwrap();
13196        assert!(
13197            !writer.get_content().contains("webui-nonce"),
13198            "empty nonce must not emit <meta name=\"webui-nonce\">"
13199        );
13200    }
13201
13202    /// Regression for the bug Akrosh caught: the `pub` fields on
13203    /// `RenderOptions` let a caller bypass the `with_*` builder
13204    /// normalisation, e.g.:
13205    ///
13206    /// ```ignore
13207    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
13208    /// ```
13209    ///
13210    /// Without defensive normalisation at handler init, this would
13211    /// emit `<script nonce="">` on every inline script. Under a
13212    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
13213    /// empty nonce is a HARD CSP failure that blocks every inline
13214    /// script — a complete inline-script-execution outage.
13215    ///
13216    /// The handler now treats `Some("")` identically to `None` for
13217    /// all three injection points (nonce / head_inject / body_inject)
13218    /// regardless of how the option was populated.
13219    #[test]
13220    fn empty_field_bypass_is_normalised_at_handler_init() {
13221        let protocol = build_head_body_protocol();
13222        let state = test_json!({});
13223
13224        // Bypass the `with_nonce` builder by writing the field directly.
13225        let opts_with_empty_nonce = RenderOptions {
13226            nonce: Some(""),
13227            ..RenderOptions::new("index.html", "/")
13228        };
13229        let mut writer = TestWriter::new();
13230        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13231        let html = writer.get_content();
13232        assert!(
13233            !html.contains("webui-nonce"),
13234            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13235        );
13236        assert!(
13237            !html.contains("nonce=\"\""),
13238            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13239        );
13240
13241        // Same defence for inject fields.
13242        let opts_with_empty_injects = RenderOptions {
13243            head_inject: Some(""),
13244            body_inject: Some(""),
13245            ..RenderOptions::new("index.html", "/")
13246        };
13247        let mut writer = TestWriter::new();
13248        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13249        // No assertion needed beyond "doesn't panic and doesn't emit
13250        // empty inject markers" — the head_end / body_end paths must
13251        // treat the empty inject as no-op the same way the builder does.
13252    }
13253
13254    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
13255    /// path optimisation (insert key once + `get_mut`-swap value
13256    /// in-place) was suspected of corrupting the outer scope when a
13257    /// nested `<for>` loop reuses the same variable name. This test
13258    /// proves the optimisation is correct under that condition by
13259    /// requiring the outer `item` to be visible before, between, and
13260    /// after the inner loop, with its value preserved across inner
13261    /// iterations.
13262    ///
13263    /// Trace through the optimisation on `outer = [A, B]`,
13264    /// `inner = [X, Y]` with both loops using `item` as the variable:
13265    ///
13266    ///   outer pre-insert "item": Null
13267    ///   iter 1: get_mut → write A
13268    ///     emit "outer:A"
13269    ///     enter inner: saved = remove("item") = Some(A)
13270    ///                  pre-insert "item": Null
13271    ///                  iter 1: write X → emit "inner:X"
13272    ///                  iter 2: write Y → emit "inner:Y"
13273    ///                  restore: insert("item", A)   ← outer's A back
13274    ///     emit "outer:A again"               ← reads A correctly
13275    ///   iter 2: get_mut → write B (overwrites the restored A,
13276    ///                              but that's correct — we're now
13277    ///                              in iter 2 of the outer loop)
13278    ///     emit "outer:B"
13279    ///     enter inner: saved = remove("item") = Some(B), …, restore B
13280    ///     emit "outer:B again"
13281    ///
13282    /// If the audit's claim were correct — that the outer's `get_mut`
13283    /// somehow held a reference past the inner loop and clobbered the
13284    /// restoration — we'd see corrupted values in the "outer:X again"
13285    /// emissions. The assertion below pins the correct sequence.
13286    #[test]
13287    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13288        let mut fragments = HashMap::new();
13289        fragments.insert(
13290            "index.html".to_string(),
13291            FragmentList {
13292                fragments: vec![
13293                    WebUIFragment::raw("["),
13294                    WebUIFragment::for_loop("item", "outer", "outer_body"),
13295                    WebUIFragment::raw("]"),
13296                ],
13297                contains_boundary: false,
13298            },
13299        );
13300        fragments.insert(
13301            "outer_body".to_string(),
13302            FragmentList {
13303                fragments: vec![
13304                    WebUIFragment::raw("(O="),
13305                    WebUIFragment::signal("item.tag", false),
13306                    WebUIFragment::for_loop("item", "inner", "inner_body"),
13307                    WebUIFragment::raw(",O="),
13308                    WebUIFragment::signal("item.tag", false),
13309                    WebUIFragment::raw(")"),
13310                ],
13311                contains_boundary: false,
13312            },
13313        );
13314        fragments.insert(
13315            "inner_body".to_string(),
13316            FragmentList {
13317                fragments: vec![
13318                    WebUIFragment::raw("[I="),
13319                    WebUIFragment::signal("item.tag", false),
13320                    WebUIFragment::raw("]"),
13321                ],
13322                contains_boundary: false,
13323            },
13324        );
13325        let protocol = WebUIProtocol::new(fragments);
13326        let state = test_json!({
13327            "outer": [{"tag": "A"}, {"tag": "B"}],
13328            "inner": [{"tag": "X"}, {"tag": "Y"}],
13329        });
13330        let mut writer = TestWriter::new();
13331        handle(
13332            &protocol,
13333            &state,
13334            &RenderOptions::new("index.html", "/"),
13335            &mut writer,
13336        )
13337        .unwrap();
13338        // Expected sequence:
13339        //   outer iter 1 (item=A):
13340        //     emit "(O=A"               ← outer A before inner
13341        //     inner iter 1 (item=X) emit "[I=X]"
13342        //     inner iter 2 (item=Y) emit "[I=Y]"
13343        //     emit ",O=A)"              ← outer A AFTER inner restore
13344        //   outer iter 2 (item=B):
13345        //     emit "(O=B"
13346        //     inner iter 1 (item=X) emit "[I=X]"
13347        //     inner iter 2 (item=Y) emit "[I=Y]"
13348        //     emit ",O=B)"
13349        assert_eq!(
13350            writer.get_content(),
13351            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13352            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13353        );
13354    }
13355    #[derive(Default)]
13356    struct FlushTestWriter {
13357        output: String,
13358        flushes: Vec<usize>,
13359        fail_flush: bool,
13360        fail_flush_at: Option<usize>,
13361        flush_attempts: usize,
13362        ended: bool,
13363    }
13364
13365    impl ResponseWriter for FlushTestWriter {
13366        fn write(&mut self, content: &str) -> Result<()> {
13367            self.output.push_str(content);
13368            Ok(())
13369        }
13370
13371        fn end(&mut self) -> Result<()> {
13372            self.ended = true;
13373            Ok(())
13374        }
13375    }
13376
13377    impl FlushWriter for FlushTestWriter {
13378        fn flush(&mut self) -> Result<()> {
13379            let attempt = self.flush_attempts;
13380            self.flush_attempts += 1;
13381            if self.fail_flush || self.fail_flush_at == Some(attempt) {
13382                return Err(HandlerError::ClientDisconnected);
13383            }
13384            self.flushes.push(self.output.len());
13385            Ok(())
13386        }
13387    }
13388
13389    fn streaming_protocol(with_boundaries: bool) -> Protocol {
13390        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13391    }
13392
13393    fn streaming_protocol_with_state_strategy(
13394        with_boundaries: bool,
13395        state_strategy: InitialStateStrategy,
13396    ) -> Protocol {
13397        let mut fragments = HashMap::new();
13398        let mut entry = vec![
13399            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13400            structural_fragment("head_start"),
13401            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13402            structural_fragment("head_end"),
13403            WebUIFragment::raw("</HEAD><body>"),
13404            structural_fragment("body_start"),
13405        ];
13406        if with_boundaries {
13407            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13408        }
13409        entry.extend([
13410            WebUIFragment::raw("<my-counter"),
13411            structural_fragment("streaming_root:my-counter"),
13412            WebUIFragment::raw(">"),
13413            WebUIFragment::component("my-counter"),
13414            WebUIFragment::raw("</my-counter>"),
13415        ]);
13416        if with_boundaries {
13417            entry.push(WebUIFragment::boundary_end(0));
13418        }
13419        entry.extend([
13420            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13421            structural_fragment("body_end"),
13422            WebUIFragment::raw("</body></html>"),
13423        ]);
13424        fragments.insert(
13425            "index.html".to_string(),
13426            FragmentList {
13427                fragments: entry,
13428                contains_boundary: with_boundaries,
13429            },
13430        );
13431        fragments.insert(
13432            "my-counter".to_string(),
13433            FragmentList {
13434                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13435                contains_boundary: false,
13436            },
13437        );
13438
13439        let mut document = WebUIProtocol::new(fragments);
13440        document.initial_state_strategy = state_strategy as i32;
13441        document.components.insert(
13442            "my-counter".to_string(),
13443            webui_protocol::ComponentData {
13444                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13445                template_functions: "[function(){return true}]".to_string(),
13446                hydration_mode: StateProjectionMode::Keys as i32,
13447                hydration_keys: vec!["count".to_string()],
13448                ..Default::default()
13449            },
13450        );
13451        Protocol::new(document)
13452    }
13453
13454    #[test]
13455    fn handler_error_stays_small() {
13456        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
13457        // therefore `Result<(), HandlerError>` threaded through the hot legacy
13458        // render path — down to a single `String`-sized payload plus a
13459        // discriminant word. If the boundary payload is un-boxed back to
13460        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
13461        // this fails.
13462        assert!(
13463            std::mem::size_of::<HandlerError>()
13464                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13465            "HandlerError grew to {} bytes",
13466            std::mem::size_of::<HandlerError>()
13467        );
13468    }
13469
13470    #[test]
13471    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13472        let protocol = streaming_protocol(true);
13473        let handler = WebUIHandler::with_plugin(|| {
13474            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13475        });
13476        let state = test_json!({ "count": 1, "serverOnly": "secret" });
13477        let mut writer = FlushTestWriter::default();
13478
13479        handler
13480            .render_streaming(
13481                &protocol,
13482                &state,
13483                &RenderOptions::new("index.html", "/"),
13484                &mut writer,
13485            )
13486            .unwrap();
13487
13488        assert!(writer.ended);
13489        assert_eq!(
13490            writer.flushes.len(),
13491            3,
13492            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13493        );
13494        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13495        assert!(boundary_flush.contains("<!--wb:0-->"));
13496        assert!(boundary_flush.contains("<!--/wb:0-->"));
13497        assert!(
13498            boundary_flush.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
13499            "boundary flush: {boundary_flush}"
13500        );
13501        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13502        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13503        assert!(!boundary_flush.contains("slow tail"));
13504        assert!(!writer.output.contains("id=\"webui-data\""));
13505        // The terminal flush commits the scriptless tail without manufacturing
13506        // another state/template projection.
13507        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13508        assert!(terminal_flush.contains("slow tail"));
13509        assert!(writer.output.contains("[2,1,4,0,{}]"));
13510        assert!(!writer.output.contains("[2,1,0,1,"));
13511        assert!(!writer.output.contains("[2,2,4,0,{}]"));
13512
13513        let marker = writer
13514            .output
13515            .find(STREAMING_MARKER)
13516            .expect("streaming marker");
13517        let attributed_head = writer
13518            .output
13519            .find("<HEAD data-shell=\"main\">")
13520            .expect("attributed mixed-case head");
13521        let authored_script = writer
13522            .output
13523            .find("src=\"/index.js\"")
13524            .expect("entry script");
13525        assert!(attributed_head < marker && marker < authored_script);
13526    }
13527
13528    #[test]
13529    fn streaming_terminal_tail_never_resends_full_state() {
13530        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13531        let handler = WebUIHandler::with_plugin(|| {
13532            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13533        });
13534        let mut writer = FlushTestWriter::default();
13535
13536        handler
13537            .render_streaming(
13538                &protocol,
13539                &test_json!({ "count": 1, "serverOnly": "secret" }),
13540                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13541                &mut writer,
13542            )
13543            .unwrap();
13544
13545        assert_eq!(
13546            writer.output.matches("serverOnly").count(),
13547            1,
13548            "full state belongs only to the interactive boundary"
13549        );
13550        assert!(writer.output.contains("[2,1,4,0,{}]"));
13551    }
13552
13553    /// Streaming must place the reserved-state injects exactly where the
13554    /// ordinary render does, so a host can switch modes without its
13555    /// boundary HTML moving.
13556    #[test]
13557    fn state_inject_placement_matches_between_render_modes() {
13558        let entry = vec![
13559            WebUIFragment::raw("<html><head>"),
13560            structural_fragment("head_start"),
13561            structural_fragment("head_end"),
13562            WebUIFragment::raw("</head><body>"),
13563            structural_fragment("body_start"),
13564            WebUIFragment::raw("<main>static</main>"),
13565            structural_fragment("body_end"),
13566            WebUIFragment::raw("</body></html>"),
13567        ];
13568        let fragments = HashMap::from([(
13569            "index.html".to_string(),
13570            FragmentList {
13571                fragments: entry,
13572                contains_boundary: false,
13573            },
13574        )]);
13575        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13576        let state = test_json!({
13577            "$webui": {
13578                "headEnd": "<meta name=he>",
13579                "bodyStart": "<span id=bs></span>",
13580                "bodyEnd": "<script>be</script>",
13581            }
13582        });
13583        let options = RenderOptions::new("index.html", "/");
13584
13585        let mut ordinary = TestWriter::new();
13586        WebUIHandler::new()
13587            .render(&protocol, &state, &options, &mut ordinary)
13588            .unwrap();
13589        let ordinary_html = ordinary.get_content().to_string();
13590
13591        let mut streamed = FlushTestWriter::default();
13592        WebUIHandler::new()
13593            .render_streaming(&protocol, &state, &options, &mut streamed)
13594            .unwrap();
13595        let streamed_html = &streamed.output;
13596
13597        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13598            let head_end = html.find("<meta name=he>").expect("headEnd missing");
13599            let head_close = html.find("</head>").expect("</head> missing");
13600            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13601            let main = html.find("<main>static</main>").expect("content missing");
13602            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13603            let body_close = html.find("</body>").expect("</body> missing");
13604            assert!(head_end < head_close, "headEnd misplaced: {html}");
13605            assert!(body_start < main, "bodyStart misplaced: {html}");
13606            assert!(
13607                main < body_end && body_end < body_close,
13608                "bodyEnd misplaced: {html}"
13609            );
13610        }
13611
13612        // The streaming response still terminates with its single empty
13613        // terminal record: an inject must not perturb the record stream.
13614        assert!(
13615            streamed_html.contains(",4,0,{}]"),
13616            "streaming must still end in one empty terminal record: {streamed_html}"
13617        );
13618    }
13619
13620    #[test]
13621    fn streaming_state_inject_emits_and_strips_reserved_key() {
13622        let entry = vec![
13623            WebUIFragment::raw("<html><head>"),
13624            structural_fragment("head_start"),
13625            structural_fragment("head_end"),
13626            WebUIFragment::raw("</head><body>"),
13627            structural_fragment("body_start"),
13628            structural_fragment("body_end"),
13629            WebUIFragment::raw("</body></html>"),
13630        ];
13631        let fragments = HashMap::from([(
13632            "index.html".to_string(),
13633            FragmentList {
13634                fragments: entry,
13635                contains_boundary: false,
13636            },
13637        )]);
13638        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13639        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13640
13641        let mut writer = FlushTestWriter::default();
13642        WebUIHandler::new()
13643            .render_streaming(
13644                &protocol,
13645                &state,
13646                &RenderOptions::new("index.html", "/"),
13647                &mut writer,
13648            )
13649            .unwrap();
13650        assert!(writer.output.contains("<script>be</script>"));
13651        assert!(!writer.output.contains("$webui"));
13652    }
13653
13654    #[test]
13655    fn static_streaming_document_uses_one_empty_terminal_record() {
13656        let fragments = HashMap::from([(
13657            "index.html".to_string(),
13658            FragmentList {
13659                fragments: vec![
13660                    WebUIFragment::raw("<html><head>"),
13661                    structural_fragment("head_start"),
13662                    structural_fragment("head_end"),
13663                    WebUIFragment::raw("</head><body>"),
13664                    structural_fragment("body_start"),
13665                    WebUIFragment::raw("<main>static</main>"),
13666                    structural_fragment("body_end"),
13667                    WebUIFragment::raw("</body></html>"),
13668                ],
13669                contains_boundary: false,
13670            },
13671        )]);
13672        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13673        let mut writer = FlushTestWriter::default();
13674
13675        WebUIHandler::new()
13676            .render_streaming(
13677                &protocol,
13678                &test_json!({ "serverOnly": "secret" }),
13679                &RenderOptions::new("index.html", "/"),
13680                &mut writer,
13681            )
13682            .unwrap();
13683
13684        assert_eq!(writer.flushes.len(), 1);
13685        assert!(writer.output.contains(STREAMING_MARKER));
13686        assert!(writer.output.contains("[2,0,4,0,{}]"));
13687        assert!(!writer.output.contains("serverOnly"));
13688        assert!(!writer.output.contains("id=\"webui-data\""));
13689        assert!(!writer.output.contains("<!--wb:"));
13690    }
13691
13692    #[test]
13693    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13694        let protocol = streaming_protocol(true);
13695        let handler = WebUIHandler::with_plugin(|| {
13696            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13697        });
13698        let mut writer = FlushTestWriter::default();
13699        handler
13700            .render_streaming(
13701                &protocol,
13702                &test_json!({ "count": 1 }),
13703                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13704                &mut writer,
13705            )
13706            .unwrap();
13707
13708        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13709        let envelope = writer.output[boundary_end..]
13710            .find("data-webui-boundary nonce=\"test-nonce-123\"")
13711            .map(|index| index + boundary_end)
13712            .expect("nonce-bearing envelope");
13713        let functions = writer
13714            .output
13715            .find("templateFns")
13716            .expect("function side channel");
13717        let sentinel = writer
13718            .output
13719            .find("<webui-hydrate>")
13720            .expect("hydration sentinel");
13721        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13722        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13723    }
13724
13725    #[test]
13726    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13727        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13728            let mut fragments = vec![structural_fragment("head_start")];
13729            fragments.extend(boundaries);
13730            let fragments = HashMap::from([(
13731                "index.html".to_string(),
13732                FragmentList {
13733                    fragments,
13734                    contains_boundary: true,
13735                },
13736            )]);
13737            Protocol::new(WebUIProtocol::new(fragments))
13738        }
13739
13740        let handler = WebUIHandler::new();
13741        let mut writer = FlushTestWriter::default();
13742        let result = handler.render_streaming(
13743            &protocol(vec![WebUIFragment::boundary_end(0)]),
13744            &test_json!({}),
13745            &RenderOptions::new("index.html", "/"),
13746            &mut writer,
13747        );
13748        match result {
13749            Err(HandlerError::StreamingBoundary(err)) => {
13750                assert_eq!(err.signal, "boundary");
13751                assert!(err.reason.contains("no active occurrence"));
13752            }
13753            other => panic!("expected ordered-boundary error, got {other:?}"),
13754        }
13755
13756        let mut writer = FlushTestWriter::default();
13757        let result = handler.render_streaming(
13758            &protocol(vec![
13759                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13760                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13761            ]),
13762            &test_json!({}),
13763            &RenderOptions::new("index.html", "/"),
13764            &mut writer,
13765        );
13766        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13767    }
13768
13769    #[test]
13770    fn streaming_render_propagates_boundary_flush_disconnect() {
13771        let protocol = streaming_protocol(true);
13772        let handler = WebUIHandler::new();
13773        let mut writer = FlushTestWriter {
13774            fail_flush: true,
13775            ..FlushTestWriter::default()
13776        };
13777        let result = handler.render_streaming(
13778            &protocol,
13779            &test_json!({}),
13780            &RenderOptions::new("index.html", "/"),
13781            &mut writer,
13782        );
13783        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13784        assert!(!writer.ended);
13785    }
13786
13787    #[test]
13788    fn streaming_render_requires_structural_head_start_before_writing() {
13789        let fragments = HashMap::from([(
13790            "index.html".to_string(),
13791            FragmentList {
13792                fragments: vec![
13793                    WebUIFragment::raw(
13794                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13795                    ),
13796                    structural_fragment("head_end"),
13797                    WebUIFragment::raw("</HEAD><body>"),
13798                    structural_fragment("body_start"),
13799                    structural_fragment("body_end"),
13800                ],
13801                contains_boundary: false,
13802            },
13803        )]);
13804        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13805        let mut writer = FlushTestWriter::default();
13806
13807        let result = WebUIHandler::new().render_streaming(
13808            &protocol,
13809            &test_json!({}),
13810            &RenderOptions::new("index.html", "/"),
13811            &mut writer,
13812        );
13813
13814        assert!(matches!(
13815            result,
13816            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13817        ));
13818        assert_eq!(
13819            writer.output,
13820            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13821        );
13822        assert!(writer.flushes.is_empty());
13823        assert!(!writer.ended);
13824    }
13825
13826    #[test]
13827    fn streaming_render_rejects_duplicate_head_start() {
13828        let fragments = HashMap::from([(
13829            "index.html".to_string(),
13830            FragmentList {
13831                fragments: vec![
13832                    WebUIFragment::raw("<html><head>"),
13833                    structural_fragment("head_start"),
13834                    structural_fragment("head_start"),
13835                    structural_fragment("head_end"),
13836                    WebUIFragment::raw("</head><body>"),
13837                    structural_fragment("body_start"),
13838                    structural_fragment("body_end"),
13839                ],
13840                contains_boundary: false,
13841            },
13842        )]);
13843        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13844        let mut writer = FlushTestWriter::default();
13845
13846        let result = WebUIHandler::new().render_streaming(
13847            &protocol,
13848            &test_json!({}),
13849            &RenderOptions::new("index.html", "/"),
13850            &mut writer,
13851        );
13852
13853        assert!(matches!(
13854            result,
13855            Err(HandlerError::DuplicateStreamingHeadStart)
13856        ));
13857        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
13858        assert!(writer.flushes.is_empty());
13859    }
13860
13861    #[test]
13862    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
13863        let handler = WebUIHandler::with_plugin(|| {
13864            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13865        });
13866        let state = test_json!({
13867            "count": 1,
13868            "head_start": "must not render",
13869            "boundary_start:0": "must not render",
13870            "boundary_end:0": "must not render",
13871        });
13872        let mut with_boundaries = TestWriter::new();
13873        let mut without_boundaries = TestWriter::new();
13874        handler
13875            .render(
13876                &streaming_protocol(true),
13877                &state,
13878                &RenderOptions::new("index.html", "/"),
13879                &mut with_boundaries,
13880            )
13881            .unwrap();
13882        handler
13883            .render(
13884                &streaming_protocol(false),
13885                &state,
13886                &RenderOptions::new("index.html", "/"),
13887                &mut without_boundaries,
13888            )
13889            .unwrap();
13890        assert_eq!(
13891            with_boundaries.get_content(),
13892            without_boundaries.get_content()
13893        );
13894    }
13895
13896    #[test]
13897    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
13898        for source in [
13899            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
13900            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
13901        ] {
13902            let mut parser = HtmlParser::new();
13903            parser
13904                .parse("index.html", source)
13905                .expect("parse head fixture");
13906            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13907            let mut writer = TestWriter::new();
13908            WebUIHandler::new()
13909                .render(
13910                    &protocol,
13911                    &test_json!({ "theme": "light" }),
13912                    &RenderOptions::new("index.html", "/"),
13913                    &mut writer,
13914                )
13915                .unwrap();
13916
13917            assert_eq!(
13918                writer.get_content(),
13919                "<html><head><title>T</title></head><body>x</body></html>"
13920            );
13921        }
13922    }
13923
13924    #[test]
13925    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
13926        let source =
13927            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
13928        let mut parser = HtmlParser::new();
13929        parser.parse("index.html", source).expect("parse fixture");
13930        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13931
13932        let mut ordinary = TestWriter::new();
13933        WebUIHandler::new()
13934            .render(
13935                &protocol,
13936                &test_json!({}),
13937                &RenderOptions::new("index.html", "/"),
13938                &mut ordinary,
13939            )
13940            .unwrap();
13941        assert_eq!(ordinary.get_content(), source);
13942
13943        let mut streaming = FlushTestWriter::default();
13944        WebUIHandler::new()
13945            .render_streaming(
13946                &protocol,
13947                &test_json!({}),
13948                &RenderOptions::new("index.html", "/"),
13949                &mut streaming,
13950            )
13951            .unwrap();
13952        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
13953        assert!(streaming.output.contains("</HEAD><BODY>x"));
13954        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
13955        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
13956        let title = streaming.output.find("<title>").expect("title");
13957        assert!(opening < marker && marker < title);
13958        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
13959    }
13960
13961    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
13962    /// unprefixed `body_end` is ordinary authored content.
13963    #[test]
13964    fn unnamespaced_signal_is_ordinary_content() {
13965        let fragments = HashMap::from([(
13966            "index.html".to_string(),
13967            FragmentList {
13968                fragments: vec![
13969                    WebUIFragment::raw("<html><head>"),
13970                    structural_fragment("head_end"),
13971                    WebUIFragment::raw("</head><body>"),
13972                    WebUIFragment::signal("body_end".to_string(), false),
13973                    structural_fragment("body_end"),
13974                    WebUIFragment::raw("</body></html>"),
13975                ],
13976                contains_boundary: false,
13977            },
13978        )]);
13979        let protocol = Protocol::new(WebUIProtocol::new(fragments));
13980        let mut writer = TestWriter::new();
13981        WebUIHandler::new()
13982            .render(
13983                &protocol,
13984                &test_json!({ "body_end": "content" }),
13985                &RenderOptions::new("index.html", "/"),
13986                &mut writer,
13987            )
13988            .expect("current protocol must render");
13989        assert!(writer.get_content().contains("content"));
13990    }
13991
13992    #[test]
13993    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
13994        let source = concat!(
13995            "<html><head></head><body>",
13996            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
13997            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
13998            "</body></html>",
13999        );
14000        let state = test_json!({
14001            "head_start": "hs",
14002            "head_end": "he",
14003            "body_start": "bs",
14004            "body_end": "be",
14005            "boundary_start:0": "b0s",
14006            "boundary_end:0": "b0e",
14007            "streaming_root:forged": "root",
14008        });
14009        let mut parser = HtmlParser::new();
14010        parser.parse("index.html", source).expect("parse fixture");
14011        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14012        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14013
14014        let mut ordinary = TestWriter::new();
14015        WebUIHandler::new()
14016            .render(
14017                &protocol,
14018                &state,
14019                &RenderOptions::new("index.html", "/"),
14020                &mut ordinary,
14021            )
14022            .unwrap();
14023        assert_eq!(ordinary.get_content(), expected);
14024
14025        let mut streaming = FlushTestWriter::default();
14026        WebUIHandler::new()
14027            .render_streaming(
14028                &protocol,
14029                &state,
14030                &RenderOptions::new("index.html", "/"),
14031                &mut streaming,
14032            )
14033            .unwrap();
14034        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14035        assert!(!streaming.output.contains("<!--wb:"));
14036        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14037        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14038    }
14039
14040    #[test]
14041    fn streaming_rejects_structural_signals_after_terminal() {
14042        let fragments = HashMap::from([(
14043            "index.html".to_string(),
14044            FragmentList {
14045                fragments: vec![
14046                    WebUIFragment::raw("<html><head>"),
14047                    structural_fragment("head_start"),
14048                    structural_fragment("head_end"),
14049                    WebUIFragment::raw("</head><body>"),
14050                    structural_fragment("body_start"),
14051                    structural_fragment("body_end"),
14052                    WebUIFragment::raw("</body>"),
14053                    structural_fragment("boundary_start:0"),
14054                    WebUIFragment::raw("</html>"),
14055                ],
14056                contains_boundary: false,
14057            },
14058        )]);
14059        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14060        let mut streaming = FlushTestWriter::default();
14061        let result = WebUIHandler::new().render_streaming(
14062            &protocol,
14063            &test_json!({}),
14064            &RenderOptions::new("index.html", "/"),
14065            &mut streaming,
14066        );
14067
14068        match result {
14069            Err(HandlerError::StreamingBoundary(error)) => {
14070                assert_eq!(error.signal, "boundary_start:0");
14071                assert!(
14072                    error
14073                        .reason
14074                        .contains("structural signal arrived after body_end"),
14075                    "reason: {}",
14076                    error.reason
14077                );
14078            }
14079            other => panic!("expected post-terminal rejection, got {other:?}"),
14080        }
14081        assert_eq!(
14082            streaming.output,
14083            "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14084        );
14085        assert!(!streaming.output.contains("<!--wb:0-->"));
14086
14087        let mut ordinary = TestWriter::new();
14088        WebUIHandler::new()
14089            .render(
14090                &protocol,
14091                &test_json!({}),
14092                &RenderOptions::new("index.html", "/"),
14093                &mut ordinary,
14094            )
14095            .unwrap();
14096        assert_eq!(
14097            ordinary.get_content(),
14098            "<html><head></head><body></body></html>"
14099        );
14100    }
14101
14102    fn disjoint_streaming_protocol_ext(
14103        hosts: &[&str],
14104        emit_root_signal: bool,
14105        include_styles: bool,
14106    ) -> Protocol {
14107        let mut fragments = HashMap::new();
14108        let mut entry = vec![
14109            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14110            structural_fragment("head_start"),
14111            structural_fragment("head_end"),
14112            WebUIFragment::raw("</head><body>"),
14113            structural_fragment("body_start"),
14114        ];
14115        for (sequence, host) in hosts.iter().enumerate() {
14116            entry.push(WebUIFragment::boundary(
14117                sequence as u32,
14118                "index.html",
14119                format!("boundary-{sequence}"),
14120                None,
14121            ));
14122            entry.push(WebUIFragment::raw(format!("<{host}")));
14123            if emit_root_signal {
14124                entry.push(structural_fragment(format!("streaming_root:{host}")));
14125            }
14126            entry.push(WebUIFragment::raw(">"));
14127            entry.push(WebUIFragment::component(*host));
14128            entry.push(WebUIFragment::raw(format!("</{host}>")));
14129            entry.push(WebUIFragment::boundary_end(sequence as u32));
14130        }
14131        entry.push(structural_fragment("body_end"));
14132        entry.push(WebUIFragment::raw("</body></html>"));
14133        fragments.insert(
14134            "index.html".to_string(),
14135            FragmentList {
14136                fragments: entry,
14137                contains_boundary: true,
14138            },
14139        );
14140        fragments.insert(
14141            "comp-a".to_string(),
14142            FragmentList {
14143                fragments: vec![WebUIFragment::raw("<b>A</b>")],
14144                contains_boundary: false,
14145            },
14146        );
14147        fragments.insert(
14148            "comp-b".to_string(),
14149            FragmentList {
14150                fragments: vec![WebUIFragment::raw("<b>B</b>")],
14151                contains_boundary: false,
14152            },
14153        );
14154
14155        let mut document = WebUIProtocol::new(fragments);
14156        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14157        document.components.insert(
14158            "comp-a".to_string(),
14159            webui_protocol::ComponentData {
14160                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14161                hydration_mode: StateProjectionMode::Keys as i32,
14162                hydration_keys: vec!["a_count".to_string()],
14163                ..Default::default()
14164            },
14165        );
14166        document.components.insert(
14167            "comp-b".to_string(),
14168            webui_protocol::ComponentData {
14169                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14170                hydration_mode: StateProjectionMode::Keys as i32,
14171                hydration_keys: vec!["b_count".to_string()],
14172                ..Default::default()
14173            },
14174        );
14175        if include_styles {
14176            document.set_css_strategy(webui_protocol::CssStrategy::Style);
14177            document
14178                .components
14179                .get_mut("comp-a")
14180                .expect("comp-a component")
14181                .css = ".comp-a{color:red}".to_string();
14182            document
14183                .components
14184                .get_mut("comp-b")
14185                .expect("comp-b component")
14186                .css = ".comp-b{color:blue}".to_string();
14187            document.populate_style_closures(&["index.html"]);
14188        }
14189        Protocol::new(document)
14190    }
14191
14192    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14193        disjoint_streaming_protocol_ext(hosts, true, false)
14194    }
14195
14196    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14197        disjoint_streaming_protocol_ext(hosts, true, true)
14198    }
14199
14200    fn streaming_root_validation_protocol(
14201        mut host_fragments: Vec<WebUIFragment>,
14202        tail: Vec<WebUIFragment>,
14203    ) -> Protocol {
14204        let mut entry = vec![
14205            WebUIFragment::raw("<html><head>"),
14206            structural_fragment("head_start"),
14207            structural_fragment("head_end"),
14208            WebUIFragment::raw("</head><body>"),
14209            structural_fragment("body_start"),
14210            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14211        ];
14212        entry.append(&mut host_fragments);
14213        entry.extend(tail);
14214        let fragments = HashMap::from([
14215            (
14216                "index.html".to_string(),
14217                FragmentList {
14218                    fragments: entry,
14219                    contains_boundary: true,
14220                },
14221            ),
14222            (
14223                "comp-a".to_string(),
14224                FragmentList {
14225                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14226                    contains_boundary: false,
14227                },
14228            ),
14229            (
14230                "comp-b".to_string(),
14231                FragmentList {
14232                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
14233                    contains_boundary: false,
14234                },
14235            ),
14236        ]);
14237        Protocol::new(WebUIProtocol::new(fragments))
14238    }
14239
14240    fn completed_streaming_tail() -> Vec<WebUIFragment> {
14241        vec![
14242            WebUIFragment::raw("</comp-a>"),
14243            WebUIFragment::boundary_end(0),
14244            structural_fragment("body_end"),
14245            WebUIFragment::raw("</body></html>"),
14246        ]
14247    }
14248
14249    fn assert_streaming_root_error(
14250        protocol: &Protocol,
14251        expected_signal: &str,
14252        expected_reason: &str,
14253    ) {
14254        let mut writer = FlushTestWriter::default();
14255        let result = WebUIHandler::new().render_streaming(
14256            protocol,
14257            &test_json!({}),
14258            &RenderOptions::new("index.html", "/"),
14259            &mut writer,
14260        );
14261        match result {
14262            Err(HandlerError::StreamingBoundary(error)) => {
14263                assert_eq!(error.signal, expected_signal);
14264                assert!(
14265                    error.reason.contains(expected_reason),
14266                    "reason: {}",
14267                    error.reason
14268                );
14269            }
14270            other => panic!("expected streaming-root rejection, got {other:?}"),
14271        }
14272    }
14273
14274    #[test]
14275    fn streaming_root_signal_injects_data_ws_inside_boundary() {
14276        let protocol = disjoint_streaming_protocol(&["comp-a"]);
14277        let handler = WebUIHandler::with_plugin(|| {
14278            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14279        });
14280        let mut writer = FlushTestWriter::default();
14281        handler
14282            .render_streaming(
14283                &protocol,
14284                &test_json!({ "a_count": 1 }),
14285                &RenderOptions::new("index.html", "/"),
14286                &mut writer,
14287            )
14288            .unwrap();
14289        // The parser-owned signal is consumed to inject exactly ` data-ws`
14290        // inside the host's opening tag, before the custom element upgrades.
14291        assert!(
14292            writer.output.contains("<comp-a data-ws>"),
14293            "streamed host must carry data-ws: {}",
14294            writer.output
14295        );
14296    }
14297
14298    #[test]
14299    fn streaming_root_signal_preserves_ordinary_output_bytes() {
14300        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
14301        // output with and without the signal, and never a `data-ws` attribute.
14302        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14303        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14304        let state = test_json!({ "a_count": 1, "b_count": 2 });
14305        let plugin = || {
14306            WebUIHandler::with_plugin(
14307                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14308            )
14309        };
14310        let mut with_writer = TestWriter::new();
14311        let mut without_writer = TestWriter::new();
14312        plugin()
14313            .render(
14314                &with_signal,
14315                &state,
14316                &RenderOptions::new("index.html", "/"),
14317                &mut with_writer,
14318            )
14319            .unwrap();
14320        plugin()
14321            .render(
14322                &without_signal,
14323                &state,
14324                &RenderOptions::new("index.html", "/"),
14325                &mut without_writer,
14326            )
14327            .unwrap();
14328        // The rendered DOM (everything up to the inert data block) is
14329        // deterministic and is where a leaked `data-ws` would appear. The
14330        // ordinary template map is HashSet-ordered, so compare the DOM prefix
14331        // for byte identity rather than the whole document.
14332        let dom_prefix = |content: &str| -> String {
14333            content
14334                .split_once(r#"<script type="application/json" id="webui-data""#)
14335                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14336        };
14337        assert_eq!(
14338            dom_prefix(&with_writer.get_content()),
14339            dom_prefix(&without_writer.get_content())
14340        );
14341        assert!(!with_writer.get_content().contains("data-ws"));
14342        assert!(with_writer.get_content().contains("<comp-a>"));
14343    }
14344
14345    #[test]
14346    fn streaming_component_without_root_signal_is_rejected() {
14347        let protocol = streaming_root_validation_protocol(
14348            vec![
14349                WebUIFragment::raw("<comp-a>"),
14350                WebUIFragment::component("comp-a"),
14351            ],
14352            completed_streaming_tail(),
14353        );
14354
14355        assert_streaming_root_error(
14356            &protocol,
14357            "comp-a",
14358            "has no compiler-owned streaming_root signal",
14359        );
14360    }
14361
14362    #[test]
14363    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14364        let duplicate = streaming_root_validation_protocol(
14365            vec![
14366                WebUIFragment::raw("<comp-a"),
14367                structural_fragment("streaming_root:comp-a"),
14368                structural_fragment("streaming_root:comp-a"),
14369                WebUIFragment::raw(">"),
14370                WebUIFragment::component("comp-a"),
14371            ],
14372            completed_streaming_tail(),
14373        );
14374        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14375
14376        let after_tag_close = streaming_root_validation_protocol(
14377            vec![
14378                WebUIFragment::raw("<comp-a>"),
14379                structural_fragment("streaming_root:comp-a"),
14380                WebUIFragment::component("comp-a"),
14381            ],
14382            completed_streaming_tail(),
14383        );
14384        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14385
14386        let detached_from_opening = streaming_root_validation_protocol(
14387            vec![
14388                WebUIFragment::raw("not an opening tag"),
14389                structural_fragment("streaming_root:comp-a"),
14390                WebUIFragment::raw(">"),
14391                WebUIFragment::component("comp-a"),
14392            ],
14393            completed_streaming_tail(),
14394        );
14395        assert_streaming_root_error(
14396            &detached_from_opening,
14397            "comp-a",
14398            "unclosed component opening-tag close",
14399        );
14400    }
14401
14402    #[test]
14403    fn mismatched_streaming_root_signal_is_rejected() {
14404        let protocol = streaming_root_validation_protocol(
14405            vec![
14406                WebUIFragment::raw("<comp-a"),
14407                structural_fragment("streaming_root:comp-b"),
14408                WebUIFragment::raw(">"),
14409                WebUIFragment::component("comp-a"),
14410            ],
14411            completed_streaming_tail(),
14412        );
14413
14414        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14415    }
14416
14417    #[test]
14418    fn pending_streaming_root_is_rejected_at_structural_ends() {
14419        let protocol = streaming_root_validation_protocol(
14420            vec![
14421                WebUIFragment::raw("<comp-a"),
14422                structural_fragment("streaming_root:comp-a"),
14423                WebUIFragment::raw(">"),
14424            ],
14425            vec![structural_fragment("boundary_end:0")],
14426        );
14427        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14428    }
14429
14430    #[test]
14431    fn streaming_root_outside_boundary_is_rejected() {
14432        let fragments = HashMap::from([
14433            (
14434                "index.html".to_string(),
14435                FragmentList {
14436                    fragments: vec![
14437                        WebUIFragment::raw("<html><head>"),
14438                        structural_fragment("head_start"),
14439                        structural_fragment("head_end"),
14440                        WebUIFragment::raw("</head><body>"),
14441                        structural_fragment("body_start"),
14442                        WebUIFragment::raw("<comp-a"),
14443                        structural_fragment("streaming_root:comp-a"),
14444                        WebUIFragment::raw(">"),
14445                        WebUIFragment::component("comp-a"),
14446                        WebUIFragment::raw("</comp-a>"),
14447                        structural_fragment("body_end"),
14448                        WebUIFragment::raw("</body></html>"),
14449                    ],
14450                    contains_boundary: false,
14451                },
14452            ),
14453            (
14454                "comp-a".to_string(),
14455                FragmentList {
14456                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
14457                    contains_boundary: false,
14458                },
14459            ),
14460        ]);
14461        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14462        let mut writer = FlushTestWriter::default();
14463        let result = WebUIHandler::new().render_streaming(
14464            &protocol,
14465            &test_json!({}),
14466            &RenderOptions::new("index.html", "/"),
14467            &mut writer,
14468        );
14469        match result {
14470            Err(HandlerError::StreamingBoundary(err)) => {
14471                assert_eq!(err.signal, "comp-a");
14472                assert!(
14473                    err.reason
14474                        .contains("no boundary or generated component span"),
14475                    "reason: {}",
14476                    err.reason
14477                );
14478            }
14479            other => panic!("expected outside-boundary rejection, got {other:?}"),
14480        }
14481    }
14482
14483    fn parser_route_protocol(with_boundary: bool) -> Protocol {
14484        let mut parser = HtmlParser::with_options(DomStrategy::Light);
14485        parser
14486            .component_registry_mut()
14487            .register_component(ComponentRegistration::new(
14488                "route-page",
14489                "<p>route content</p>",
14490                None,
14491                true,
14492            ))
14493            .expect("register route component");
14494        let route = r#"<route path="/" component="route-page" exact />"#;
14495        let html = if with_boundary {
14496            format!(
14497                "<html><head></head><body>\
14498                 <boundary name=\"route\">{route}</boundary>\
14499                 </body></html>"
14500            )
14501        } else {
14502            format!("<html><head></head><body>{route}</body></html>")
14503        };
14504        parser
14505            .parse("index.html", &html)
14506            .expect("parse route streaming fixture");
14507        let mut document = WebUIProtocol::new(parser.into_fragment_records());
14508        document.components.insert(
14509            "route-page".to_string(),
14510            webui_protocol::ComponentData::default(),
14511        );
14512        Protocol::new(document)
14513    }
14514
14515    #[test]
14516    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14517        let protocol = parser_route_protocol(true);
14518        let mut writer = FlushTestWriter::default();
14519        WebUIHandler::new()
14520            .render_streaming(
14521                &protocol,
14522                &test_json!({}),
14523                &RenderOptions::new("index.html", "/"),
14524                &mut writer,
14525            )
14526            .unwrap();
14527
14528        assert!(
14529            writer
14530                .output
14531                .contains("<route-page data-ws><p>route content</p></route-page>"),
14532            "matched route host must be deferred before upgrade: {}",
14533            writer.output
14534        );
14535    }
14536
14537    #[test]
14538    fn streaming_parser_route_host_outside_boundary_is_rejected() {
14539        let protocol = parser_route_protocol(false);
14540        let mut writer = FlushTestWriter::default();
14541        let result = WebUIHandler::new().render_streaming(
14542            &protocol,
14543            &test_json!({}),
14544            &RenderOptions::new("index.html", "/"),
14545            &mut writer,
14546        );
14547
14548        match result {
14549            Err(HandlerError::StreamingBoundary(error)) => {
14550                assert_eq!(error.signal, "route-page");
14551                assert!(
14552                    error
14553                        .reason
14554                        .contains("no boundary or generated component span"),
14555                    "reason: {}",
14556                    error.reason
14557                );
14558            }
14559            other => panic!("expected route boundary rejection, got {other:?}"),
14560        }
14561        assert!(
14562            !writer.output.contains("<route-page>"),
14563            "an unmarked custom-element opening must never be completed"
14564        );
14565    }
14566
14567    #[test]
14568    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14569        let entry = vec![
14570            WebUIFragment::raw("<html><head>"),
14571            structural_fragment("head_start"),
14572            structural_fragment("head_end"),
14573            WebUIFragment::raw("</head><body>"),
14574            structural_fragment("body_start"),
14575            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14576            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14577                path: "/account".into(),
14578                fragment_id: "account-shell".into(),
14579                exact: false,
14580                ..Default::default()
14581            }),
14582            WebUIFragment::boundary_end(0),
14583            structural_fragment("body_end"),
14584            WebUIFragment::raw("</body></html>"),
14585        ];
14586        let fragments = HashMap::from([
14587            (
14588                "index.html".to_string(),
14589                FragmentList {
14590                    fragments: entry,
14591                    contains_boundary: true,
14592                },
14593            ),
14594            (
14595                "account-shell".to_string(),
14596                FragmentList {
14597                    fragments: vec![WebUIFragment::route_from(
14598                        webui_protocol::WebUiFragmentRoute {
14599                            path: "./details".into(),
14600                            fragment_id: "details-page".into(),
14601                            exact: true,
14602                            pending_component: "details-loading".into(),
14603                            error_component: "details-error".into(),
14604                            ..Default::default()
14605                        },
14606                    )],
14607                    contains_boundary: false,
14608                },
14609            ),
14610            (
14611                "details-page".to_string(),
14612                FragmentList {
14613                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
14614                    contains_boundary: false,
14615                },
14616            ),
14617            (
14618                "details-loading".to_string(),
14619                FragmentList {
14620                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14621                    contains_boundary: false,
14622                },
14623            ),
14624            (
14625                "details-error".to_string(),
14626                FragmentList {
14627                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
14628                    contains_boundary: false,
14629                },
14630            ),
14631        ]);
14632        let mut document = WebUIProtocol::new(fragments);
14633        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14634        for name in [
14635            "account-shell",
14636            "details-page",
14637            "details-loading",
14638            "details-error",
14639        ] {
14640            document.components.insert(
14641                name.to_string(),
14642                webui_protocol::ComponentData {
14643                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14644                    ..Default::default()
14645                },
14646            );
14647        }
14648        let protocol = Protocol::new(document);
14649        let handler = WebUIHandler::with_plugin(|| {
14650            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14651        });
14652        let mut writer = FlushTestWriter::default();
14653
14654        handler
14655            .render_streaming(
14656                &protocol,
14657                &test_json!({}),
14658                &RenderOptions::new("index.html", "/account/details"),
14659                &mut writer,
14660            )
14661            .unwrap();
14662
14663        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14664        assert!(
14665            first.contains(r#""details-loading":"#),
14666            "relative-route pending metadata missing: {first}"
14667        );
14668        assert!(
14669            first.contains(r#""details-error":"#),
14670            "relative-route error metadata missing: {first}"
14671        );
14672    }
14673
14674    #[test]
14675    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14676        let entry = vec![
14677            WebUIFragment::raw("<html><head>"),
14678            structural_fragment("head_start"),
14679            structural_fragment("head_end"),
14680            WebUIFragment::raw("</head><body>"),
14681            structural_fragment("body_start"),
14682            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14683            WebUIFragment::raw("<static-shell"),
14684            structural_fragment("streaming_root:static-shell"),
14685            WebUIFragment::raw(">"),
14686            WebUIFragment::component("static-shell"),
14687            WebUIFragment::raw("</static-shell>"),
14688            WebUIFragment::raw("<route-shell"),
14689            structural_fragment("streaming_root:route-shell"),
14690            WebUIFragment::raw(">"),
14691            WebUIFragment::component("route-shell"),
14692            WebUIFragment::raw("</route-shell>"),
14693            WebUIFragment::boundary_end(0),
14694            structural_fragment("body_end"),
14695            WebUIFragment::raw("</body></html>"),
14696        ];
14697        let fragments = HashMap::from([
14698            (
14699                "index.html".to_string(),
14700                FragmentList {
14701                    fragments: entry,
14702                    contains_boundary: true,
14703                },
14704            ),
14705            (
14706                "static-shell".to_string(),
14707                FragmentList {
14708                    fragments: vec![WebUIFragment::if_cond(
14709                        ConditionExpr::identifier("show_hidden"),
14710                        "static-hidden-if",
14711                    )],
14712                    contains_boundary: false,
14713                },
14714            ),
14715            (
14716                "static-hidden-if".to_string(),
14717                FragmentList {
14718                    fragments: vec![WebUIFragment::component("static-hidden")],
14719                    contains_boundary: false,
14720                },
14721            ),
14722            (
14723                "static-hidden".to_string(),
14724                FragmentList {
14725                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14726                    contains_boundary: false,
14727                },
14728            ),
14729            (
14730                "route-shell".to_string(),
14731                FragmentList {
14732                    fragments: vec![WebUIFragment::route_from(
14733                        webui_protocol::WebUiFragmentRoute {
14734                            path: "/account".into(),
14735                            fragment_id: "route-page".into(),
14736                            exact: true,
14737                            ..Default::default()
14738                        },
14739                    )],
14740                    contains_boundary: false,
14741                },
14742            ),
14743            (
14744                "route-page".to_string(),
14745                FragmentList {
14746                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
14747                    contains_boundary: false,
14748                },
14749            ),
14750        ]);
14751        let mut document = WebUIProtocol::new(fragments);
14752        document.set_css_strategy(webui_protocol::CssStrategy::Link);
14753        document.initial_state_strategy = InitialStateStrategy::Components as i32;
14754        for (name, keys) in [
14755            ("static-shell", vec!["show_hidden", "static_count"]),
14756            ("static-hidden", vec!["hidden_count"]),
14757            ("route-shell", vec!["route_shell_count"]),
14758            ("route-page", vec!["route_count"]),
14759        ] {
14760            document.components.insert(
14761                name.to_string(),
14762                webui_protocol::ComponentData {
14763                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14764                    hydration_mode: StateProjectionMode::Keys as i32,
14765                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
14766                    css_href: format!("/{name}.css"),
14767                    ..Default::default()
14768                },
14769            );
14770        }
14771        document.populate_style_closures(&["index.html"]);
14772        let protocol = Protocol::new(document);
14773        assert_eq!(
14774            protocol
14775                .component_reachability()
14776                .is_route_dependent(protocol.component_index()["route-shell"]),
14777            Some(true),
14778            "fixture must exercise request-aware checkpoint reachability"
14779        );
14780        let handler = WebUIHandler::with_plugin(|| {
14781            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14782        });
14783        let mut writer = FlushTestWriter::default();
14784
14785        handler
14786            .render_streaming(
14787                &protocol,
14788                &test_json!({
14789                    "show_hidden": false,
14790                    "static_count": 1,
14791                    "hidden_count": 2,
14792                    "route_shell_count": 3,
14793                    "route_count": 4,
14794                    "unrelated": "private",
14795                }),
14796                &RenderOptions::new("index.html", "/account"),
14797                &mut writer,
14798            )
14799            .unwrap();
14800
14801        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14802        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14803            assert!(
14804                checkpoint.contains(&format!(r#""{template}":"#)),
14805                "{template} metadata missing: {checkpoint}"
14806            );
14807        }
14808        for state in [
14809            r#""show_hidden":false"#,
14810            r#""static_count":1"#,
14811            r#""hidden_count":2"#,
14812            r#""route_shell_count":3"#,
14813            r#""route_count":4"#,
14814        ] {
14815            assert!(
14816                checkpoint.contains(state),
14817                "{state} state missing: {checkpoint}"
14818            );
14819        }
14820        assert!(
14821            !checkpoint.contains("unrelated"),
14822            "checkpoint leaked unrelated state: {checkpoint}"
14823        );
14824        assert!(
14825            checkpoint.contains(
14826                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14827            ),
14828            "checkpoint metadata lost source order: {checkpoint}"
14829        );
14830        assert!(
14831            checkpoint.contains(r#""componentStyles":"#)
14832                && checkpoint.contains(r#""strategy":"link""#)
14833                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14834            "checkpoint is missing tree-local style metadata: {checkpoint}"
14835        );
14836        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14837            assert_eq!(
14838                writer
14839                    .output
14840                    .matches(&format!(r#"data-webui-resource="{resource}""#))
14841                    .count(),
14842                1,
14843                "Document delivery state must persist across streamed checkpoints"
14844            );
14845        }
14846    }
14847
14848    #[test]
14849    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
14850        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
14851        // envelope must be locally scoped — templates/state for exactly the
14852        // components rendered since the previous checkpoint, with reused tags
14853        // carrying hydration state but never a duplicate template.
14854        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14855        let handler = WebUIHandler::with_plugin(|| {
14856            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14857        });
14858        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
14859        let mut writer = FlushTestWriter::default();
14860        handler
14861            .render_streaming(
14862                &protocol,
14863                &state,
14864                &RenderOptions::new("index.html", "/"),
14865                &mut writer,
14866            )
14867            .unwrap();
14868
14869        assert_eq!(
14870            writer.flushes.len(),
14871            5,
14872            "bootstrap, three boundary commits, and one terminal"
14873        );
14874        let segment = |index: usize| -> &str {
14875            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14876        };
14877        let b0 = segment(0);
14878        let b1 = segment(1);
14879        let b2 = segment(2);
14880
14881        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
14882        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
14883        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
14884        assert!(
14885            b0.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
14886            "b0: {b0}"
14887        );
14888        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
14889        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
14890        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
14891
14892        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
14893        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
14894        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
14895        assert!(
14896            b1.contains(r#"[2,1,0,1,{"declarationId":1,"componentStyles":"#),
14897            "b1: {b1}"
14898        );
14899        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
14900        assert!(
14901            !b1.contains(r#""templates":{"comp-a"#),
14902            "b1 re-sent comp-a: {b1}"
14903        );
14904        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
14905
14906        // Boundary 2: comp-a reused — state present, template absent (empty delta).
14907        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
14908        assert!(
14909            b2.contains(r#"[2,2,0,2,{"declarationId":2,"componentStyles":"#),
14910            "b2: {b2}"
14911        );
14912        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
14913        assert!(
14914            !b2.contains(r#""templates""#),
14915            "b2 re-sent a template: {b2}"
14916        );
14917
14918        // Server-only state never leaks into any envelope.
14919        assert!(!writer.output.contains("serverOnly"));
14920    }
14921
14922    #[test]
14923    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
14924        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14925        let handler = WebUIHandler::with_plugin(|| {
14926            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14927        });
14928        let mut writer = FlushTestWriter::default();
14929        handler
14930            .render_streaming(
14931                &protocol,
14932                &test_json!({ "a_count": 1, "b_count": 2 }),
14933                &RenderOptions::new("index.html", "/"),
14934                &mut writer,
14935            )
14936            .unwrap();
14937
14938        let segment = |index: usize| -> &str {
14939            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14940        };
14941        let checkpoint_styles = |checkpoint: &str| -> Value {
14942            let script = checkpoint
14943                .rfind(r#"<script type="application/json" data-webui-boundary"#)
14944                .unwrap();
14945            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
14946            let payload_end =
14947                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
14948            let record: Value =
14949                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
14950            record[4]["componentStyles"].clone()
14951        };
14952
14953        let first = checkpoint_styles(segment(0));
14954        assert!(first["resources"]["comp-a"].is_object());
14955        assert!(first["resources"]["comp-b"].is_object());
14956        assert!(first["closures"]["index.html"].is_array());
14957        assert!(first["closures"]["comp-a"].is_array());
14958
14959        let second = checkpoint_styles(segment(1));
14960        assert_eq!(second["resources"], test_json!({}));
14961        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
14962
14963        let repeated = checkpoint_styles(segment(2));
14964        assert_eq!(repeated["resources"], test_json!({}));
14965        assert_eq!(repeated["closures"], test_json!({}));
14966        for resource in ["comp-a", "comp-b"] {
14967            assert_eq!(
14968                [&first, &second, &repeated]
14969                    .iter()
14970                    .filter(|styles| {
14971                        styles["resources"]
14972                            .as_object()
14973                            .is_some_and(|resources| resources.contains_key(resource))
14974                    })
14975                    .count(),
14976                1,
14977                "{resource} style metadata was serialized more than once"
14978            );
14979        }
14980    }
14981
14982    #[test]
14983    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
14984        let entry = vec![
14985            WebUIFragment::raw("<html><head>"),
14986            structural_fragment("head_start"),
14987            structural_fragment("head_end"),
14988            WebUIFragment::raw("</head><body>"),
14989            structural_fragment("body_start"),
14990            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14991            WebUIFragment::raw("<comp-a"),
14992            structural_fragment("streaming_root:comp-a"),
14993            WebUIFragment::raw(">"),
14994            WebUIFragment::component("comp-a"),
14995            WebUIFragment::raw("</comp-a>"),
14996            WebUIFragment::boundary_end(0),
14997            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14998            WebUIFragment::raw("<comp-hidden"),
14999            structural_fragment("streaming_root:comp-hidden"),
15000            WebUIFragment::raw(">"),
15001            WebUIFragment::component("comp-hidden"),
15002            WebUIFragment::raw("</comp-hidden>"),
15003            WebUIFragment::boundary_end(1),
15004            structural_fragment("body_end"),
15005            WebUIFragment::raw("</body></html>"),
15006        ];
15007        let fragments = HashMap::from([
15008            (
15009                "index.html".to_string(),
15010                FragmentList {
15011                    fragments: entry,
15012                    contains_boundary: true,
15013                },
15014            ),
15015            (
15016                "comp-a".to_string(),
15017                FragmentList {
15018                    fragments: vec![WebUIFragment::if_cond(
15019                        ConditionExpr::identifier("show_hidden"),
15020                        "hidden-if",
15021                    )],
15022                    contains_boundary: false,
15023                },
15024            ),
15025            (
15026                "hidden-if".to_string(),
15027                FragmentList {
15028                    fragments: vec![WebUIFragment::component("comp-hidden")],
15029                    contains_boundary: false,
15030                },
15031            ),
15032            (
15033                "comp-hidden".to_string(),
15034                FragmentList {
15035                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15036                    contains_boundary: false,
15037                },
15038            ),
15039        ]);
15040        let mut document = WebUIProtocol::new(fragments);
15041        document.set_css_strategy(webui_protocol::CssStrategy::Module);
15042        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15043        document.components.insert(
15044            "comp-a".to_string(),
15045            webui_protocol::ComponentData {
15046                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15047                hydration_mode: StateProjectionMode::Keys as i32,
15048                hydration_keys: vec!["show_hidden".to_string()],
15049                ..Default::default()
15050            },
15051        );
15052        document.components.insert(
15053            "comp-hidden".to_string(),
15054            webui_protocol::ComponentData {
15055                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15056                css: ".hidden{display:block}".to_string(),
15057                hydration_mode: StateProjectionMode::Keys as i32,
15058                hydration_keys: vec!["hidden_count".to_string()],
15059                ..Default::default()
15060            },
15061        );
15062        document.populate_style_closures(&["index.html"]);
15063        let protocol = Protocol::new(document);
15064        let handler = WebUIHandler::with_plugin(|| {
15065            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15066        });
15067        let mut writer = FlushTestWriter::default();
15068
15069        handler
15070            .render_streaming(
15071                &protocol,
15072                &test_json!({
15073                    "show_hidden": false,
15074                    "hidden_count": 7,
15075                    "unrelated": "private",
15076                }),
15077                &RenderOptions::new("index.html", "/"),
15078                &mut writer,
15079            )
15080            .unwrap();
15081
15082        let boundary = |index: usize| -> &str {
15083            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15084        };
15085        let first = boundary(0);
15086        let second = boundary(1);
15087
15088        assert!(first.contains(r#""comp-a":"#), "first: {first}");
15089        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15090        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15091        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15092        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15093        assert!(
15094            first.contains(r#"<script type="importmap""#),
15095            "first: {first}"
15096        );
15097        assert!(
15098            !first.contains("unrelated"),
15099            "first leaked unrelated state: {first}"
15100        );
15101
15102        assert!(
15103            !second.contains(r#""templates""#),
15104            "second re-sent metadata: {second}"
15105        );
15106        assert!(
15107            !second.contains(r#"<script type="importmap""#),
15108            "second re-sent CSS: {second}"
15109        );
15110        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15111        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15112        assert_eq!(
15113            writer
15114                .output
15115                .matches(r#"<script type="importmap">"#)
15116                .count(),
15117            1,
15118            "import-map output: {}",
15119            writer.output
15120        );
15121    }
15122
15123    #[test]
15124    fn streaming_flush_count_is_boundaries_plus_one() {
15125        for &count in &[1usize, 3, 10, 100] {
15126            let hosts: Vec<&str> = (0..count)
15127                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15128                .collect();
15129            let protocol = disjoint_streaming_protocol(&hosts);
15130            let handler = WebUIHandler::with_plugin(|| {
15131                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15132            });
15133            let mut writer = FlushTestWriter::default();
15134            handler
15135                .render_streaming(
15136                    &protocol,
15137                    &test_json!({ "a_count": 1, "b_count": 2 }),
15138                    &RenderOptions::new("index.html", "/"),
15139                    &mut writer,
15140                )
15141                .unwrap();
15142            assert_eq!(
15143                writer.flushes.len(),
15144                count + 2,
15145                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15146            );
15147        }
15148    }
15149
15150    #[test]
15151    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15152        let mut fragments = HashMap::new();
15153        fragments.insert("comp-a".to_string(), FragmentList::default());
15154        fragments.insert("comp-b".to_string(), FragmentList::default());
15155        let mut protocol = WebUIProtocol::new(fragments);
15156        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15157        protocol.components.insert(
15158            "comp-a".to_string(),
15159            webui_protocol::ComponentData {
15160                hydration_mode: StateProjectionMode::Keys as i32,
15161                hydration_keys: vec!["a".to_string(), "shared".to_string()],
15162                ..Default::default()
15163            },
15164        );
15165        protocol.components.insert(
15166            "comp-b".to_string(),
15167            webui_protocol::ComponentData {
15168                hydration_mode: StateProjectionMode::Keys as i32,
15169                hydration_keys: vec!["b".to_string(), "shared".to_string()],
15170                ..Default::default()
15171            },
15172        );
15173
15174        let protocol = Protocol::new(protocol);
15175        let component_index = protocol.component_index();
15176        let reachability = protocol.component_reachability();
15177        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15178        assert!(!collect_hydration_key_ids_into(
15179            protocol.protocol(),
15180            reachability,
15181            [component_index["comp-a"]],
15182            &mut scratch,
15183        ));
15184        let keys = scratch
15185            .iter()
15186            .filter_map(|id| reachability.hydration_key(*id))
15187            .collect::<Vec<_>>();
15188        assert_eq!(keys, ["a", "shared"]);
15189        let pointer = scratch.as_ptr();
15190        let capacity = scratch.capacity();
15191
15192        assert!(!collect_hydration_key_ids_into(
15193            protocol.protocol(),
15194            reachability,
15195            [component_index["comp-b"]],
15196            &mut scratch,
15197        ));
15198        let keys = scratch
15199            .iter()
15200            .filter_map(|id| reachability.hydration_key(*id))
15201            .collect::<Vec<_>>();
15202        assert_eq!(keys, ["b", "shared"]);
15203        assert_eq!(scratch.as_ptr(), pointer);
15204        assert_eq!(scratch.capacity(), capacity);
15205    }
15206}