Skip to main content

webui_handler/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebUI Handler implementation for Rust.
5//!
6//! This crate provides functionality to process and render WebUI protocols
7//! into final HTML output based on provided data.
8
9pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12mod render_capacity;
13mod response_writer;
14pub mod route_handler;
15pub mod route_matcher;
16pub(crate) mod route_renderer;
17pub(crate) mod streaming;
18
19#[doc(hidden)]
20pub use html_encode::{append_escaped_to_bytes, append_escaped_to_string};
21#[doc(hidden)]
22pub use render_capacity::RenderCapacityHints;
23#[doc(hidden)]
24pub use response_writer::{
25    append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
26    append_boolean_attribute_to_string,
27};
28pub use route_handler::Protocol;
29
30/// Minimal HTML escaper for the 6 XSS-critical characters
31/// (`& < > " ' /`). Returns `Cow::Borrowed` when no escaping is
32/// needed (zero allocation on the happy path), `Cow::Owned` when
33/// any character had to be replaced.
34///
35/// Re-exported here so external callers of `RenderOptions::with_head_inject`
36/// / `with_body_inject` can pre-escape untrusted content with the
37/// same escaper the handler uses internally for SSR text content,
38/// without having to pull in a separate HTML-escape crate.
39pub use html_encode::encode_safe;
40
41use plugin::BootstrapExtensionContext;
42use plugin::HandlerPlugin;
43use plugin::WebUiTemplatePayload;
44use route_handler::ComponentReachabilityIndex;
45use route_matcher::CompiledRouteIndex;
46use serde::ser::SerializeMap;
47use serde::Serialize;
48use serde_json::Value;
49use std::borrow::Cow;
50use std::cell::{Cell, OnceCell};
51use std::collections::{HashMap, HashSet};
52use std::fmt::{self, Write as _};
53use std::hash::{BuildHasherDefault, Hasher};
54use std::sync::{Arc, LazyLock};
55use streaming::{
56    consume_streaming_component_root, ensure_no_pending_streaming_root,
57    prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
58    validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
59    StreamingRenderState,
60};
61pub use streaming::{
62    BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
63    SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
64    MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
65};
66use thiserror::Error;
67use webui_expressions::{evaluate_with_resolver, ExpressionError};
68use webui_protocol::{
69    web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
70    InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
71};
72use webui_state::find_value_by_dotted_path_ref;
73
74/// Error types for the WebUI handler.
75#[derive(Debug, Error)]
76pub enum HandlerError {
77    #[error("Rendering error: {0}")]
78    Rendering(String),
79
80    /// Host-supplied state JSON could not be parsed or validated.
81    ///
82    /// This is a caller input error, not a render failure. Bindings map it to
83    /// their own state-error type, so it must stay distinguishable from
84    /// [`HandlerError::Rendering`] without inspecting the message text.
85    #[error("invalid state JSON: {0}")]
86    InvalidState(String),
87
88    #[error("Rendering invariant error: {0}")]
89    Invariant(String),
90
91    #[error("Missing fragment: {0}")]
92    MissingFragment(String),
93
94    #[error("Missing data field: {0}")]
95    MissingData(String),
96
97    #[error("Type error: {0}")]
98    TypeError(String),
99
100    #[error("Protocol error: {0}")]
101    Protocol(#[from] webui_protocol::ProtocolError),
102
103    #[error("Evaluation error: {0}")]
104    Evaluation(String),
105
106    #[error("I/O error: {0}")]
107    Io(#[from] std::io::Error),
108
109    #[error("Writer error: {0}")]
110    Writer(String),
111
112    #[error("Plugin data error: {0}")]
113    PluginData(String),
114
115    /// The HTTP client disconnected before the render completed.
116    ///
117    /// Streaming `ResponseWriter` implementations return this from
118    /// `write()` once their channel/socket is closed, so the handler
119    /// can abort the render rather than do CPU work that has nowhere
120    /// to go. Allocation-free (the variant carries no payload).
121    #[error("client disconnected")]
122    ClientDisconnected,
123
124    /// The streaming writer's flush exceeded its configured deadline.
125    ///
126    /// Indicates a slow/unresponsive consumer (slow-loris client,
127    /// stuck proxy, etc.). The render thread is freed; downstream
128    /// telemetry should distinguish this from `ClientDisconnected`
129    /// so ops can alert on slow-client attacks.
130    #[error("streaming flush timed out")]
131    StreamTimeout,
132
133    /// A streaming-boundary signal was malformed or arrived out of order.
134    ///
135    /// The payload is boxed so this cold, streaming-only variant does not
136    /// widen [`HandlerError`] — and therefore `Result<(), HandlerError>`
137    /// threaded through the entire hot legacy render path — with two inline
138    /// `String`s.
139    #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
140    StreamingBoundary(Box<StreamingBoundaryError>),
141
142    /// A streaming render ended without its structural `body_end` signal.
143    #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
144    MissingStreamingBodyEnd,
145
146    /// Streaming initialization cannot be emitted before document content.
147    #[error(
148        "streaming protocol is missing the required `head_start` signal before `{before}`; \
149         rebuild the protocol with streaming-boundary parser support"
150    )]
151    MissingStreamingHeadStart {
152        /// Structural point that made initialization too late.
153        before: &'static str,
154    },
155
156    /// A malformed protocol emitted streaming initialization more than once.
157    #[error("streaming protocol emitted duplicate `head_start` signals")]
158    DuplicateStreamingHeadStart,
159}
160
161/// Boxed payload for [`HandlerError::StreamingBoundary`].
162///
163/// Kept behind a `Box` so the streaming-validation variant contributes only a
164/// pointer-sized payload to [`HandlerError`], keeping the common
165/// `Result<(), HandlerError>` small on the ordinary render path where these
166/// boundary errors never occur.
167#[derive(Debug)]
168pub struct StreamingBoundaryError {
169    /// The logical structural token (the internal namespace is stripped).
170    pub signal: String,
171    /// Actionable validation failure.
172    pub reason: String,
173}
174
175pub type Result<T> = std::result::Result<T, HandlerError>;
176
177#[cold]
178#[inline(never)]
179fn invalid_fragment_range_error(
180    range: &std::ops::Range<usize>,
181    fragment_count: usize,
182) -> HandlerError {
183    HandlerError::Invariant(format!(
184        "fragment range {}..{} exceeds fragment count {fragment_count}",
185        range.start, range.end
186    ))
187}
188
189#[cold]
190#[inline(never)]
191fn route_style_plan_missing_error() -> HandlerError {
192    HandlerError::Invariant(
193        "matched route style targets were computed without a route chain".to_string(),
194    )
195}
196
197#[cold]
198#[inline(never)]
199fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
200    HandlerError::Invariant(format!(
201        "matched route style target count {targets} does not match route count {routes}"
202    ))
203}
204
205/// Interface for writing rendered output
206pub trait ResponseWriter {
207    /// Write content to the output
208    fn write(&mut self, content: &str) -> Result<()>;
209
210    /// Write untrusted text with the same HTML escaping as [`encode_safe`].
211    ///
212    /// Buffered writers may override this to append encoded text directly
213    /// without allocating an intermediate string.
214    fn write_escaped(&mut self, content: &str) -> Result<()> {
215        self.write(&encode_safe(content))
216    }
217
218    /// Write a quoted HTML attribute, escaping its untrusted value.
219    ///
220    /// The name must already be a valid, trusted attribute name. Implementations
221    /// must preserve the six entity replacements used by [`encode_safe`].
222    fn write_escaped_attribute(&mut self, name: &str, value: &str) -> Result<()> {
223        self.write_attribute(name, &encode_safe(value))
224    }
225
226    /// Write one complete quoted HTML attribute.
227    #[doc(hidden)]
228    fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
229        self.write(" ")?;
230        self.write(name)?;
231        self.write("=\"")?;
232        self.write(value)?;
233        self.write("\"")
234    }
235
236    /// Write one complete boolean HTML attribute.
237    #[doc(hidden)]
238    fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
239        self.write(" ")?;
240        self.write(name)
241    }
242
243    /// Finalize the output
244    fn end(&mut self) -> Result<()>;
245
246    /// Hand buffered bytes to the transport at a committed streaming boundary.
247    ///
248    /// Internal hook used only by the progressive streaming render path so the
249    /// request-local sink can flush its concrete transport without a shared-cell
250    /// borrow or a second virtual dispatch on every write. Ordinary writers keep
251    /// the no-op default; hosts must not override or depend on this — implement
252    /// [`FlushWriter`] and use [`WebUIHandler::render_streaming`] instead.
253    #[doc(hidden)]
254    fn stream_flush(&mut self) -> Result<()> {
255        Ok(())
256    }
257
258    #[doc(hidden)]
259    fn stream_begin_component(&mut self) -> Result<()> {
260        Err(HandlerError::Invariant(
261            "component opening buffering requires a streaming sink".to_string(),
262        ))
263    }
264
265    #[doc(hidden)]
266    fn stream_mark_component_root(&mut self) -> Result<()> {
267        Err(HandlerError::Invariant(
268            "component root buffering requires a streaming sink".to_string(),
269        ))
270    }
271
272    #[doc(hidden)]
273    fn stream_commit_component(
274        &mut self,
275        span_id: Option<u32>,
276        enclosing_span_id: Option<u32>,
277        deferred: bool,
278    ) -> Result<()> {
279        let _ = (span_id, enclosing_span_id, deferred);
280        Err(HandlerError::Invariant(
281            "component opening commit requires a streaming sink".to_string(),
282        ))
283    }
284}
285
286/// Serialize every application state key except a sorted base projection.
287///
288/// Streaming uses this only when a prior keyed projection is a proven subset
289/// of the current full-state projection. The browser merges the emitted delta
290/// over that exact prior range state.
291struct StateWithoutSelectedKeys<'a> {
292    value: &'a Value,
293    keys: KeyView<'a>,
294}
295
296impl Serialize for StateWithoutSelectedKeys<'_> {
297    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
298    where
299        S: serde::Serializer,
300    {
301        let Value::Object(map) = self.value else {
302            return serializer.serialize_map(Some(0))?.end();
303        };
304        let mut out = serializer.serialize_map(None)?;
305        for (key, value) in map {
306            if key != STATE_INJECT_KEY && !self.keys.contains(key) {
307                out.serialize_entry(key, value)?;
308            }
309        }
310        out.end()
311    }
312}
313
314/// A response writer that can hand buffered bytes to its transport immediately.
315///
316/// Progressive hydration requires this semantic flush at every committed
317/// boundary. Hosts that cannot provide it must use [`WebUIHandler::render`].
318pub trait FlushWriter: ResponseWriter {
319    /// Hand all currently buffered bytes to the underlying transport.
320    fn flush(&mut self) -> Result<()>;
321}
322
323/// Options controlling how the handler renders a protocol.
324///
325/// The handler performs server-side route matching: matched routes are rendered
326/// visible with content; non-matched routes are rendered hidden and empty.
327pub struct RenderOptions<'a> {
328    /// The fragment ID to start rendering from (e.g., `"index.html"`).
329    pub entry_id: &'a str,
330    /// The URL path to match routes against (e.g., `"/contacts/42"`).
331    pub request_path: &'a str,
332    /// Optional CSP nonce for inline `<script>` tags.
333    /// When set, all inline scripts include `nonce="VALUE"` and a
334    /// `<meta name="webui-nonce">` tag is emitted for the client router.
335    pub nonce: Option<&'a str>,
336    /// Optional HTML to emit immediately before the document's
337    /// `</head>` close. Used for per-request `<link rel="preload">`
338    /// hints, CSP `<meta>` tags beyond the built-in nonce, etc.
339    /// Inserted at the structural `head_end` boundary identified by
340    /// the parser — never matched against a byte pattern, so cannot
341    /// be tricked by `</head>` literals appearing in HTML comments,
342    /// `srcdoc` attributes, or inline scripts.
343    pub head_inject: Option<&'a str>,
344    /// Optional HTML to emit immediately before the document's
345    /// `</body>` close. Used for dev livereload `<script>`, analytics
346    /// snippets, OpenTelemetry trace IDs, etc.
347    /// Same structural-boundary guarantee as [`head_inject`](Self::head_inject).
348    pub body_inject: Option<&'a str>,
349}
350
351/// Reserved top-level state key carrying host-supplied boundary HTML.
352///
353/// The leading `$` marks this key as reserved host metadata rather than
354/// ordinary application state.
355///
356/// Recognized members, each an optional string:
357///
358/// | Member | Emitted at |
359/// | --- | --- |
360/// | `headEnd` | immediately before `</head>` |
361/// | `bodyStart` | immediately after `<body>` |
362/// | `bodyEnd` | immediately before `</body>` |
363///
364/// The key is always honored — it is part of the render state the host
365/// already supplies — and is stripped from the client hydration payload.
366pub const STATE_INJECT_KEY: &str = "$webui";
367
368/// Host-supplied boundary HTML resolved once per render from
369/// [`STATE_INJECT_KEY`].
370///
371/// Resolution happens when the render context is built, so each structural
372/// hook costs one `Option` check instead of a state map lookup. Every field
373/// borrows from the caller's state value — no clone, no allocation.
374#[derive(Clone, Copy, Default)]
375pub(crate) struct StateInject<'state> {
376    pub(crate) head_end: Option<&'state str>,
377    pub(crate) body_start: Option<&'state str>,
378    pub(crate) body_end: Option<&'state str>,
379}
380
381impl<'state> StateInject<'state> {
382    /// Resolve the reserved namespace from a render state value.
383    ///
384    /// Returns the empty set when the key is absent, or when any member is
385    /// missing, null, empty, or not a string. Malformed input is inert rather than an error: the reserved
386    /// key is an optional side channel, and a render must not fail because a
387    /// host wrote the wrong shape into it.
388    pub(crate) fn resolve(state: &'state Value) -> Self {
389        let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
390            return Self::default();
391        };
392        let field = |name: &str| {
393            map.get(name)
394                .and_then(Value::as_str)
395                .filter(|html| !html.is_empty())
396        };
397        Self {
398            head_end: field("headEnd"),
399            body_start: field("bodyStart"),
400            body_end: field("bodyEnd"),
401        }
402    }
403}
404
405impl<'a> RenderOptions<'a> {
406    /// Create render options for the given entry fragment and request path.
407    #[must_use]
408    pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
409        Self {
410            entry_id,
411            request_path,
412            nonce: None,
413            head_inject: None,
414            body_inject: None,
415        }
416    }
417
418    /// Set the CSP nonce for inline scripts. Pass an empty string to
419    /// disable (`None` semantics) — empty `<meta name="webui-nonce"
420    /// content="">` would be browser-ignored noise.
421    #[must_use]
422    pub fn with_nonce(mut self, nonce: &'a str) -> Self {
423        self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
424        self
425    }
426
427    /// Set HTML to emit immediately before `</head>`.
428    /// Pass an empty string to disable (`None` semantics).
429    ///
430    /// # Safety (XSS warning)
431    ///
432    /// The provided HTML is written verbatim — **no HTML escaping is
433    /// performed**. Callers MUST ensure the content is fully trusted
434    /// (typically a `&'static str` or build-time-derived bytes such as
435    /// dev livereload script, image preload `<link>` tags, or A/B test
436    /// markers). Passing user-controlled or attacker-influenced content
437    /// here is a direct cross-site scripting vulnerability. If your
438    /// caller path may include untrusted data, escape with the host's
439    /// HTML escaper (e.g. [`webui_handler::encode_safe`](crate::encode_safe))
440    /// **before** calling this builder.
441    #[must_use]
442    pub fn with_head_inject(mut self, html: &'a str) -> Self {
443        self.head_inject = if html.is_empty() { None } else { Some(html) };
444        self
445    }
446
447    /// Set HTML to emit immediately before `</body>`.
448    /// Pass an empty string to disable (`None` semantics).
449    ///
450    /// # Safety (XSS warning)
451    ///
452    /// Same contract as [`with_head_inject`](Self::with_head_inject):
453    /// the HTML is written verbatim with **no escaping**, so callers
454    /// MUST ensure the content is fully trusted. Untrusted content is
455    /// a direct XSS vector.
456    #[must_use]
457    pub fn with_body_inject(mut self, html: &'a str) -> Self {
458        self.body_inject = if html.is_empty() { None } else { Some(html) };
459        self
460    }
461}
462
463/// The main WebUI handler that processes protocols and renders them.
464///
465/// The handler is stateless: plugin instances are created per-render from
466/// the stored factory function, allowing concurrent renders with `&self`.
467pub struct WebUIHandler {
468    plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
469}
470
471#[derive(Clone, Copy, Eq, PartialEq)]
472enum StyleClosureInstall {
473    Static,
474    Routed,
475}
476
477pub(crate) struct ShadowStyleRoot {
478    component_index: u32,
479    static_closure_emitted: bool,
480    routed_resources: Vec<u32>,
481}
482
483struct LoopBinding<'protocol, 'state> {
484    name: &'protocol str,
485    value: &'state Value,
486}
487
488#[derive(Clone, Copy)]
489struct VisibleLoopScope {
490    start: usize,
491    end: usize,
492}
493
494impl VisibleLoopScope {
495    const EMPTY: Self = Self { start: 0, end: 0 };
496}
497
498/// Map backing a render scope: component props or loop/local variables.
499pub(crate) type ScopeMap = HashMap<String, Value, BuildHasherDefault<ScopeKeyHasher>>;
500
501/// Multiply-rotate hasher (the FxHash scheme used by rustc) for [`ScopeMap`].
502///
503/// Scope keys are prop and variable names from the compiled protocol, never
504/// request data, so SipHash's flooding resistance buys nothing here. Large
505/// pages insert and probe these maps tens of thousands of times per render,
506/// where SipHash was a measurable share of render time.
507#[derive(Clone, Copy, Default)]
508pub(crate) struct ScopeKeyHasher(u64);
509
510impl ScopeKeyHasher {
511    const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
512
513    #[inline]
514    fn add_word(&mut self, word: u64) {
515        self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(Self::SEED);
516    }
517}
518
519impl Hasher for ScopeKeyHasher {
520    #[inline]
521    fn write(&mut self, bytes: &[u8]) {
522        let (words, tail) = bytes.as_chunks::<8>();
523        for word in words {
524            self.add_word(u64::from_le_bytes(*word));
525        }
526        if !tail.is_empty() {
527            let mut word = [0u8; 8];
528            word[..tail.len()].copy_from_slice(tail);
529            self.add_word(u64::from_le_bytes(word));
530        }
531    }
532
533    #[inline]
534    fn write_u8(&mut self, byte: u8) {
535        self.add_word(u64::from(byte));
536    }
537
538    #[inline]
539    fn finish(&self) -> u64 {
540        self.0
541    }
542}
543
544#[derive(Clone, Copy)]
545struct LocalValueSources<'ctx, 'protocol, 'state> {
546    owned: &'ctx ScopeMap,
547    borrowed: &'ctx BorrowedScope<'protocol, 'state>,
548}
549
550/// Route level an `<outlet />` matches against.
551pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
552
553#[derive(Default)]
554struct BorrowedScope<'protocol, 'state> {
555    inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
556    inline_len: usize,
557    overflow: Vec<(&'protocol str, &'state Value)>,
558}
559
560impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
561    fn get(&self, name: &str) -> Option<&'state Value> {
562        if self.inline_len == 0 {
563            return None;
564        }
565        if let Some((entry_name, value)) = self.inline[0].as_ref() {
566            if *entry_name == name {
567                return Some(*value);
568            }
569        }
570        for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
571            if *entry_name == name {
572                return Some(*value);
573            }
574        }
575        self.overflow
576            .iter()
577            .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
578    }
579
580    fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
581        for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
582            if *entry_name == name {
583                return Some(std::mem::replace(current, value));
584            }
585        }
586        for (entry_name, current) in &mut self.overflow {
587            if *entry_name == name {
588                return Some(std::mem::replace(current, value));
589            }
590        }
591        if self.inline_len < INLINE_SCOPE_SLOTS {
592            self.inline[self.inline_len] = Some((name, value));
593            self.inline_len += 1;
594        } else {
595            self.overflow.push((name, value));
596        }
597        None
598    }
599
600    fn remove(&mut self, name: &str) -> Option<&'state Value> {
601        if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
602            entry
603                .as_ref()
604                .is_some_and(|(entry_name, _)| *entry_name == name)
605        }) {
606            let removed = self.inline[index].take().map(|(_, value)| value);
607            if let Some(entry) = self.overflow.pop() {
608                self.inline[index] = Some(entry);
609            } else {
610                self.inline_len -= 1;
611                self.inline[index] = self.inline[self.inline_len].take();
612            }
613            return removed;
614        }
615        let index = self
616            .overflow
617            .iter()
618            .position(|(entry_name, _)| *entry_name == name)?;
619        Some(self.overflow.swap_remove(index).1)
620    }
621
622    fn clear(&mut self) {
623        for entry in &mut self.inline[..self.inline_len] {
624            *entry = None;
625        }
626        self.inline_len = 0;
627        self.overflow.clear();
628    }
629
630    fn clone_into_owned(&self, target: &mut ScopeMap) {
631        for (name, value) in self.inline[..self.inline_len].iter().flatten() {
632            target.insert((*name).to_owned(), (*value).clone());
633        }
634        for (name, value) in &self.overflow {
635            target.insert((*name).to_owned(), (*value).clone());
636        }
637    }
638}
639
640/// A fragment list paired with the render metadata prepared for it when the
641/// runtime [`Protocol`] was loaded.
642///
643/// `fragments` still points at the protocol's own storage, so passing this by
644/// value never copies or clones a fragment graph.
645#[derive(Clone, Copy)]
646pub(crate) struct RenderFragmentList<'protocol> {
647    fragments: &'protocol [WebUIFragment],
648    metadata: &'protocol [RenderFragmentMetadata],
649    attr_names: &'protocol str,
650    contains_boundary: bool,
651    /// True when this list contains at least one `<webui-route>` fragment.
652    /// Renders skip the sibling route pre-scan entirely when it is false.
653    has_routes: bool,
654}
655
656impl<'protocol> RenderFragmentList<'protocol> {
657    /// Render slot the fragment at `index` descends into, if any.
658    fn target(self, index: usize) -> Option<usize> {
659        let target = self.metadata.get(index)?.target;
660        (target != NO_RENDER_SLOT).then_some(target as usize)
661    }
662
663    /// Canonical camelCase component prop name prepared for an attribute fragment.
664    fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
665        let prepared = self.metadata.get(index)?;
666        if prepared.attr_start == NO_ATTR_NAME {
667            return None;
668        }
669        let start = prepared.attr_start as usize;
670        self.attr_names
671            .get(start..start + prepared.attr_len as usize)
672    }
673}
674
675/// Sentinel for "this fragment does not descend into another fragment list".
676const NO_RENDER_SLOT: u32 = u32::MAX;
677/// Sentinel for "this fragment is not an attribute fragment".
678const NO_ATTR_NAME: u32 = u32::MAX;
679
680/// Per-fragment values hoisted out of the render loop at protocol load time.
681///
682/// Deliberately a flat 12-byte `Copy` record with no owned allocations: a large
683/// protocol keeps one contiguous arena instead of one heap block per fragment.
684#[derive(Clone, Copy)]
685struct RenderFragmentMetadata {
686    /// Numeric slot of the fragment list this fragment renders into, avoiding a
687    /// string hash lookup per component, loop, condition, and template attribute.
688    /// [`NO_RENDER_SLOT`] when the fragment renders inline.
689    target: u32,
690    /// Offset into the index's shared attribute-name arena, or [`NO_ATTR_NAME`].
691    attr_start: u32,
692    attr_len: u32,
693}
694
695/// Build-time render plan for every fragment list in a protocol.
696///
697/// Built once when a [`Protocol`] is created and shared immutably by every
698/// render. Fragment IDs are the same `Arc<str>` values the protocol already
699/// interns, metadata lives in one flat arena, and the fragment graphs
700/// themselves are never duplicated.
701pub(crate) struct RenderFragmentIndex {
702    ids: Box<[Arc<str>]>,
703    metadata: Box<[RenderFragmentMetadata]>,
704    /// Prefix offsets into `metadata`; length is `ids.len() + 1`.
705    ranges: Box<[u32]>,
706    /// Every prepared component prop name concatenated into one allocation.
707    attr_names: Box<str>,
708    /// One bit per fragment list: does it contain a route fragment?
709    route_presence: Box<[u64]>,
710}
711
712/// A [`RenderFragmentIndex`] bound to the protocol document for one render.
713///
714/// Fragment lists are borrowed lazily and memoized in an inline slot cache, so
715/// a large protocol is never eagerly walked to serve a small render.
716pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
717    cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
718    /// Memo slots for protocols with more fragment lists than the inline cache
719    /// holds, allocated on first use so a small render never pays for it and a
720    /// large one never falls back to hashing the fragment ID on every descent.
721    spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
722    index: &'protocol RenderFragmentIndex,
723    protocol: &'protocol WebUIProtocol,
724}
725
726impl RenderFragmentIndex {
727    /// Prepare the render plan for `protocol`.
728    ///
729    /// `ids` must be the protocol's sorted fragment IDs and `slots` its ID→slot
730    /// map, so render slots and the runtime [`Protocol`]'s continuation slots are
731    /// the same numbering. Reusing the map keeps preparation to one hash lookup
732    /// per reference instead of a string binary search.
733    pub(crate) fn new(
734        protocol: &WebUIProtocol,
735        ids: &[Arc<str>],
736        slots: &HashMap<Arc<str>, u32>,
737    ) -> Self {
738        let total_fragments = protocol
739            .fragments
740            .values()
741            .map(|list| list.fragments.len())
742            .sum();
743        let mut metadata = Vec::with_capacity(total_fragments);
744        let mut ranges = Vec::with_capacity(ids.len() + 1);
745        // Most attribute names are short; reserving up front keeps the shared
746        // arena from repeatedly reallocating and copying as it is filled.
747        let mut attr_names = String::with_capacity(total_fragments * 8);
748        let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
749
750        for (slot, id) in ids.iter().enumerate() {
751            // Record counts are bounded by the compiled graph, well inside u32.
752            #[allow(clippy::cast_possible_truncation)]
753            ranges.push(metadata.len() as u32);
754            let Some(list) = protocol.fragments.get(id.as_ref()) else {
755                continue;
756            };
757            let mut has_routes = false;
758            for fragment in &list.fragments {
759                let inner = fragment.fragment.as_ref();
760                if matches!(inner, Some(Fragment::Route(_))) {
761                    has_routes = true;
762                }
763                let target = fragment_target_id(fragment)
764                    .and_then(|target| slots.get(target).copied())
765                    .unwrap_or(NO_RENDER_SLOT);
766                let (attr_start, attr_len) = match inner {
767                    // `attr_skip` attributes are never collected into component
768                    // props, so preparing a name for them would be pure load-time
769                    // cost for something no render ever reads.
770                    Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
771                        #[allow(clippy::cast_possible_truncation)]
772                        let start = attr_names.len() as u32;
773                        let before = attr_names.len();
774                        push_component_attr_name(&mut attr_names, component_attr_source(attribute));
775                        #[allow(clippy::cast_possible_truncation)]
776                        let len = (attr_names.len() - before) as u32;
777                        (start, len)
778                    }
779                    _ => (NO_ATTR_NAME, 0),
780                };
781                metadata.push(RenderFragmentMetadata {
782                    target,
783                    attr_start,
784                    attr_len,
785                });
786            }
787            if has_routes {
788                route_presence[slot / 64] |= 1u64 << (slot % 64);
789            }
790        }
791        #[allow(clippy::cast_possible_truncation)]
792        ranges.push(metadata.len() as u32);
793
794        Self {
795            ids: ids.to_vec().into_boxed_slice(),
796            metadata: metadata.into_boxed_slice(),
797            ranges: ranges.into_boxed_slice(),
798            attr_names: attr_names.into_boxed_str(),
799            route_presence: route_presence.into_boxed_slice(),
800        }
801    }
802
803    /// Resolve a fragment ID to its stable numeric render slot.
804    pub(crate) fn index(&self, id: &str) -> Option<usize> {
805        self.ids
806            .binary_search_by(|candidate| candidate.as_ref().cmp(id))
807            .ok()
808    }
809
810    pub(crate) fn resolve<'protocol>(
811        &'protocol self,
812        protocol: &'protocol WebUIProtocol,
813    ) -> ResolvedRenderFragmentIndex<'protocol> {
814        ResolvedRenderFragmentIndex {
815            cache: std::array::from_fn(|_| Cell::new(None)),
816            spilled: OnceCell::new(),
817            index: self,
818            protocol,
819        }
820    }
821}
822
823impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
824    pub(crate) fn index(&self, id: &str) -> Option<usize> {
825        self.index.index(id)
826    }
827
828    /// Memo slot for `index`, spilling past the inline cache on demand.
829    fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
830        if index < INLINE_RENDER_FRAGMENT_LISTS {
831            return self.cache.get(index);
832        }
833        let spilled = self.spilled.get_or_init(|| {
834            let overflow = self
835                .index
836                .ids
837                .len()
838                .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
839            (0..overflow).map(|_| Cell::new(None)).collect()
840        });
841        spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
842    }
843
844    pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
845        let id = self.index.ids.get(index)?;
846        let fragment_list = match self.slot(index) {
847            Some(slot) => match slot.get() {
848                Some(cached) => cached,
849                None => {
850                    let resolved = self.protocol.fragments.get(id.as_ref())?;
851                    slot.set(Some(resolved));
852                    resolved
853                }
854            },
855            None => self.protocol.fragments.get(id.as_ref())?,
856        };
857        let start = *self.index.ranges.get(index)? as usize;
858        let end = *self.index.ranges.get(index + 1)? as usize;
859        let metadata = self.index.metadata.get(start..end)?;
860        let has_routes = self
861            .index
862            .route_presence
863            .get(index / 64)
864            .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
865        Some(RenderFragmentList {
866            fragments: &fragment_list.fragments,
867            metadata,
868            attr_names: &self.index.attr_names,
869            contains_boundary: fragment_list.contains_boundary,
870            has_routes,
871        })
872    }
873
874    #[cfg(test)]
875    pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
876        self.index(id).and_then(|index| self.list(index))
877    }
878}
879
880/// Name that a component prop is derived from for an attribute fragment.
881///
882/// The render path strips a leading `:` twice on the complex-value branch and
883/// once on every other branch. Every field that selects the branch is a
884/// build-time constant, so the choice is made here and the prepared name stays
885/// byte-identical to the per-render conversion it replaces.
886fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
887    let takes_complex_branch = attribute.condition_tree.is_none()
888        && attribute.template.is_empty()
889        && !attribute.value.is_empty()
890        && !attribute.raw_value
891        && attribute.complex;
892    if takes_complex_branch {
893        attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
894    } else {
895        &attribute.name
896    }
897}
898
899/// Route level one step below `children[index]`.
900///
901/// A borrowed level yields a borrowed sublevel, so descending through a route
902/// tree during a render never copies a `WebUiFragmentRoute`. A level a
903/// streaming continuation already materialized transfers its child level.
904fn descend_into<'protocol>(
905    children: &mut RouteChildren<'protocol>,
906    index: usize,
907) -> RouteChildren<'protocol> {
908    match children {
909        Cow::Borrowed(routes) => match routes.get(index) {
910            Some(route) => Cow::Borrowed(&route.children),
911            None => Cow::Borrowed(&[]),
912        },
913        Cow::Owned(routes) => match routes.get_mut(index) {
914            Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
915            None => Cow::Borrowed(&[]),
916        },
917    }
918}
919
920/// Fragment ID a fragment descends into, or `None` when it renders inline.
921fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
922    match fragment.fragment.as_ref()? {
923        Fragment::Component(component) => Some(&component.fragment_id),
924        Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
925        Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
926        Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
927            Some(&attribute.template)
928        }
929        _ => None,
930    }
931}
932
933/// Context object for processing WebUI fragments
934pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
935    pub(crate) protocol: &'protocol WebUIProtocol,
936    /// Render plan prepared once when the runtime [`Protocol`] was loaded.
937    /// Fragment lists resolve through numeric slots instead of per-fragment
938    /// string hashing.
939    pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
940    /// Build-constant component asset style metadata serialized once when the
941    /// runtime [`Protocol`] is created.
942    pub(crate) component_asset_style_manifest: &'protocol str,
943    /// Deduplicated document-scoped component asset styles for Light DOM.
944    pub(crate) component_asset_style_links: &'protocol str,
945    pub(crate) state: &'state Value,
946    pub(crate) writer: &'output mut dyn ResponseWriter,
947    pub(crate) local_vars: ScopeMap,
948    /// Component-local values that still point into immutable request state.
949    local_borrowed_vars: BorrowedScope<'protocol, 'state>,
950    /// Borrowed loop bindings, in lexical order.
951    loop_vars: Vec<LoopBinding<'protocol, 'state>>,
952    /// Range of `loop_vars` visible to the current fragment scope. Component
953    /// bodies hide outer loop monikers while still allowing their own loops.
954    visible_loop_scope: VisibleLoopScope,
955    /// Accumulates component attribute values between attrStart and the component fragment.
956    pub(crate) component_attrs: ScopeMap,
957    /// State-backed component attributes accumulated without cloning.
958    component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
959    /// True only while parser-produced component opening-tag attributes are
960    /// being accumulated. Native element attributes render directly and never
961    /// enter `component_attrs`.
962    collecting_component_attrs: bool,
963    /// URL path for server-side route matching. Borrowed from
964    /// `RenderOptions<'a>::request_path` — zero-copy.
965    pub(crate) request_path: &'protocol str,
966    /// Base path for resolving relative route paths (`./`).
967    /// Updated as the handler descends into nested matched routes.
968    /// `Cow` keeps the initial `"/"` literal zero-copy; nested-route
969    /// descent owns the recomputed path.
970    pub(crate) route_base: Cow<'protocol, str>,
971    /// Component names visited during rendering (for selective f-template emission
972    /// and CSS module dedup — only the first render of each component emits
973    /// its `<script type="importmap">` data-URI tag).
974    pub(crate) rendered_components: HashSet<String>,
975    /// Per-render plugin instance created from the handler's factory.
976    pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
977    /// Current position in the route tree for outlet-based rendering.
978    /// Contains the children of the currently matched route fragment.
979    ///
980    /// A matched route's `children` is a recursive prost subtree, so the
981    /// borrowed variant avoids deep-cloning the whole remaining route tree on
982    /// every matched route of every request. Only a suspended streaming
983    /// continuation, which outlives the borrow, materializes the owned variant.
984    pub(crate) route_children: RouteChildren<'protocol>,
985    /// Entry fragment ID — used to compute the initial inventory at head_end.
986    /// Borrowed from `RenderOptions<'a>::entry_id` — zero-copy.
987    pub(crate) entry_id: &'protocol str,
988    /// CSP nonce for inline `<script>` tags (None = no nonce attribute).
989    /// Borrowed from `RenderOptions<'a>::nonce` — zero-copy.
990    pub(crate) nonce: Option<&'protocol str>,
991    /// Component-name → bit-position map built once when the runtime
992    /// [`Protocol`] is created and shared by every render.
993    pub(crate) component_index: &'protocol HashMap<String, u32>,
994    /// Style-resource ID → request-local dedup bit built with [`Protocol`].
995    pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
996    /// Inline style resources whose CSS contains a case-insensitive `</style`.
997    ///
998    /// Computed once when [`Protocol`] loads so repeated component instances can
999    /// write ordinary CSS without rescanning identical bytes on every request.
1000    pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
1001    /// Component tag → covering bundle chunk, built once per render.
1002    ///
1003    /// Empty (and allocation-free) for unbundled builds. Every style delivery
1004    /// path resolves closure members through this so they all agree on what a
1005    /// chunk already ships.
1006    pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
1007    /// CSS strategy declared by the compiled protocol.
1008    pub(crate) css_strategy: webui_protocol::CssStrategy,
1009    /// HTML emitted at the structural `head_end` boundary (before
1010    /// `</head>`), after the built-in nonce/CSS emissions.
1011    /// Zero-copy borrow of the caller's `RenderOptions<'a>::head_inject`
1012    /// (no per-render clone — saves an allocation when the host passes
1013    /// a `&'static str` such as a dev livereload script).
1014    pub(crate) head_inject: Option<&'protocol str>,
1015    /// HTML emitted at the structural `body_end` boundary (before
1016    /// `</body>`), after the built-in template metadata emissions.
1017    /// Same zero-copy borrow as [`head_inject`](Self::head_inject).
1018    pub(crate) body_inject: Option<&'protocol str>,
1019    /// Host HTML resolved once per render from the reserved
1020    /// [`STATE_INJECT_KEY`] state namespace. Emitted after the built-in
1021    /// emissions at each structural boundary, and after the corresponding
1022    /// `RenderOptions` inject at `head_end` and `body_end`.
1023    pub(crate) state_inject: StateInject<'state>,
1024    /// Tracks whether the `head_end` hook has already fired in this
1025    /// render. Defends against malformed protocols that emit the
1026    /// signal more than once (e.g., a template with multiple `<head>`
1027    /// tags) — without this, host-supplied `head_inject` HTML, CSS
1028    /// resources, and the CSP `<meta>` nonce would be
1029    /// duplicated, which can be a CSP-bypass / cache-bloat vector.
1030    pub(crate) head_end_emitted: bool,
1031    /// Tracks whether the `body_start` hook has already fired in this
1032    /// render. Defends against malformed protocols emitting the signal
1033    /// twice — without this, state-supplied `bodyStart` HTML would be
1034    /// duplicated.
1035    pub(crate) body_start_emitted: bool,
1036    /// Tracks whether compiler-owned component asset styles were emitted at
1037    /// either the head or body fallback boundary.
1038    pub(crate) component_asset_styles_emitted: bool,
1039    /// Tracks whether the `body_end` hook has already fired in this
1040    /// render. Defends against malformed protocols emitting the
1041    /// signal twice — without this, hydration `<script>` blocks and
1042    /// host-supplied `body_inject` would be duplicated.
1043    pub(crate) body_end_emitted: bool,
1044    /// Immutable authored route patterns compiled when [`Protocol`] is loaded.
1045    pub(crate) route_index: &'protocol CompiledRouteIndex,
1046    /// Counter for `data-ri` attributes on matched route elements.
1047    /// Incremented each time a matched route is rendered, allowing O(1) element
1048    /// binding on the client side instead of DOM-walking.
1049    pub(crate) route_chain_index: usize,
1050    /// Matched route metadata computed at most once when head styles or body
1051    /// bootstrap first need it, then shared by streaming checkpoints.
1052    pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
1053    /// Whether each matched route closure installs into the Document CSS tree.
1054    ///
1055    /// Kept parallel to `route_chain`; the route graph computes both in one walk.
1056    pub(crate) route_document_style_targets: Vec<bool>,
1057    /// Request-reachable components in deterministic first-discovery order.
1058    ///
1059    /// Resolved once at `head_end` for Shadow Link preloads, then consumed at
1060    /// `body_end` for hydration metadata so the fragment graph is not walked
1061    /// twice per request.
1062    pub(crate) reachable_components: Option<Vec<String>>,
1063    /// Present only for the opt-in progressive streaming render path.
1064    pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
1065    /// Reusable serialization scratch buffer, owned by the render context.
1066    /// The bootstrap/field/template helpers borrow it so each render reuses one
1067    /// buffer across JSON fields, native template attributes, and streaming
1068    /// checkpoints instead of allocating per value. It grows lazily
1069    /// and is dropped with the context at request end — no per-thread
1070    /// high-water buffer is retained between requests.
1071    pub(crate) json_scratch: Vec<u8>,
1072    /// Small request-local pool of cleared scope maps reused across sibling
1073    /// component roots. `process_component` recycles each finished component's
1074    /// local/attr map here instead of dropping it, so a sibling reuses the
1075    /// bucket capacity rather than reallocating a fresh `HashMap`. Bounded
1076    /// ([`SCOPE_POOL_CAP`]) and dropped with the context at request end.
1077    pub(crate) scope_pool: Vec<ScopeMap>,
1078    /// Resources delivered into the Document CSS tree. The empty set does not
1079    /// allocate, and streaming retains it across checkpoints.
1080    pub(crate) document_style_resources: HashSet<String>,
1081    /// Active Shadow roots and their route-activated resource indexes. The
1082    /// route vector allocates only when a matched Light route contributes CSS.
1083    pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1084    /// Capacity-preserving pool for borrowed component scope maps.
1085    borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1086}
1087
1088/// Compiler-owned signal namespace. The leading `}}}` cannot be produced by
1089/// authored double- or triple-brace expressions because it closes the binding.
1090pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1091
1092pub(crate) fn structural_signal_value(
1093    signal: &webui_protocol::WebUIFragmentSignal,
1094) -> Option<&str> {
1095    if !signal.raw {
1096        return None;
1097    }
1098    signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1099}
1100
1101pub(crate) fn write_interaction_marker(
1102    tag: &str,
1103    context: &mut WebUIProcessContext<'_, '_, '_>,
1104) -> Result<()> {
1105    if matches!(
1106        context
1107            .protocol
1108            .components
1109            .get(tag)
1110            .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1111        Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1112    ) {
1113        context.writer.write(" data-webui-interaction")?;
1114    }
1115    Ok(())
1116}
1117
1118/// Find the end of a leading doctype without treating quoted `>` bytes in
1119/// legacy PUBLIC or SYSTEM identifiers as the declaration close.
1120fn doctype_prefix_end(raw: &str) -> Option<usize> {
1121    const PREFIX: &[u8] = b"<!doctype";
1122
1123    let bytes = raw.as_bytes();
1124    let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1125    while bytes
1126        .get(cursor)
1127        .is_some_and(|byte| byte.is_ascii_whitespace())
1128    {
1129        cursor += 1;
1130    }
1131
1132    let prefix_end = cursor.checked_add(PREFIX.len())?;
1133    if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1134        return None;
1135    }
1136    let separator = *bytes.get(prefix_end)?;
1137    if separator != b'>' && !separator.is_ascii_whitespace() {
1138        return None;
1139    }
1140
1141    let mut quote = None;
1142    for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1143        match quote {
1144            Some(active) if byte == active => quote = None,
1145            Some(_) => {}
1146            None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1147            None if byte == b'>' => return Some(prefix_end + offset + 1),
1148            None => {}
1149        }
1150    }
1151    None
1152}
1153
1154/// Maximum scope maps retained in the request-local pool. Small: sibling
1155/// component roots rarely nest deeply, so the cap keeps retained capacity bounded.
1156const SCOPE_POOL_CAP: usize = 8;
1157/// Borrowed component scopes typically contain only a handful of attributes.
1158const INLINE_SCOPE_SLOTS: usize = 4;
1159/// Fragment lists memoized inline per render. Sized to cover the distinct lists
1160/// a typical app touches in one render while keeping the per-render zeroing cost
1161/// negligible; larger protocols fall back to the protocol map for the overflow
1162/// slots instead of eagerly materializing every list.
1163/// Inline memo slots for fragment lists.
1164///
1165/// Deliberately small: the array is zeroed on every render, so a shallow render
1166/// touching two fragments should not pay to clear slots it will never read.
1167/// Deeper renders spill into a lazily allocated slice, which keeps them fully
1168/// memoized without charging that cost to the common shallow case.
1169const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1170/// Style-closure units deduplicated inline before falling back to hashing.
1171const INLINE_STYLE_UNITS: usize = 8;
1172const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1173
1174/// Delivery units already emitted by one style-closure walk.
1175///
1176/// Every Shadow root walks its closure on every render and most closures hold
1177/// only a few units, so a linear scan over an inline array replaces a hash set
1178/// allocated per walk. Larger closures continue in a hash set.
1179struct EmittedStyleUnits<'a> {
1180    inline: [&'a str; INLINE_STYLE_UNITS],
1181    inline_len: usize,
1182    overflow: HashSet<&'a str>,
1183}
1184
1185impl<'a> EmittedStyleUnits<'a> {
1186    fn new() -> Self {
1187        Self {
1188            inline: [""; INLINE_STYLE_UNITS],
1189            inline_len: 0,
1190            overflow: HashSet::new(),
1191        }
1192    }
1193
1194    /// Record `name`, returning `false` when it was already emitted.
1195    fn insert(&mut self, name: &'a str) -> bool {
1196        if self.inline[..self.inline_len].contains(&name) {
1197            return false;
1198        }
1199        if self.inline_len < INLINE_STYLE_UNITS {
1200            self.inline[self.inline_len] = name;
1201            self.inline_len += 1;
1202            return true;
1203        }
1204        self.overflow.insert(name)
1205    }
1206}
1207
1208struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1209
1210impl Serialize for ComponentAssetStyleManifest<'_> {
1211    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1212    where
1213        S: serde::Serializer,
1214    {
1215        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1216        for entry in self.0 {
1217            map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1218        }
1219        map.end()
1220    }
1221}
1222
1223fn serialize_component_asset_style_manifest(
1224    preloads: &[ComponentAssetStylePreload],
1225) -> std::result::Result<String, serde_json::Error> {
1226    if preloads.is_empty() {
1227        return Ok(String::new());
1228    }
1229    let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1230    if json.contains("</") {
1231        Ok(json.replace("</", "<\\/"))
1232    } else {
1233        Ok(json)
1234    }
1235}
1236
1237fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1238    let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1239    let mut seen = HashSet::with_capacity(href_count);
1240    let mut links = String::with_capacity(href_count.saturating_mul(48));
1241    for entry in preloads {
1242        for href in &entry.style_hrefs {
1243            if !seen.insert(href.as_str()) {
1244                continue;
1245            }
1246            links.push_str("<link rel=\"stylesheet\" href=\"");
1247            push_escaped_html_attribute(&mut links, href);
1248            links.push_str("\">");
1249        }
1250    }
1251    links
1252}
1253
1254fn push_escaped_html_attribute(output: &mut String, value: &str) {
1255    let mut start = 0;
1256    for (index, ch) in value.char_indices() {
1257        let escaped = match ch {
1258            '&' => Some("&amp;"),
1259            '"' => Some("&quot;"),
1260            '<' => Some("&lt;"),
1261            '>' => Some("&gt;"),
1262            _ => None,
1263        };
1264        let Some(entity) = escaped else {
1265            continue;
1266        };
1267        if start < index {
1268            output.push_str(&value[start..index]);
1269        }
1270        output.push_str(entity);
1271        start = index + ch.len_utf8();
1272    }
1273    if start < value.len() {
1274        output.push_str(&value[start..]);
1275    }
1276}
1277
1278/// Drop an owned component prop that a state-backed value now shadows.
1279///
1280/// `HashMap::remove` hashes its key even when the map is empty, which is the
1281/// case for components whose props are all state-backed.
1282fn remove_owned_component_attr(attrs: &mut ScopeMap, name: &str) {
1283    if !attrs.is_empty() {
1284        attrs.remove(name);
1285    }
1286}
1287
1288/// Take a cleared scope map from the pool, or a fresh empty one when the pool is
1289/// empty. A fresh `HashMap` does not allocate until its first insert.
1290fn take_scope_map(pool: &mut Vec<ScopeMap>) -> ScopeMap {
1291    pool.pop().unwrap_or_default()
1292}
1293
1294/// Return a spent scope map to the pool, clearing it but retaining its bucket
1295/// capacity for a sibling root to reuse. Drops the map once the pool is full so
1296/// retained memory stays bounded.
1297fn recycle_scope_map(pool: &mut Vec<ScopeMap>, mut map: ScopeMap) {
1298    if pool.len() < SCOPE_POOL_CAP {
1299        map.clear();
1300        pool.push(map);
1301    }
1302}
1303
1304fn take_borrowed_scope<'protocol, 'state>(
1305    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1306) -> BorrowedScope<'protocol, 'state> {
1307    pool.pop().unwrap_or_default()
1308}
1309
1310fn recycle_borrowed_scope<'protocol, 'state>(
1311    pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1312    mut scope: BorrowedScope<'protocol, 'state>,
1313) {
1314    if pool.len() < SCOPE_POOL_CAP {
1315        scope.clear();
1316        pool.push(scope);
1317    }
1318}
1319
1320pub(crate) enum WebUiBootstrapState<'a> {
1321    Complete {
1322        value: &'a Value,
1323        selection: StateSelection<'a>,
1324    },
1325    Reference {
1326        record_sequence: usize,
1327        value: &'a Value,
1328        delta: Option<StateSelection<'a>>,
1329    },
1330}
1331
1332pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1333    pub(crate) declaration_id: Option<u32>,
1334    pub(crate) enclosing_span_instance_id: Option<u32>,
1335    pub(crate) state: WebUiBootstrapState<'a>,
1336    pub(crate) chain: &'a [Value],
1337    pub(crate) inventory: &'a str,
1338    pub(crate) nonce: Option<&'a str>,
1339    pub(crate) css_hrefs: &'a [&'a str],
1340    pub(crate) style_specs: &'a [&'a str],
1341    pub(crate) component_styles: &'a ComponentStyles,
1342    pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1343}
1344
1345/// Get the component attribute name, stripping `:` prefix and converting to camelCase.
1346///
1347/// Uses `webui_protocol::attrs::attribute_to_camel` which handles irregular
1348/// attributes (multi-word ARIA and global HTML attributes like `readonly`,
1349/// `tabindex`) via the shared lookup table.
1350///
1351/// Renders read prepared names out of [`RenderFragmentIndex`] instead of calling
1352/// this, so it remains only as the single-name form of
1353/// [`push_component_attr_name`] for tests.
1354#[cfg(test)]
1355fn component_attr_name(name: &str) -> String {
1356    let mut buffer = String::new();
1357    push_component_attr_name(&mut buffer, name);
1358    buffer
1359}
1360
1361/// Append the canonical camelCase component prop name for `name` to `buffer`.
1362///
1363/// Strips a leading `:` and converts via `webui_protocol::attrs::attribute_to_camel`,
1364/// which handles irregular attributes (multi-word ARIA and global HTML attributes
1365/// like `readonly`, `tabindex`) through the shared lookup table.
1366///
1367/// Used while preparing the render fragment index so a protocol with thousands
1368/// of attribute fragments grows one arena instead of allocating per name.
1369fn push_component_attr_name(buffer: &mut String, name: &str) {
1370    let stripped = name.strip_prefix(':').unwrap_or(name);
1371    // Same conversion as `webui_protocol::attrs::attribute_to_camel`, written
1372    // straight into the shared arena. That function returns an owned `String`,
1373    // so calling it here would allocate and drop one buffer per attribute while
1374    // preparing a protocol.
1375    if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1376        buffer.push_str(property);
1377        return;
1378    }
1379    buffer.reserve(stripped.len());
1380    let mut capitalize_next = false;
1381    for ch in stripped.chars() {
1382        if ch == '-' {
1383            capitalize_next = true;
1384        } else if capitalize_next {
1385            buffer.extend(ch.to_uppercase());
1386            capitalize_next = false;
1387        } else {
1388            buffer.push(ch);
1389        }
1390    }
1391}
1392
1393#[cold]
1394#[inline(never)]
1395fn missing_component_attr_name_error() -> HandlerError {
1396    HandlerError::Invariant("prepared component attribute name is missing".to_string())
1397}
1398
1399/// Fixed-capacity [`fmt::Write`] sink used to render JSON scalars on the stack.
1400///
1401/// `serde_json::Number` is backed by `i64`, `u64`, or `f64` (the crate is built
1402/// without `arbitrary_precision`), so its longest rendering is the 24-byte
1403/// `ryu` form of an extreme `f64`. The buffer is oversized well past that, and
1404/// an overflow is reported rather than truncated so callers fall back to the
1405/// allocating path instead of emitting a malformed value.
1406struct ScalarBuffer {
1407    buf: [u8; 48],
1408    len: usize,
1409}
1410
1411impl ScalarBuffer {
1412    #[inline]
1413    const fn new() -> Self {
1414        Self {
1415            buf: [0; 48],
1416            len: 0,
1417        }
1418    }
1419
1420    #[inline]
1421    fn as_str(&self) -> Option<&str> {
1422        std::str::from_utf8(&self.buf[..self.len]).ok()
1423    }
1424}
1425
1426impl fmt::Write for ScalarBuffer {
1427    #[inline]
1428    fn write_str(&mut self, s: &str) -> fmt::Result {
1429        let bytes = s.as_bytes();
1430        let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1431        let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1432        slot.copy_from_slice(bytes);
1433        self.len = end;
1434        Ok(())
1435    }
1436}
1437
1438/// Render a JSON scalar that can never contain HTML-significant bytes.
1439///
1440/// Numbers, booleans, and `null` render identically escaped and unescaped, so
1441/// this bypasses both the `Value::to_string` allocation and the `encode_safe`
1442/// scan that the generic display path performs. Returns `None` when `value` is
1443/// a string, array, or object, leaving those to the caller's fallback.
1444fn format_plain_json_scalar<'buf>(
1445    buffer: &'buf mut ScalarBuffer,
1446    value: &Value,
1447) -> Option<&'buf str> {
1448    match value {
1449        Value::Bool(true) => Some("true"),
1450        Value::Bool(false) => Some("false"),
1451        Value::Null => Some("null"),
1452        Value::Number(number) => {
1453            // `Display for Number` is the same serializer `Value::to_string`
1454            // drives, so the bytes are identical to the allocating path.
1455            write!(buffer, "{number}").ok()?;
1456            buffer.as_str()
1457        }
1458        _ => None,
1459    }
1460}
1461
1462/// Write a JSON scalar straight to the sink, skipping escaping and allocation.
1463///
1464/// Returns `false` when `value` needs the caller's allocating fallback.
1465fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1466    let mut buffer = ScalarBuffer::new();
1467    match format_plain_json_scalar(&mut buffer, value) {
1468        Some(rendered) => {
1469            writer.write(rendered)?;
1470            Ok(true)
1471        }
1472        None => Ok(false),
1473    }
1474}
1475
1476/// Write a usize as decimal digits directly to the writer, avoiding `format!` allocation.
1477pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1478    if n == 0 {
1479        return writer.write("0");
1480    }
1481    // Max digits for a 64-bit usize is 20.
1482    let mut buf = [0u8; 20];
1483    let mut pos = buf.len();
1484    while n > 0 {
1485        pos -= 1;
1486        // n % 10 is always in 0..=9, fits in u8 without truncation.
1487        #[allow(clippy::cast_possible_truncation)]
1488        let digit = (n % 10) as u8;
1489        buf[pos] = b'0' + digit;
1490        n /= 10;
1491    }
1492    // Digits are always valid ASCII/UTF-8.
1493    match std::str::from_utf8(&buf[pos..]) {
1494        Ok(s) => writer.write(s),
1495        Err(_) => writer.write("0"),
1496    }
1497}
1498
1499pub(crate) fn write_script_safe_json<T>(
1500    writer: &mut dyn ResponseWriter,
1501    scratch: &mut Vec<u8>,
1502    value: &T,
1503) -> Result<()>
1504where
1505    T: Serialize + ?Sized,
1506{
1507    // Serialize into the caller's request-local `scratch`. The streaming path
1508    // emits one bootstrap envelope per committed boundary and the ordinary
1509    // body_end bootstrap serializes several fields, so reusing one buffer across
1510    // the render avoids a fresh allocation per value. The buffer grows lazily on
1511    // first use (no allocation until serialization needs it) and is dropped with
1512    // the render context — capacity is reused within a request but never
1513    // retained across requests.
1514    scratch.clear();
1515    serde_json::to_writer(&mut *scratch, value)
1516        .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1517    let json = std::str::from_utf8(scratch)
1518        .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1519    write_script_safe_json_str(writer, json)
1520}
1521
1522/// `</` finder shared by every call, so short template strings don't pay to
1523/// build one per call.
1524static END_TAG_OPENER: LazyLock<memchr::memmem::Finder<'static>> =
1525    LazyLock::new(|| memchr::memmem::Finder::new(b"</"));
1526
1527fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1528    // Most serialized state has no `<`, so a single-byte scan settles it. From
1529    // the first `<` on, search for the `</` pair itself so markup-heavy payloads
1530    // don't stop at every opening or self-closing tag.
1531    let bytes = json.as_bytes();
1532    let mut start = 0;
1533    if let Some(first) = memchr::memchr(b'<', bytes) {
1534        for offset in END_TAG_OPENER.find_iter(&bytes[first..]) {
1535            let index = first + offset;
1536            if index > start {
1537                writer.write(&json[start..index])?;
1538            }
1539            writer.write("<\\/")?;
1540            start = index + 2;
1541        }
1542    }
1543    if start < json.len() {
1544        writer.write(&json[start..])?;
1545    }
1546    Ok(())
1547}
1548
1549fn write_json_field_name(
1550    writer: &mut dyn ResponseWriter,
1551    wrote_field: &mut bool,
1552    name: &str,
1553) -> Result<()> {
1554    if *wrote_field {
1555        writer.write(",")?;
1556    }
1557    *wrote_field = true;
1558    writer.write("\"")?;
1559    writer.write(name)?;
1560    writer.write("\":")
1561}
1562
1563fn write_json_field<T>(
1564    writer: &mut dyn ResponseWriter,
1565    scratch: &mut Vec<u8>,
1566    wrote_field: &mut bool,
1567    name: &str,
1568    value: &T,
1569) -> Result<()>
1570where
1571    T: Serialize + ?Sized,
1572{
1573    write_json_field_name(writer, wrote_field, name)?;
1574    write_script_safe_json(writer, scratch, value)
1575}
1576
1577/// Serialize wrapper that projects an SSR state object down to only the
1578/// keys present in the build-time hydration allowlist.
1579///
1580/// This is the runtime half of the projected-hydration design: instead of
1581/// serializing the entire application state (potentially megabytes) on every
1582/// full-HTML render, only the fields a component actually hydrates are
1583/// emitted. The request allowlist conservatively includes every reachable
1584/// component's hydration keys so no field a component needs is dropped.
1585///
1586/// Projection is a payload boundary, not a secrecy boundary. Any key selected
1587/// by compiled client metadata is browser-facing, so hosts must never place
1588/// secrets in browser render state.
1589///
1590/// `keys` MUST be sorted and deduplicated. Projection iterates whichever side
1591/// is smaller: hydration keys with direct map lookup for wide states, or state
1592/// entries with binary-search membership for compact states. Non-object states
1593/// carry nothing hydratable and serialize as an empty object.
1594struct ProjectedState<'a> {
1595    value: &'a Value,
1596    keys: KeyView<'a>,
1597}
1598
1599impl Serialize for ProjectedState<'_> {
1600    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1601    where
1602        S: serde::Serializer,
1603    {
1604        let Value::Object(map) = self.value else {
1605            return serializer.serialize_map(Some(0))?.end();
1606        };
1607
1608        let mut out = serializer.serialize_map(None)?;
1609        if self.keys.len() < map.len() {
1610            let mut previous = None;
1611            for key in self.keys.iter() {
1612                if key == STATE_INJECT_KEY {
1613                    continue;
1614                }
1615                if previous == Some(key) {
1616                    continue;
1617                }
1618                previous = Some(key);
1619                if let Some(value) = map.get(key) {
1620                    out.serialize_entry(key, value)?;
1621                }
1622            }
1623        } else {
1624            for (key, value) in map {
1625                if key == STATE_INJECT_KEY {
1626                    continue;
1627                }
1628                if self.keys.contains(key.as_str()) {
1629                    out.serialize_entry(key, value)?;
1630                }
1631            }
1632        }
1633        out.end()
1634    }
1635}
1636
1637/// Write the SSR `state` into the bootstrap block according to the protocol's
1638/// build-time selection and escape it for safe embedding in a `<script>`.
1639///
1640/// [`ProjectedState`] serializes only the allowlisted keys, so for the typical
1641/// payload — a large state with a small hydratable surface — serde ever only
1642/// touches the projected subset. Serialization reuses the proven
1643/// [`write_script_safe_json`] path (serde's fast `Vec<u8>` target plus a single
1644/// SIMD-accelerated `</` escape pass), which matches the pre-projection cost
1645/// when every key is hydratable and collapses to a few bytes when it is not.
1646/// Buffering the projected bytes and escaping once is measurably faster than
1647/// streaming through a per-token `io::Write` adapter, and the projected buffer
1648/// is tiny in the common case.
1649/// Serialize a state object with the reserved [`STATE_INJECT_KEY`] entry
1650/// omitted.
1651///
1652/// The reserved key carries host-supplied boundary HTML, not application
1653/// state, so it must never reach the client hydration payload — shipping it
1654/// would both duplicate the markup as a JSON string and expose the host's
1655/// inject channel to client code. Filtering happens during serialization so
1656/// the state tree is never cloned.
1657struct StateWithoutReservedKey<'a> {
1658    value: &'a Value,
1659}
1660
1661impl Serialize for StateWithoutReservedKey<'_> {
1662    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1663    where
1664        S: serde::Serializer,
1665    {
1666        let Value::Object(map) = self.value else {
1667            return self.value.serialize(serializer);
1668        };
1669        let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1670        for (key, value) in map {
1671            if key == STATE_INJECT_KEY {
1672                continue;
1673            }
1674            out.serialize_entry(key, value)?;
1675        }
1676        out.end()
1677    }
1678}
1679
1680/// Write the complete state, stripping the reserved inject key when present.
1681///
1682/// The membership test is a single map lookup and the common case — no
1683/// reserved key — takes the original zero-overhead path unchanged.
1684fn write_full_state(
1685    writer: &mut dyn ResponseWriter,
1686    scratch: &mut Vec<u8>,
1687    state: &Value,
1688) -> Result<()> {
1689    if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1690        return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1691    }
1692    write_script_safe_json(writer, scratch, state)
1693}
1694
1695pub(crate) fn write_selected_state(
1696    writer: &mut dyn ResponseWriter,
1697    scratch: &mut Vec<u8>,
1698    state: &Value,
1699    selection: &StateSelection<'_>,
1700) -> Result<()> {
1701    let keys = match selection {
1702        StateSelection::Full => return write_full_state(writer, scratch, state),
1703        StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1704        StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1705        StateSelection::FullExceptKeyIds(selection) => {
1706            return write_script_safe_json(
1707                writer,
1708                scratch,
1709                &StateWithoutSelectedKeys {
1710                    value: state,
1711                    keys: KeyView::Ids(*selection),
1712                },
1713            );
1714        }
1715    };
1716    if keys.is_empty() {
1717        return writer.write("{}");
1718    }
1719
1720    // Projection membership may use binary search, so a mis-sorted key set
1721    // would silently drop hydration keys. The key allowlist is produced sorted +
1722    // deduped at build time; this guard makes hand-built protocols that violate
1723    // the invariant fail loudly in tests at zero release cost.
1724    debug_assert!(
1725        keys.iter()
1726            .zip(keys.iter().skip(1))
1727            .all(|(left, right)| left <= right),
1728        "hydration keys must be sorted for binary-search projection"
1729    );
1730    if let Value::Object(map) = state {
1731        let selects_entire_map =
1732            keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1733        if selects_entire_map {
1734            return write_full_state(writer, scratch, state);
1735        }
1736    }
1737    write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1738}
1739
1740// Covers the common route surface without trusting protocol-derived counts for
1741// an eager allocation; larger key sets grow only as actual keys are visited.
1742pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1743
1744/// Request-scoped state selection derived from reachable component metadata.
1745pub(crate) enum StateSelection<'a> {
1746    /// Preserve the complete state value.
1747    Full,
1748    /// Project an object to a sorted, deduplicated key allowlist.
1749    Keys(Vec<&'a str>),
1750    /// Project using the streaming continuation's interned hydration key IDs.
1751    ///
1752    /// IDs are assigned in lexicographic order, so a sorted ID slice is a
1753    /// sorted key slice and the projection needs no borrowed-string buffer at
1754    /// all — the streaming record keeps its scratch as plain integers that
1755    /// survive every semantic step.
1756    KeyIds(HydrationKeySelection<'a>),
1757    /// Preserve full state except keys inherited from a referenced range state.
1758    FullExceptKeyIds(HydrationKeySelection<'a>),
1759}
1760
1761/// A projection expressed as interned hydration key IDs.
1762#[derive(Clone, Copy)]
1763pub(crate) struct HydrationKeySelection<'a> {
1764    pub(crate) ids: &'a [u32],
1765    pub(crate) index: &'a ComponentReachabilityIndex,
1766}
1767
1768impl<'a> HydrationKeySelection<'a> {
1769    fn key(self, position: usize) -> Option<&'a str> {
1770        let id = self.ids.get(position).copied()?;
1771        self.index.hydration_key(id)
1772    }
1773}
1774
1775/// A sorted, deduplicated key allowlist in whichever form its producer holds.
1776#[derive(Clone, Copy)]
1777enum KeyView<'a> {
1778    Borrowed(&'a [&'a str]),
1779    Ids(HydrationKeySelection<'a>),
1780}
1781
1782impl<'a> KeyView<'a> {
1783    fn len(self) -> usize {
1784        match self {
1785            Self::Borrowed(keys) => keys.len(),
1786            Self::Ids(selection) => selection.ids.len(),
1787        }
1788    }
1789
1790    fn is_empty(self) -> bool {
1791        self.len() == 0
1792    }
1793
1794    fn get(self, position: usize) -> Option<&'a str> {
1795        match self {
1796            Self::Borrowed(keys) => keys.get(position).copied(),
1797            Self::Ids(selection) => selection.key(position),
1798        }
1799    }
1800
1801    fn iter(self) -> impl Iterator<Item = &'a str> {
1802        (0..self.len()).map_while(move |position| self.get(position))
1803    }
1804
1805    /// Sorted-membership probe used when the state object is smaller than the
1806    /// allowlist.
1807    fn contains(self, key: &str) -> bool {
1808        match self {
1809            Self::Borrowed(keys) => keys
1810                .binary_search_by(|candidate| str::cmp(candidate, key))
1811                .is_ok(),
1812            Self::Ids(selection) => selection
1813                .ids
1814                .binary_search_by(|id| {
1815                    selection
1816                        .index
1817                        .hydration_key(*id)
1818                        .map_or(std::cmp::Ordering::Less, |candidate| {
1819                            str::cmp(candidate, key)
1820                        })
1821                })
1822                .is_ok(),
1823        }
1824    }
1825}
1826
1827#[derive(Clone, Copy)]
1828enum ComponentStateSurface {
1829    Hydration,
1830    Navigation,
1831}
1832
1833/// Select initial state for the components reachable on this request path.
1834///
1835/// Non-WebUI protocols preserve full state without walking component surfaces.
1836/// WebUI protocols project exact surfaces, while any unknown surface restores
1837/// the full state for correctness.
1838pub(crate) fn collect_hydration_state<'a, 'b>(
1839    protocol: &'a WebUIProtocol,
1840    components: impl IntoIterator<Item = &'b str>,
1841) -> StateSelection<'a> {
1842    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1843        return StateSelection::Full;
1844    }
1845    collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1846}
1847
1848/// Fill a reusable hydration key-ID allowlist for a streaming record.
1849///
1850/// Components arrive as inventory indexes, so the projection never resolves a
1851/// component name, never hashes it against the compiled component map, and
1852/// never sorts strings: the interned runs concatenate and the integer sort
1853/// leaves the IDs in lexicographic key order. Returns `true` when correctness
1854/// requires sending full state instead.
1855pub(crate) fn collect_hydration_key_ids_into(
1856    protocol: &WebUIProtocol,
1857    index: &ComponentReachabilityIndex,
1858    components: impl IntoIterator<Item = u32>,
1859    ids: &mut Vec<u32>,
1860) -> bool {
1861    ids.clear();
1862    if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1863        return true;
1864    }
1865    for component in components {
1866        if index.extend_hydration_keys(component, ids) {
1867            ids.clear();
1868            return true;
1869        }
1870    }
1871    ids.sort_unstable();
1872    ids.dedup();
1873    false
1874}
1875
1876/// Select state for client-created components reachable during navigation.
1877pub(crate) fn collect_navigation_state<'a, 'b>(
1878    protocol: &'a WebUIProtocol,
1879    components: impl IntoIterator<Item = &'b str>,
1880) -> StateSelection<'a> {
1881    collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1882}
1883
1884fn collect_component_state<'a, 'b>(
1885    protocol: &'a WebUIProtocol,
1886    components: impl IntoIterator<Item = &'b str>,
1887    surface: ComponentStateSurface,
1888) -> StateSelection<'a> {
1889    let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1890    if collect_component_state_into(protocol, components, surface, &mut keys) {
1891        StateSelection::Full
1892    } else {
1893        StateSelection::Keys(keys)
1894    }
1895}
1896
1897/// Fill a reusable state-key allowlist. Returns `true` when correctness
1898/// requires sending full state instead of the collected keys.
1899fn collect_component_state_into<'a, 'b>(
1900    protocol: &'a WebUIProtocol,
1901    components: impl IntoIterator<Item = &'b str>,
1902    surface: ComponentStateSurface,
1903    keys: &mut Vec<&'a str>,
1904) -> bool {
1905    keys.clear();
1906    for name in components {
1907        let Some(component) = protocol.components.get(name) else {
1908            return true;
1909        };
1910        let (mode, component_keys) = match surface {
1911            ComponentStateSurface::Hydration => {
1912                (component.hydration_mode, &component.hydration_keys)
1913            }
1914            ComponentStateSurface::Navigation => (
1915                match component.navigation_mode {
1916                    Some(mode) => mode,
1917                    None if !component.navigation_keys.is_empty() => {
1918                        StateProjectionMode::Keys as i32
1919                    }
1920                    None => return true,
1921                },
1922                &component.navigation_keys,
1923            ),
1924        };
1925        if mode == StateProjectionMode::All as i32 {
1926            return true;
1927        }
1928        if mode == StateProjectionMode::Keys as i32
1929            || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1930        {
1931            keys.extend(component_keys.iter().map(String::as_str));
1932        } else if mode != StateProjectionMode::None as i32 {
1933            return true;
1934        }
1935    }
1936    keys.sort_unstable();
1937    keys.dedup();
1938    false
1939}
1940
1941pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1942    writer: &mut dyn ResponseWriter,
1943    scratch: &mut Vec<u8>,
1944    bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1945) -> Result<()> {
1946    let mut wrote_field = false;
1947
1948    writer.write("{")?;
1949    if let Some(declaration_id) = bootstrap.declaration_id {
1950        write_json_field(
1951            writer,
1952            scratch,
1953            &mut wrote_field,
1954            "declarationId",
1955            &declaration_id,
1956        )?;
1957    }
1958    if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1959        write_json_field(
1960            writer,
1961            scratch,
1962            &mut wrote_field,
1963            "enclosingSpanInstanceId",
1964            &span_id,
1965        )?;
1966    }
1967    if !bootstrap.chain.is_empty() {
1968        write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1969    }
1970    // Definitions must be visible before template metadata can cause the
1971    // runtime to create a component root.
1972    write_json_field(
1973        writer,
1974        scratch,
1975        &mut wrote_field,
1976        "componentStyles",
1977        bootstrap.component_styles,
1978    )?;
1979    if !bootstrap.css_hrefs.is_empty() {
1980        write_json_field(
1981            writer,
1982            scratch,
1983            &mut wrote_field,
1984            "css",
1985            bootstrap.css_hrefs,
1986        )?;
1987    }
1988    write_json_field(
1989        writer,
1990        scratch,
1991        &mut wrote_field,
1992        "inventory",
1993        bootstrap.inventory,
1994    )?;
1995    if let Some(nonce) = bootstrap.nonce {
1996        write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1997    }
1998    match bootstrap.state {
1999        WebUiBootstrapState::Complete { value, selection } => {
2000            write_json_field_name(writer, &mut wrote_field, "state")?;
2001            write_selected_state(writer, scratch, value, &selection)?;
2002        }
2003        WebUiBootstrapState::Reference {
2004            record_sequence,
2005            value,
2006            delta,
2007        } => {
2008            write_json_field(
2009                writer,
2010                scratch,
2011                &mut wrote_field,
2012                "stateRef",
2013                &record_sequence,
2014            )?;
2015            if let Some(delta) = delta {
2016                write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
2017                write_selected_state(writer, scratch, value, &delta)?;
2018            }
2019        }
2020    }
2021    if !bootstrap.style_specs.is_empty() {
2022        write_json_field(
2023            writer,
2024            scratch,
2025            &mut wrote_field,
2026            "styles",
2027            bootstrap.style_specs,
2028        )?;
2029    }
2030    if bootstrap
2031        .templates
2032        .iter()
2033        .any(|template| !template.template_json.is_empty())
2034    {
2035        write_json_field_name(writer, &mut wrote_field, "templates")?;
2036        write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
2037    }
2038    writer.write("}")
2039}
2040
2041fn write_webui_data_block(
2042    writer: &mut dyn ResponseWriter,
2043    scratch: &mut Vec<u8>,
2044    bootstrap: WebUiBootstrap<'_>,
2045) -> Result<()> {
2046    writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
2047    if let Some(nonce) = bootstrap.nonce {
2048        writer.write(" nonce=\"")?;
2049        writer.write(nonce)?;
2050        writer.write("\"")?;
2051    }
2052
2053    writer.write(">")?;
2054    write_webui_bootstrap(writer, scratch, bootstrap)?;
2055    writer.write("</script>\n")
2056}
2057
2058fn write_component_asset_style_manifest(
2059    writer: &mut dyn ResponseWriter,
2060    manifest: &str,
2061    nonce: Option<&str>,
2062) -> Result<()> {
2063    if manifest.is_empty() {
2064        return Ok(());
2065    }
2066    writer.write("<script type=\"application/json\" id=\"")?;
2067    writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
2068    writer.write("\"")?;
2069    if let Some(nonce) = nonce {
2070        writer.write(" nonce=\"")?;
2071        writer.write(&crate::html_encode::encode_safe(nonce))?;
2072        writer.write("\"")?;
2073    }
2074    writer.write(">")?;
2075    writer.write(manifest)?;
2076    writer.write("</script>")
2077}
2078
2079fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
2080    if context.component_asset_styles_emitted {
2081        return Ok(());
2082    }
2083    context.component_asset_styles_emitted = true;
2084    context.writer.write(context.component_asset_style_links)?;
2085    write_component_asset_style_manifest(
2086        context.writer,
2087        context.component_asset_style_manifest,
2088        context.nonce,
2089    )
2090}
2091
2092fn write_webui_template_json_map(
2093    writer: &mut dyn ResponseWriter,
2094    scratch: &mut Vec<u8>,
2095    templates: &[WebUiTemplatePayload<'_>],
2096) -> Result<()> {
2097    writer.write("{")?;
2098    let mut wrote = false;
2099    for template in templates {
2100        if template.template_json.is_empty() {
2101            continue;
2102        }
2103        if wrote {
2104            writer.write(",")?;
2105        }
2106        wrote = true;
2107        write_script_safe_json(writer, scratch, template.tag_name)?;
2108        writer.write(":")?;
2109        write_script_safe_json_str(writer, template.template_json)?;
2110    }
2111    writer.write("}")
2112}
2113
2114fn resolve_value_from_sources<'ctx, 'state>(
2115    path: &str,
2116    loop_vars: &'ctx [LoopBinding<'_, 'state>],
2117    visible_loop_scope: VisibleLoopScope,
2118    local_values: LocalValueSources<'ctx, '_, 'state>,
2119    state: &'state Value,
2120) -> Option<Cow<'ctx, Value>>
2121where
2122    'state: 'ctx,
2123{
2124    if let Some(binding) = loop_vars
2125        .get(visible_loop_scope.start..visible_loop_scope.end)
2126        .and_then(|bindings| bindings.last())
2127    {
2128        let name = binding.name;
2129        if path.len() == name.len() && path == name {
2130            return Some(Cow::Borrowed(binding.value));
2131        }
2132        if path.len() > name.len()
2133            && path.as_bytes().get(name.len()) == Some(&b'.')
2134            && path.starts_with(name)
2135        {
2136            if let Some(value) =
2137                find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2138            {
2139                return Some(value);
2140            }
2141            return find_value_by_dotted_path_ref(path, state);
2142        }
2143    }
2144
2145    if let Some(first_part) = path.split('.').next() {
2146        if let Some(local_value) = local_values.borrowed.get(first_part) {
2147            if first_part.len() == path.len() {
2148                return Some(Cow::Borrowed(local_value));
2149            }
2150            let remaining = &path[first_part.len() + 1..];
2151            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2152                return Some(value);
2153            }
2154            return find_value_by_dotted_path_ref(path, state);
2155        }
2156        if let Some(local_value) = local_values.owned.get(first_part) {
2157            if first_part.len() == path.len() {
2158                return Some(Cow::Borrowed(local_value));
2159            }
2160            let remaining = &path[first_part.len() + 1..];
2161            if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2162                return Some(value);
2163            }
2164            return find_value_by_dotted_path_ref(path, state);
2165        }
2166
2167        for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2168            .iter()
2169            .rev()
2170        {
2171            if binding.name != first_part {
2172                continue;
2173            }
2174            if first_part.len() == path.len() {
2175                return Some(Cow::Borrowed(binding.value));
2176            }
2177            let remaining = &path[first_part.len() + 1..];
2178            if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2179                return Some(value);
2180            }
2181            return find_value_by_dotted_path_ref(path, state);
2182        }
2183    }
2184
2185    find_value_by_dotted_path_ref(path, state)
2186}
2187
2188fn resolve_borrowed_collection<'state>(
2189    path: &str,
2190    loop_vars: &[LoopBinding<'_, 'state>],
2191    visible_loop_scope: VisibleLoopScope,
2192    local_values: LocalValueSources<'_, '_, 'state>,
2193    state: &'state Value,
2194) -> Option<&'state [Value]> {
2195    let first_part = path.split('.').next()?;
2196    if let Some(binding) = loop_vars
2197        .get(visible_loop_scope.start..visible_loop_scope.end)
2198        .and_then(|bindings| bindings.last())
2199        .filter(|binding| binding.name == first_part)
2200    {
2201        if first_part.len() == path.len() {
2202            return binding.value.as_array().map(Vec::as_slice);
2203        }
2204        let remaining = &path[first_part.len() + 1..];
2205        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2206            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2207            Some(Cow::Owned(_)) => None,
2208            None => borrowed_state_array(path, state),
2209        };
2210    }
2211    if let Some(local_value) = local_values.borrowed.get(first_part) {
2212        let local_match = if first_part.len() == path.len() {
2213            Some(Cow::Borrowed(local_value))
2214        } else {
2215            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2216        };
2217        return match local_match {
2218            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2219            Some(Cow::Owned(_)) => None,
2220            None => borrowed_state_array(path, state),
2221        };
2222    }
2223    if let Some(local_value) = local_values.owned.get(first_part) {
2224        let local_match = if first_part.len() == path.len() {
2225            Some(Cow::Borrowed(local_value))
2226        } else {
2227            find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2228        };
2229        if local_match.is_some() {
2230            return None;
2231        }
2232        return borrowed_state_array(path, state);
2233    }
2234
2235    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2236        .iter()
2237        .rev()
2238    {
2239        if binding.name != first_part {
2240            continue;
2241        }
2242        if first_part.len() == path.len() {
2243            return binding.value.as_array().map(Vec::as_slice);
2244        }
2245        let remaining = &path[first_part.len() + 1..];
2246        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2247            Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2248            Some(Cow::Owned(_)) => None,
2249            None => borrowed_state_array(path, state),
2250        };
2251    }
2252
2253    borrowed_state_array(path, state)
2254}
2255
2256fn resolve_state_backed_value<'state>(
2257    path: &str,
2258    loop_vars: &[LoopBinding<'_, 'state>],
2259    visible_loop_scope: VisibleLoopScope,
2260    local_values: LocalValueSources<'_, '_, 'state>,
2261    state: &'state Value,
2262) -> Option<&'state Value> {
2263    let first_part = path.split('.').next()?;
2264    if let Some(binding) = loop_vars
2265        .get(visible_loop_scope.start..visible_loop_scope.end)
2266        .and_then(|bindings| bindings.last())
2267        .filter(|binding| binding.name == first_part)
2268    {
2269        if first_part.len() == path.len() {
2270            return Some(binding.value);
2271        }
2272        let remaining = &path[first_part.len() + 1..];
2273        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2274            Some(Cow::Borrowed(value)) => Some(value),
2275            Some(Cow::Owned(_)) => None,
2276            None => borrowed_state_value(path, state),
2277        };
2278    }
2279    if let Some(local_value) = local_values.borrowed.get(first_part) {
2280        if first_part.len() == path.len() {
2281            return Some(local_value);
2282        }
2283        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2284            Some(Cow::Borrowed(value)) => Some(value),
2285            Some(Cow::Owned(_)) => None,
2286            None => borrowed_state_value(path, state),
2287        };
2288    }
2289    if let Some(local_value) = local_values.owned.get(first_part) {
2290        if first_part.len() == path.len() {
2291            return None;
2292        }
2293        return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2294            Some(_) => None,
2295            None => borrowed_state_value(path, state),
2296        };
2297    }
2298
2299    for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2300        .iter()
2301        .rev()
2302    {
2303        if binding.name != first_part {
2304            continue;
2305        }
2306        if first_part.len() == path.len() {
2307            return Some(binding.value);
2308        }
2309        let remaining = &path[first_part.len() + 1..];
2310        return match find_value_by_dotted_path_ref(remaining, binding.value) {
2311            Some(Cow::Borrowed(value)) => Some(value),
2312            Some(Cow::Owned(_)) => None,
2313            None => borrowed_state_value(path, state),
2314        };
2315    }
2316
2317    borrowed_state_value(path, state)
2318}
2319
2320fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2321    match find_value_by_dotted_path_ref(path, state) {
2322        Some(Cow::Borrowed(value)) => Some(value),
2323        Some(Cow::Owned(_)) | None => None,
2324    }
2325}
2326
2327fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2328    match find_value_by_dotted_path_ref(path, state)? {
2329        Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2330        Cow::Owned(_) => None,
2331    }
2332}
2333
2334impl WebUIHandler {
2335    /// Create a new WebUI handler with no plugin.
2336    pub fn new() -> Self {
2337        Self {
2338            plugin_factory: None,
2339        }
2340    }
2341
2342    /// Create a new WebUI handler with a plugin factory.
2343    ///
2344    /// Each render call creates a fresh plugin instance from the factory,
2345    /// enabling concurrent renders with `&self`.
2346    pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2347        Self {
2348            plugin_factory: Some(factory),
2349        }
2350    }
2351
2352    #[cfg(test)]
2353    fn handle(
2354        &self,
2355        document: &WebUIProtocol,
2356        state: &Value,
2357        options: &RenderOptions<'_>,
2358        writer: &mut dyn ResponseWriter,
2359    ) -> Result<()> {
2360        let protocol = Protocol::new(document.clone());
2361        self.render(&protocol, state, options, writer)
2362    }
2363
2364    /// Process a fragment by its prepared render slot, falling back to an ID
2365    /// lookup when the caller has no prepared slot (handler-generated hosts).
2366    ///
2367    /// The `context` parameter contains scope-local variables that are accessible during rendering,
2368    /// such as loop iteration variables. This is separate from the global `state`.
2369    fn process_fragment_target<'data>(
2370        &self,
2371        target: Option<usize>,
2372        fragment_id: &str,
2373        context: &mut WebUIProcessContext<'data, '_, '_>,
2374    ) -> Result<()> {
2375        let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2376            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2377        };
2378        let Some(fragment_list) = context.render_fragments.list(index) else {
2379            return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2380        };
2381        self.process_fragment(fragment_list, context)
2382    }
2383
2384    /// Process a vector of fragments.
2385    ///
2386    /// The `context` maintains scope-specific variables that can be accessed by fragments
2387    /// during rendering, while `state` contains the global application state.
2388    fn process_fragment<'data>(
2389        &self,
2390        fragment_list: RenderFragmentList<'data>,
2391        context: &mut WebUIProcessContext<'data, '_, '_>,
2392    ) -> Result<()> {
2393        self.process_fragment_from(fragment_list, 0, context)
2394    }
2395
2396    fn process_fragment_from<'data>(
2397        &self,
2398        fragment_list: RenderFragmentList<'data>,
2399        start: usize,
2400        context: &mut WebUIProcessContext<'data, '_, '_>,
2401    ) -> Result<()> {
2402        let fragments = fragment_list.fragments;
2403        // Pre-scan: find the best matching route among sibling routes by specificity.
2404        // This ensures `/contacts/add` (2 literals) beats `/contacts/:id` (1 literal).
2405        // Resolves relative paths (`./`) using the current route_base.
2406        // Lists prepared without any route fragment cannot produce a match, so
2407        // the scan is skipped entirely for them.
2408        let best_route = if fragment_list.has_routes {
2409            route_renderer::find_best_route_match(
2410                fragments,
2411                context.request_path,
2412                &context.route_base,
2413                context.route_index,
2414            )
2415        } else {
2416            None
2417        };
2418        self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2419    }
2420
2421    fn process_fragment_range<'data>(
2422        &self,
2423        fragment_list: RenderFragmentList<'data>,
2424        range: std::ops::Range<usize>,
2425        best_route: &Option<(String, route_matcher::RouteMatch)>,
2426        context: &mut WebUIProcessContext<'data, '_, '_>,
2427    ) -> Result<()> {
2428        let fragments = fragment_list.fragments;
2429        let Some(selected) = fragments.get(range.clone()) else {
2430            return Err(invalid_fragment_range_error(&range, fragments.len()));
2431        };
2432        for (offset, item) in selected.iter().enumerate() {
2433            let index = range.start + offset;
2434            if context.streaming.is_some() {
2435                validate_pending_streaming_root(item, context)?;
2436                validate_streaming_root_opening(&fragments[..index], item)?;
2437            }
2438            // The prepared target is only read by fragments that descend, so it
2439            // is resolved per-arm. Hoisting it here would charge every raw text
2440            // fragment for a lookup it never uses.
2441            match item.fragment.as_ref() {
2442                Some(Fragment::Raw(raw)) => {
2443                    context.writer.write(&raw.value)?;
2444                }
2445                Some(Fragment::Component(component)) => {
2446                    self.process_component(
2447                        &component.fragment_id,
2448                        fragment_list.target(index),
2449                        ComponentHostOrigin::ParserProduced,
2450                        context,
2451                    )?;
2452                }
2453                Some(Fragment::ForLoop(for_loop)) => {
2454                    self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2455                }
2456                Some(Fragment::Signal(signal)) => {
2457                    self.process_signal(signal, context)?;
2458                }
2459                Some(Fragment::IfCond(if_cond)) => {
2460                    self.process_if(if_cond, fragment_list.target(index), context)?;
2461                }
2462                Some(Fragment::Attribute(attr)) => {
2463                    self.process_attribute(
2464                        attr,
2465                        fragment_list.target(index),
2466                        fragment_list.component_attr_name(index),
2467                        context,
2468                    )?;
2469                }
2470                Some(Fragment::Plugin(plugin_frag)) => {
2471                    if let Some(p) = &mut context.plugin {
2472                        p.on_element_data(&plugin_frag.data, context.writer)?;
2473                    }
2474                }
2475                Some(Fragment::Route(route_frag)) => {
2476                    self.process_route(route_frag, best_route, context)?;
2477                }
2478                Some(Fragment::Outlet(_)) => {
2479                    self.process_outlet(context)?;
2480                }
2481                Some(Fragment::Boundary(_)) => {}
2482                None => {}
2483            }
2484        }
2485        ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2486    }
2487
2488    /// Process an `<outlet />` directive.
2489    ///
2490    /// Matches children from the currently active route's `children` field
2491    /// against the request path, renders the matched child `<webui-route>`
2492    /// elements directly at this position (no wrapper element).
2493    fn process_outlet<'protocol>(
2494        &self,
2495        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2496    ) -> Result<()> {
2497        // Moved out so the matched child can render with the context pointing
2498        // at its grandchildren. The level is deliberately not put back, which
2499        // preserves the previous behavior exactly: a second `<outlet />` at
2500        // this level renders nothing. That is a latent bug tracked by #515, not
2501        // a property this function needs; fixing it belongs in its own change.
2502        let mut children = std::mem::take(&mut context.route_children);
2503        if children.is_empty() {
2504            return Ok(());
2505        }
2506
2507        // Find the best matching child route
2508        let request_segments = route_matcher::split_request_path(context.request_path);
2509        let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2510        for (idx, child) in children.iter().enumerate() {
2511            if let Some(m) = route_matcher::match_route_indexed_with_segments(
2512                context.route_index,
2513                &child.path,
2514                &context.route_base,
2515                &request_segments,
2516                child.exact,
2517            ) {
2518                let is_better = best
2519                    .as_ref()
2520                    .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2521                if is_better {
2522                    best = Some((idx, m));
2523                }
2524            }
2525        }
2526
2527        let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2528        if let Some((idx, ref rm)) = best {
2529            route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2530            let descended = descend_into(&mut children, idx);
2531            let Some(matched_child) = children.get(idx) else {
2532                return Ok(());
2533            };
2534            let comp = &matched_child.fragment_id;
2535
2536            if !comp.is_empty() {
2537                let saved_route_base = (rm.consumed_segments > 0).then(|| {
2538                    let base = route_matcher::compute_route_base(
2539                        context.request_path,
2540                        rm.consumed_segments,
2541                    );
2542                    std::mem::replace(&mut context.route_base, Cow::Owned(base))
2543                });
2544                context.route_children = descended;
2545
2546                // Emit matched <webui-route>
2547                context.writer.write("<webui-route")?;
2548                context.writer.write(" path=\"")?;
2549                context.writer.write(&matched_child.path)?;
2550                context.writer.write("\"")?;
2551                context.writer.write(" component=\"")?;
2552                context.writer.write(comp)?;
2553                context.writer.write("\"")?;
2554                if matched_child.exact {
2555                    context.writer.write(" exact")?;
2556                }
2557                route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2558                // Emit data-ri for O(1) client-side element binding
2559                let ri = context.route_chain_index;
2560                context.route_chain_index += 1;
2561                context.writer.write(" data-ri=\"")?;
2562                write_usize(context.writer, ri)?;
2563                context.writer.write("\" active>")?;
2564
2565                if !Self::component_owns_css_tree(comp, context.protocol) {
2566                    self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2567                }
2568                if !matched_child.content_fragment_id.is_empty() {
2569                    self.process_fragment_target(
2570                        None,
2571                        &matched_child.content_fragment_id,
2572                        context,
2573                    )?;
2574                }
2575
2576                context.writer.write("<")?;
2577                context.writer.write(comp)?;
2578                if let Some(p) = &context.plugin {
2579                    p.write_route_component_state(context.state, context.writer)?;
2580                }
2581                write_interaction_marker(comp, context)?;
2582                prepare_generated_streaming_root(comp, context)?;
2583                context.writer.write(">")?;
2584
2585                self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2586
2587                context.writer.write("</")?;
2588                context.writer.write(comp)?;
2589                context.writer.write(">")?;
2590                context.writer.write("</webui-route>")?;
2591
2592                if let Some(saved) = saved_route_base {
2593                    context.route_base = saved;
2594                }
2595                // Restores the empty level the matched child was rendered
2596                // against, rather than the level this outlet matched. See the
2597                // note at the top of this function and #515.
2598                context.route_children = Cow::Borrowed(&[]);
2599            }
2600        }
2601
2602        route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2603    }
2604
2605    /// Emit a `<script type="importmap">` tag that registers a component's
2606    /// CSS module under its specifier via a `data:text/css,…` URI.
2607    ///
2608    /// Requires Multiple Import Maps (Chrome 133+); each call emits an
2609    /// independent importmap that the browser merges at the document
2610    /// level. The per-render CSP nonce is applied when set (importmap
2611    /// scripts honor `script-src`).
2612    ///
2613    /// Example for `my-comp` with CSS `span{color:blue;}`:
2614    /// `<script type="importmap" nonce="..." data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>`
2615    fn emit_css_module_importmap(
2616        &self,
2617        specifier: &str,
2618        css: &str,
2619        context: &mut WebUIProcessContext,
2620    ) -> Result<()> {
2621        let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2622        context.writer.write(&tag)?;
2623        Ok(())
2624    }
2625
2626    /// Install one compiler-ordered closure into the active CSS tree.
2627    fn emit_component_style_closure(
2628        &self,
2629        root: &str,
2630        install: StyleClosureInstall,
2631        context: &mut WebUIProcessContext,
2632    ) -> Result<()> {
2633        if context.protocol.style_closures.is_empty() {
2634            return Ok(());
2635        }
2636        let shadow_static_closure = if install == StyleClosureInstall::Routed {
2637            match context.shadow_style_roots.last() {
2638                Some(shadow_root) => {
2639                    if !shadow_root.static_closure_emitted {
2640                        return Err(HandlerError::Invariant(
2641                            "routed styles reached a ShadowRoot before its static style hook"
2642                                .to_string(),
2643                        ));
2644                    }
2645                    let root_name = context
2646                        .component_index
2647                        .iter()
2648                        .find_map(|(name, index)| {
2649                            (*index == shadow_root.component_index).then_some(name.as_str())
2650                        })
2651                        .ok_or_else(|| {
2652                            HandlerError::Invariant(
2653                                "active Shadow style root lost its protocol index".to_string(),
2654                            )
2655                        })?;
2656                    Some(
2657                        context
2658                            .protocol
2659                            .style_closures
2660                            .get(root_name)
2661                            .ok_or_else(|| {
2662                                HandlerError::Invariant(format!(
2663                            "component style closure metadata is missing Shadow root `{root_name}`"
2664                        ))
2665                            })?,
2666                    )
2667                }
2668                None => None,
2669            }
2670        } else {
2671            None
2672        };
2673        let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2674            HandlerError::Invariant(format!(
2675                "component style closure metadata is missing root `{root}`"
2676            ))
2677        })?;
2678        let is_document_tree = context.shadow_style_roots.is_empty();
2679        let strategy = context.css_strategy;
2680
2681        // A bundled build delivers merged chunks; otherwise every component
2682        // delivers its own stylesheet. Both walk the closure in the same order,
2683        // so the emitted cascade is identical either way. Bundling is a
2684        // build-wide decision, so the two never mix within one protocol.
2685        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2686        let mut emitted_resources = EmittedStyleUnits::new();
2687
2688        for position in 0..unit_count {
2689            let unit = context
2690                .protocol
2691                .style_closure_unit(closure, &context.style_chunk_index, position)
2692                .ok_or_else(|| {
2693                    HandlerError::Invariant(format!(
2694                        "component style closure `{root}` references out-of-range unit {position}"
2695                    ))
2696                })?;
2697            let (name, chunk) = (unit.name, unit.chunk);
2698            if !emitted_resources.insert(name) {
2699                continue;
2700            }
2701            let resource = unit.resource.ok_or_else(|| match chunk {
2702                Some(index) => HandlerError::Invariant(format!(
2703                    "component style closure `{root}` references missing style chunk {index}"
2704                )),
2705                None => HandlerError::Invariant(format!(
2706                    "component style closure `{root}` references missing resource `{name}`"
2707                )),
2708            })?;
2709            if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2710                continue;
2711            }
2712            if let Some(static_closure) = shadow_static_closure {
2713                let resource_index = match chunk {
2714                    Some(index) => {
2715                        if static_closure.style_chunks.contains(&index) {
2716                            continue;
2717                        }
2718                        index
2719                    }
2720                    None => {
2721                        if static_closure.component_tags.iter().any(|tag| tag == name) {
2722                            continue;
2723                        }
2724                        context.component_index.get(name).copied().ok_or_else(|| {
2725                            HandlerError::Invariant(format!(
2726                                "component style resource `{name}` is missing its protocol index"
2727                            ))
2728                        })?
2729                    }
2730                };
2731                let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2732                    HandlerError::Invariant(
2733                        "active Shadow style root disappeared during routed style delivery"
2734                            .to_string(),
2735                    )
2736                })?;
2737                if shadow_root.routed_resources.contains(&resource_index) {
2738                    continue;
2739                }
2740                shadow_root.routed_resources.push(resource_index);
2741            }
2742
2743            match strategy {
2744                webui_protocol::CssStrategy::Link => {
2745                    context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2746                    context
2747                        .writer
2748                        .write(&crate::html_encode::encode_safe(resource))?;
2749                    context.writer.write("\" data-webui-resource=\"")?;
2750                    context
2751                        .writer
2752                        .write(&crate::html_encode::encode_safe(name))?;
2753                    context.writer.write("\" data-webui-strategy=\"link\">")?;
2754                }
2755                strategy => {
2756                    context.writer.write("<style")?;
2757                    if let Some(nonce) = context.nonce {
2758                        context.writer.write(" nonce=\"")?;
2759                        context
2760                            .writer
2761                            .write(&crate::html_encode::encode_safe(nonce))?;
2762                        context.writer.write("\"")?;
2763                    }
2764                    context.writer.write(" data-webui-resource=\"")?;
2765                    context
2766                        .writer
2767                        .write(&crate::html_encode::encode_safe(name))?;
2768                    context.writer.write("\" data-webui-strategy=\"")?;
2769                    context
2770                        .writer
2771                        .write(if strategy == webui_protocol::CssStrategy::Module {
2772                            "module"
2773                        } else {
2774                            "style"
2775                        })?;
2776                    context.writer.write("\">")?;
2777                    if context.style_resources_requiring_escape.contains(name) {
2778                        crate::html_encode::write_style_text(context.writer, resource)?;
2779                    } else {
2780                        context.writer.write(resource)?;
2781                    }
2782                    context.writer.write("</style>")?;
2783                }
2784            }
2785        }
2786        Ok(())
2787    }
2788
2789    pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2790        if context.route_chain.is_none() {
2791            let plan = crate::route_handler::collect_route_chain_plan(
2792                context.protocol,
2793                context.entry_id,
2794                context.request_path,
2795                context.route_index,
2796            );
2797            context.route_document_style_targets = plan.document_style_targets;
2798            context.route_chain = Some(plan.entries);
2799        }
2800    }
2801
2802    fn emit_active_route_styles(
2803        &self,
2804        preloaded: &mut Vec<u32>,
2805        context: &mut WebUIProcessContext,
2806    ) -> Result<()> {
2807        Self::ensure_request_route_chain(context);
2808        let Some(chain) = context.route_chain.take() else {
2809            return Err(route_style_plan_missing_error());
2810        };
2811        let document_targets = std::mem::take(&mut context.route_document_style_targets);
2812        if chain.len() != document_targets.len() {
2813            let error = route_style_plan_length_error(chain.len(), document_targets.len());
2814            context.route_document_style_targets = document_targets;
2815            context.route_chain = Some(chain);
2816            return Err(error);
2817        }
2818
2819        let result = (|| {
2820            for (entry, targets_document) in chain.iter().zip(&document_targets) {
2821                if *targets_document {
2822                    self.emit_component_style_closure(
2823                        &entry.component,
2824                        StyleClosureInstall::Static,
2825                        context,
2826                    )?;
2827                    continue;
2828                }
2829
2830                if context.css_strategy != webui_protocol::CssStrategy::Link {
2831                    continue;
2832                }
2833                self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2834            }
2835            Ok(())
2836        })();
2837
2838        context.route_document_style_targets = document_targets;
2839        context.route_chain = Some(chain);
2840        result
2841    }
2842
2843    fn emit_component_style_preloads(
2844        &self,
2845        root: &str,
2846        preloaded: &mut Vec<u32>,
2847        context: &mut WebUIProcessContext,
2848    ) -> Result<()> {
2849        let Some(closure) = context.protocol.style_closures.get(root) else {
2850            return Ok(());
2851        };
2852        // A chunk shared by several tree-local roots needs one preload.
2853        let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2854        for position in 0..unit_count {
2855            let Some(unit) =
2856                context
2857                    .protocol
2858                    .style_closure_unit(closure, &context.style_chunk_index, position)
2859            else {
2860                continue;
2861            };
2862            let Some(href) = unit.resource else {
2863                continue;
2864            };
2865            let name = unit.name;
2866            if context.document_style_resources.contains(name) {
2867                continue;
2868            }
2869            let Some(&resource_index) = context.style_resource_index.get(name) else {
2870                continue;
2871            };
2872            if preloaded.contains(&resource_index) {
2873                continue;
2874            }
2875            context
2876                .writer
2877                .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2878            context
2879                .writer
2880                .write(&crate::html_encode::encode_safe(href))?;
2881            context.writer.write("\">")?;
2882            preloaded.push(resource_index);
2883        }
2884        Ok(())
2885    }
2886
2887    fn emit_reachable_shadow_preloads(
2888        &self,
2889        preloaded: &mut Vec<u32>,
2890        context: &mut WebUIProcessContext,
2891    ) -> Result<()> {
2892        if context.css_strategy != webui_protocol::CssStrategy::Link {
2893            return Ok(());
2894        }
2895        let reachable = context.reachable_components.take().unwrap_or_else(|| {
2896            crate::route_handler::collect_reachable_component_order_for_request(
2897                context.protocol,
2898                context.entry_id,
2899                context.request_path,
2900                context.route_index,
2901            )
2902        });
2903        let result = (|| {
2904            for component in &reachable {
2905                if context.protocol.component_uses_shadow_dom(component) {
2906                    self.emit_component_style_preloads(component, preloaded, context)?;
2907                }
2908            }
2909            Ok(())
2910        })();
2911        context.reachable_components = Some(reachable);
2912        result
2913    }
2914
2915    fn process_shadow_style_signal(
2916        &self,
2917        root: &str,
2918        context: &mut WebUIProcessContext,
2919    ) -> Result<()> {
2920        let Some(root_index) = context.component_index.get(root).copied() else {
2921            return Err(HandlerError::Invariant(format!(
2922                "Shadow style hook `{root}` references an unknown component"
2923            )));
2924        };
2925        if context
2926            .shadow_style_roots
2927            .last()
2928            .map(|shadow_root| shadow_root.component_index)
2929            != Some(root_index)
2930        {
2931            return Err(HandlerError::Invariant(format!(
2932                "Shadow style hook `{root}` does not match the active component root"
2933            )));
2934        }
2935        self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2936        let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2937            HandlerError::Invariant(
2938                "active Shadow style root disappeared after its static style hook".to_string(),
2939            )
2940        })?;
2941        shadow_root.static_closure_emitted = true;
2942        Ok(())
2943    }
2944
2945    /// Emit a component's CSS module importmap on its first render
2946    /// (deduped by `rendered_components`) into the component's light DOM,
2947    /// so the browser registers it under the component's specifier
2948    /// before the shadow root template is parsed. See
2949    /// [`Self::emit_css_module_importmap`] for the emitted shape.
2950    ///
2951    /// Only components rendered on the current route get inline definitions;
2952    /// navigation responses carry later definitions in `componentStyles`.
2953    fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2954        if context.css_strategy != webui_protocol::CssStrategy::Module {
2955            return Ok(());
2956        }
2957        let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2958            streaming_template_already_sent(streaming, context.component_index, fragment_id)
2959        });
2960        if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2961            if let Some(css) = context
2962                .protocol
2963                .components
2964                .get(fragment_id)
2965                .map(|c| c.css.as_str())
2966                .filter(|s| !s.is_empty())
2967            {
2968                self.emit_css_module_importmap(fragment_id, css, context)?;
2969            }
2970        }
2971        Ok(())
2972    }
2973
2974    /// Process a route fragment — renders `<webui-route>` with matched/hidden state.
2975    fn process_route<'protocol>(
2976        &self,
2977        route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2978        best_route: &Option<(String, route_matcher::RouteMatch)>,
2979        context: &mut WebUIProcessContext<'protocol, '_, '_>,
2980    ) -> Result<()> {
2981        let is_matched = best_route
2982            .as_ref()
2983            .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2984
2985        context.writer.write("<webui-route")?;
2986        context.writer.write(" path=\"")?;
2987        context.writer.write(&route_frag.path)?;
2988        context.writer.write("\"")?;
2989        if !route_frag.fragment_id.is_empty() {
2990            context.writer.write(" component=\"")?;
2991            context.writer.write(&route_frag.fragment_id)?;
2992            context.writer.write("\"")?;
2993        }
2994        if route_frag.exact {
2995            context.writer.write(" exact")?;
2996        }
2997        route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2998
2999        if is_matched {
3000            // Emit data-ri for O(1) client-side element binding
3001            let ri = context.route_chain_index;
3002            context.route_chain_index += 1;
3003            context.writer.write(" data-ri=\"")?;
3004            write_usize(context.writer, ri)?;
3005            context.writer.write("\" active>")?;
3006
3007            let saved_route_base = best_route.as_ref().map(|(_, rm)| {
3008                let base =
3009                    route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
3010                std::mem::replace(&mut context.route_base, Cow::Owned(base))
3011            });
3012            let saved_route_children = std::mem::replace(
3013                &mut context.route_children,
3014                Cow::Borrowed(&route_frag.children),
3015            );
3016
3017            if !route_frag.content_fragment_id.is_empty() {
3018                self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
3019            }
3020
3021            if !route_frag.fragment_id.is_empty() {
3022                if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
3023                    self.emit_component_style_closure(
3024                        &route_frag.fragment_id,
3025                        StyleClosureInstall::Routed,
3026                        context,
3027                    )?;
3028                }
3029                context.writer.write("<")?;
3030                context.writer.write(&route_frag.fragment_id)?;
3031                if let Some(p) = &context.plugin {
3032                    p.write_route_component_state(context.state, context.writer)?;
3033                }
3034                write_interaction_marker(&route_frag.fragment_id, context)?;
3035                prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
3036                context.writer.write(">")?;
3037
3038                self.process_component(
3039                    &route_frag.fragment_id,
3040                    None,
3041                    ComponentHostOrigin::HandlerGenerated,
3042                    context,
3043                )?;
3044
3045                context.writer.write("</")?;
3046                context.writer.write(&route_frag.fragment_id)?;
3047                context.writer.write(">")?;
3048            }
3049            if let Some(saved) = saved_route_base {
3050                context.route_base = saved;
3051            }
3052            context.route_children = saved_route_children;
3053        } else {
3054            context.writer.write(" style=\"display:none\">")?;
3055        }
3056
3057        context.writer.write("</webui-route>")?;
3058        Ok(())
3059    }
3060
3061    /// Process a component fragment.
3062    fn process_component(
3063        &self,
3064        fragment_id: &str,
3065        target: Option<usize>,
3066        origin: ComponentHostOrigin,
3067        context: &mut WebUIProcessContext,
3068    ) -> Result<()> {
3069        if context.streaming.is_some() {
3070            consume_streaming_component_root(fragment_id, origin, context)?;
3071            // Capture only after root parity succeeds, so malformed protocols
3072            // cannot contribute unmarked hosts to a checkpoint.
3073            record_checkpoint_tag(context, fragment_id);
3074        }
3075
3076        // Emit the component's CSS module importmap into its light DOM and track
3077        // the component as rendered on first encounter only. `rendered_components`
3078        // is a set, so gating the `insert` (and its `String` clone) behind the
3079        // first-encounter check avoids allocating a throwaway `String` for every
3080        // duplicate instance while keeping the set contents identical.
3081        if !context.rendered_components.contains(fragment_id) {
3082            self.emit_css_module(fragment_id, context)?;
3083            context.rendered_components.insert(fragment_id.to_string());
3084        }
3085
3086        let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
3087        if owns_css_tree {
3088            Self::push_shadow_style_root(fragment_id, context)?;
3089        }
3090
3091        // Save parent scope. `mem::take` leaves an alloc-free empty map behind.
3092        let saved_local_vars = std::mem::take(&mut context.local_vars);
3093        let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
3094        // The component's accumulated attrs become its local vars; the next
3095        // sibling accumulates into a recycled (capacity-preserving) map from the
3096        // request-local pool instead of a freshly allocated `HashMap`.
3097        let saved_component_attrs = std::mem::replace(
3098            &mut context.component_attrs,
3099            take_scope_map(&mut context.scope_pool),
3100        );
3101        let saved_component_borrowed_attrs = std::mem::replace(
3102            &mut context.component_borrowed_attrs,
3103            take_borrowed_scope(&mut context.borrowed_scope_pool),
3104        );
3105        context.local_vars = saved_component_attrs;
3106        context.local_borrowed_vars = saved_component_borrowed_attrs;
3107        context.collecting_component_attrs = false;
3108        let saved_loop_scope = context.visible_loop_scope;
3109        context.visible_loop_scope = VisibleLoopScope {
3110            start: context.loop_vars.len(),
3111            end: context.loop_vars.len(),
3112        };
3113
3114        if let Some(p) = &mut context.plugin {
3115            p.push_scope();
3116        }
3117
3118        let render_result = self.process_fragment_target(target, fragment_id, context);
3119        context.visible_loop_scope = saved_loop_scope;
3120
3121        if owns_css_tree {
3122            Self::pop_shadow_style_root(fragment_id, context)?;
3123        }
3124        render_result?;
3125
3126        if let Some(p) = &mut context.plugin {
3127            p.pop_scope();
3128        }
3129
3130        // Restore parent scope, recycling this component's local map (its
3131        // accumulated attrs) back into the pool so a sibling reuses its capacity.
3132        let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3133        recycle_scope_map(&mut context.scope_pool, used_locals);
3134        let used_borrowed_locals =
3135            std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3136        recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3137        // The attr accumulator (pulled from the pool above) is cleared for the
3138        // next sibling while retaining its bucket capacity.
3139        context.component_attrs.clear();
3140        context.component_borrowed_attrs.clear();
3141
3142        Ok(())
3143    }
3144
3145    #[inline]
3146    pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3147        !protocol.style_closures.is_empty()
3148            && protocol
3149                .components
3150                .get(component)
3151                .is_some_and(|data| data.uses_shadow_dom)
3152    }
3153
3154    pub(crate) fn push_shadow_style_root(
3155        component: &str,
3156        context: &mut WebUIProcessContext,
3157    ) -> Result<()> {
3158        if !context.protocol.style_closures.contains_key(component) {
3159            return Err(HandlerError::Invariant(format!(
3160                "component style closure metadata is missing Shadow root `{component}`"
3161            )));
3162        }
3163        let root_index = context
3164            .component_index
3165            .get(component)
3166            .copied()
3167            .ok_or_else(|| {
3168                HandlerError::Invariant(format!(
3169                    "Shadow component `{component}` is missing its protocol index"
3170                ))
3171            })?;
3172        context.shadow_style_roots.push(ShadowStyleRoot {
3173            component_index: root_index,
3174            static_closure_emitted: false,
3175            routed_resources: Vec::new(),
3176        });
3177        Ok(())
3178    }
3179
3180    pub(crate) fn pop_shadow_style_root(
3181        component: &str,
3182        context: &mut WebUIProcessContext,
3183    ) -> Result<()> {
3184        context.shadow_style_roots.pop().ok_or_else(|| {
3185            HandlerError::Invariant(format!(
3186                "Shadow component `{component}` lost its active style root"
3187            ))
3188        })?;
3189        Ok(())
3190    }
3191
3192    /// Resolve a dotted path into owned state for values retained by the context.
3193    fn resolve_value_owned(
3194        &self,
3195        path: &str,
3196        context: &WebUIProcessContext<'_, '_, '_>,
3197    ) -> Option<Value> {
3198        resolve_value_from_sources(
3199            path,
3200            &context.loop_vars,
3201            context.visible_loop_scope,
3202            LocalValueSources {
3203                owned: &context.local_vars,
3204                borrowed: &context.local_borrowed_vars,
3205            },
3206            context.state,
3207        )
3208        .map(Cow::into_owned)
3209    }
3210
3211    /// Evaluate a condition expression against the current context.
3212    ///
3213    /// Uses a resolver closure that checks local variables first, then falls
3214    /// back to global state — avoiding a full clone of the state tree.
3215    /// Missing identifier operands are falsy before logical operators are applied.
3216    /// Missing predicate values make the complete condition false.
3217    fn evaluate_condition(
3218        &self,
3219        condition: &webui_protocol::ConditionExpr,
3220        context: &WebUIProcessContext,
3221    ) -> Result<bool> {
3222        let loop_vars = &context.loop_vars;
3223        let visible_loop_scope = context.visible_loop_scope;
3224        let local_values = LocalValueSources {
3225            owned: &context.local_vars,
3226            borrowed: &context.local_borrowed_vars,
3227        };
3228        let state = context.state;
3229        match evaluate_with_resolver(condition, |path| {
3230            resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3231        }) {
3232            Ok(result) => Ok(result),
3233            Err(ExpressionError::MissingValue(_)) => Ok(false),
3234            Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3235        }
3236    }
3237
3238    /// Process a for loop fragment.
3239    ///
3240    /// Creates a new context for each iteration that includes the current loop item.
3241    /// This allows nested templates to access both the loop variable and any parent context.
3242    /// Example: `for item in items` makes "item" available in the loop body.
3243    fn process_for_loop<'protocol, 'state>(
3244        &self,
3245        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3246        target: Option<usize>,
3247        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3248    ) -> Result<()> {
3249        if let Some(items) = resolve_borrowed_collection(
3250            &for_loop.collection,
3251            &context.loop_vars,
3252            context.visible_loop_scope,
3253            LocalValueSources {
3254                owned: &context.local_vars,
3255                borrowed: &context.local_borrowed_vars,
3256            },
3257            context.state,
3258        ) {
3259            return self.process_borrowed_for_loop(for_loop, target, items, context);
3260        }
3261        self.process_owned_for_loop(for_loop, target, context)
3262    }
3263
3264    fn process_borrowed_for_loop<'protocol, 'state>(
3265        &self,
3266        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3267        target: Option<usize>,
3268        items: &'state [Value],
3269        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3270    ) -> Result<()> {
3271        if let Some(plugin) = &mut context.plugin {
3272            plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3273        }
3274
3275        let item_name = for_loop.item.as_str();
3276        let saved_value = context.local_vars.remove(item_name);
3277        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3278        let saved_scope = context.visible_loop_scope;
3279        for (index, item) in items.iter().enumerate() {
3280            if let Some(plugin) = &mut context.plugin {
3281                plugin.on_repeat_item_start(index, context.writer)?;
3282                plugin.push_scope();
3283            }
3284
3285            context.loop_vars.push(LoopBinding {
3286                name: item_name,
3287                value: item,
3288            });
3289            context.visible_loop_scope.end = context.loop_vars.len();
3290            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3291            context.loop_vars.pop();
3292            context.visible_loop_scope = saved_scope;
3293
3294            if let Some(plugin) = &mut context.plugin {
3295                plugin.pop_scope();
3296                plugin.on_repeat_item_end(index, context.writer)?;
3297            }
3298        }
3299        if let Some(value) = saved_value {
3300            context.local_vars.insert(item_name.to_string(), value);
3301        }
3302        if let Some(value) = saved_borrowed_value {
3303            context.local_borrowed_vars.insert(item_name, value);
3304        }
3305
3306        if let Some(plugin) = &mut context.plugin {
3307            plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3308        }
3309        Ok(())
3310    }
3311
3312    fn process_owned_for_loop<'protocol, 'state>(
3313        &self,
3314        for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3315        target: Option<usize>,
3316        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3317    ) -> Result<()> {
3318        let collection_name = &for_loop.collection;
3319
3320        // If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
3321        // Hydration comments are always emitted regardless of collection presence.
3322        let items = match self.resolve_value_owned(collection_name, context) {
3323            Some(Value::Array(arr)) => arr,
3324            Some(_) => {
3325                return Err(HandlerError::TypeError(format!(
3326                    "Collection '{}' is not an array",
3327                    collection_name
3328                )))
3329            }
3330            None => Vec::new(),
3331        };
3332
3333        if let Some(p) = &mut context.plugin {
3334            p.on_for_start(&for_loop.fragment_id, context.writer)?;
3335        }
3336
3337        // Hot-loop optimisation: the loop variable name is `String`-keyed
3338        // in `local_vars`. The naive impl re-inserts (and so re-allocates
3339        // the key) on every iteration — a 1000-item loop pays 2000 String
3340        // clones for the key alone. Instead, we save the outer-scope
3341        // value (if any) ONCE before the loop, install the key ONCE with
3342        // an empty placeholder, then overwrite the value in-place each
3343        // iteration via `get_mut`. Restoration at the end happens once.
3344        let item_name = for_loop.item.as_str();
3345        let saved_value = context.local_vars.remove(item_name);
3346        let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3347        // Pre-insert the key so per-iteration `get_mut` is infallible.
3348        // Cost: at most one `String::from(item_name)` for the lifetime
3349        // of the loop, regardless of iteration count.
3350        if !items.is_empty() {
3351            context
3352                .local_vars
3353                .insert(item_name.to_string(), Value::Null);
3354        }
3355        for (i, item) in items.into_iter().enumerate() {
3356            if let Some(p) = &mut context.plugin {
3357                p.on_repeat_item_start(i, context.writer)?;
3358                p.push_scope();
3359            }
3360
3361            // O(1) value swap; no key allocation.
3362            if let Some(slot) = context.local_vars.get_mut(item_name) {
3363                *slot = item;
3364            }
3365            self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3366
3367            if let Some(p) = &mut context.plugin {
3368                p.pop_scope();
3369                p.on_repeat_item_end(i, context.writer)?;
3370            }
3371        }
3372        // Restore outer scope (or remove the placeholder we installed).
3373        match saved_value {
3374            Some(value) => {
3375                context.local_vars.insert(item_name.to_string(), value);
3376            }
3377            None => {
3378                context.local_vars.remove(item_name);
3379            }
3380        }
3381        if let Some(value) = saved_borrowed_value {
3382            context.local_borrowed_vars.insert(item_name, value);
3383        }
3384
3385        if let Some(p) = &mut context.plugin {
3386            p.on_for_end(&for_loop.fragment_id, context.writer)?;
3387        }
3388
3389        Ok(())
3390    }
3391
3392    /// Process a signal fragment.
3393    ///
3394    /// Looks up the value in the context first (for local variables), then in the global state.
3395    /// This prioritization allows local variables (like loop items) to override global state.
3396    /// If the value is not found in either scope, an empty string is returned.
3397    #[inline]
3398    fn process_signal<'data>(
3399        &self,
3400        signal: &'data webui_protocol::WebUIFragmentSignal,
3401        context: &mut WebUIProcessContext<'data, '_, '_>,
3402    ) -> Result<()> {
3403        if signal.raw {
3404            self.process_raw_signal(signal, context)
3405        } else {
3406            self.process_state_signal(signal, false, context)
3407        }
3408    }
3409
3410    #[inline(never)]
3411    fn process_raw_signal<'data>(
3412        &self,
3413        signal: &'data webui_protocol::WebUIFragmentSignal,
3414        context: &mut WebUIProcessContext<'data, '_, '_>,
3415    ) -> Result<()> {
3416        let Some(structural_value) = structural_signal_value(signal) else {
3417            return self.process_state_signal(signal, !signal.raw_text_context, context);
3418        };
3419
3420        if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3421            return self.process_shadow_style_signal(root, context);
3422        }
3423
3424        if context.streaming.is_some()
3425            && self.process_streaming_signal(structural_value, context)?
3426        {
3427            return Ok(());
3428        }
3429
3430        // Hook: emit nonce meta and Document-owned CSS before </head>.
3431        // Guarded by `head_end_emitted` so a malformed protocol cannot
3432        // emit nonce/preloads/inject more than once per render.
3433        if structural_value == "head_end" && !context.head_end_emitted {
3434            context.head_end_emitted = true;
3435            if let Some(nonce) = context.nonce {
3436                context
3437                    .writer
3438                    .write("<meta name=\"webui-nonce\" content=\"")?;
3439                context
3440                    .writer
3441                    .write(&crate::html_encode::encode_safe(nonce))?;
3442                context.writer.write("\">")?;
3443            }
3444
3445            // Render-policy CSS is emitted before component styles so an
3446            // authored declaration still wins on a tie.
3447            if !context.protocol.component_render_css.is_empty() {
3448                context.writer.write("<style data-webui-render-policy")?;
3449                if let Some(nonce) = context.nonce {
3450                    context.writer.write(" nonce=\"")?;
3451                    context
3452                        .writer
3453                        .write(&crate::html_encode::encode_safe(nonce))?;
3454                    context.writer.write("\"")?;
3455                }
3456                context.writer.write(">")?;
3457                context
3458                    .writer
3459                    .write(&context.protocol.component_render_css)?;
3460                context.writer.write("</style>")?;
3461            }
3462
3463            if !context.protocol.style_closures.is_empty() {
3464                let entry_id = context.entry_id;
3465                self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3466                let mut preloaded = Vec::new();
3467                self.emit_active_route_styles(&mut preloaded, context)?;
3468                self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3469            }
3470
3471            // Compiler-resolved `modulepreload` hints for the shared chunks
3472            // the page's module entries statically import. Those chunks are
3473            // named only inside the entry's own bytes, so without this the
3474            // browser must download and parse the entry before it can even
3475            // discover them.
3476            //
3477            // Emitted after the CSS links on purpose: CSS is render-blocking
3478            // and owns first paint, while these own first interaction, so the
3479            // stylesheet requests go out first. The list arrives pre-ordered
3480            // (largest chunk first) and pre-resolved from the build, so this
3481            // is a straight write with no per-request work.
3482            if !context.protocol.module_preloads.is_empty() {
3483                for href in &context.protocol.module_preloads {
3484                    context
3485                        .writer
3486                        .write("<link rel=\"modulepreload\" href=\"")?;
3487                    context.writer.write(href)?;
3488                    context.writer.write("\">")?;
3489                }
3490            }
3491
3492            write_component_asset_styles(context)?;
3493
3494            // Per-render `head_inject` HTML — image preloads, A/B test
3495            // markers, etc. supplied by the host via RenderOptions.
3496            // Emitted at the structural head_end boundary, after the
3497            // built-in nonce + CSS-link emissions, so host injects
3498            // appear immediately before `</head>`.
3499            if let Some(html) = context.head_inject {
3500                context.writer.write(html)?;
3501            }
3502
3503            // Reserved-state `headEnd` HTML, last at this boundary so a
3504            // host that sets both channels gets a deterministic order:
3505            // built-in emissions, then `RenderOptions`, then state.
3506            if let Some(html) = context.state_inject.head_end {
3507                context.writer.write(html)?;
3508            }
3509        }
3510
3511        // Hook: emit state-supplied HTML immediately after `<body>`. Guarded
3512        // by its own dedup flag so a malformed protocol cannot duplicate it.
3513        if structural_value == "body_start" && !context.body_start_emitted {
3514            context.body_start_emitted = true;
3515            write_component_asset_styles(context)?;
3516            if let Some(html) = context.state_inject.body_start {
3517                context.writer.write(html)?;
3518            }
3519        }
3520
3521        // Hook: emit component templates and host body_inject before </body>.
3522        // Single guarded block so the dedup flag protects both the
3523        // hydration emission and the host inject from a malformed
3524        // protocol that fires `body_end` more than once per render.
3525        if structural_value == "body_end" && !context.body_end_emitted {
3526            context.body_end_emitted = true;
3527            if context.plugin.is_some() {
3528                // Emit templates for all REACHABLE components on the current route,
3529                // not just those rendered in this SSR pass. Components inside false
3530                // <if> blocks or empty <for> loops are reachable via client-side
3531                // state changes and need their templates available without a server
3532                // round-trip. The graph walker follows conditional and loop branches
3533                // unconditionally, but only descends into the matched route chain —
3534                // components on other routes are delivered via SPA partial navigation.
3535                // Kept as the traversal-ordered `Vec` produced upstream (already
3536                // deduplicated) rather than collected into a `HashSet`: downstream
3537                // `<head>` CSS `<link>`/style-module emission order must stay
3538                // deterministic across renders, not vary with the process's
3539                // randomized hash seed.
3540                let reachable = context.reachable_components.take().unwrap_or_else(|| {
3541                    crate::route_handler::collect_reachable_component_order_for_request(
3542                        context.protocol,
3543                        context.entry_id,
3544                        context.request_path,
3545                        context.route_index,
3546                    )
3547                });
3548                let state_selection =
3549                    collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3550
3551                // Emit CSS module importmaps for reachable-but-unrendered
3552                // components so the framework can adopt them when an `<if>`
3553                // condition flips true client-side.
3554                if context.css_strategy == webui_protocol::CssStrategy::Module {
3555                    for name in &reachable {
3556                        if !context.rendered_components.contains(name) {
3557                            if let Some(css) = context
3558                                .protocol
3559                                .components
3560                                .get(name)
3561                                .map(|c| c.css.as_str())
3562                                .filter(|s| !s.is_empty())
3563                            {
3564                                self.emit_css_module_importmap(name, css, context)?;
3565                            }
3566                        }
3567                    }
3568                }
3569
3570                // Try to collect split WebUI template payloads. If the plugin
3571                // returns None (non-WebUI templates, e.g. FAST), fall back to
3572                // separate emission.
3573                let template_payloads = context
3574                    .plugin
3575                    .as_ref()
3576                    .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3577
3578                if template_payloads.is_none() {
3579                    // Non-JS templates (FAST plugins) - emit separately
3580                    if let Some(ref p) = context.plugin {
3581                        p.emit_templates(
3582                            context.protocol,
3583                            &reachable,
3584                            context.nonce,
3585                            context.writer,
3586                        )?;
3587                    }
3588                }
3589
3590                // Compute the inventory hex from actually rendered components.
3591                let inventory_hex = crate::route_handler::encode_component_inventory(
3592                    &context.rendered_components,
3593                    context.component_index,
3594                );
3595
3596                // Chain
3597                Self::ensure_request_route_chain(context);
3598                let chain = context.route_chain.as_deref().ok_or_else(|| {
3599                    HandlerError::Invariant(
3600                        "request route chain disappeared after collection".to_string(),
3601                    )
3602                })?;
3603                let chain_json: Vec<Value> = chain
3604                    .iter()
3605                    .map(crate::route_handler::RouteChainEntry::to_json)
3606                    .collect();
3607
3608                // CSS hrefs emitted during SSR (Link-strategy components)
3609                let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3610                let mut css_hrefs: Vec<&str> = Vec::new();
3611                if is_link {
3612                    for name in &reachable {
3613                        if let Some(href) = context
3614                            .protocol
3615                            .components
3616                            .get(name)
3617                            .map(|c| c.css_href.as_str())
3618                            .filter(|h| !h.is_empty())
3619                        {
3620                            css_hrefs.push(href);
3621                        }
3622                    }
3623                }
3624
3625                // Module style specifiers emitted during SSR
3626                let mut style_specs: Vec<&str> = Vec::new();
3627                if context.css_strategy == webui_protocol::CssStrategy::Module {
3628                    for name in &reachable {
3629                        if context
3630                            .protocol
3631                            .components
3632                            .get(name)
3633                            .map(|c| !c.css.is_empty())
3634                            .unwrap_or(false)
3635                        {
3636                            style_specs.push(name);
3637                        }
3638                    }
3639                }
3640
3641                let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3642                let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3643                let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3644                style_roots.push(context.entry_id);
3645                for entry in chain {
3646                    style_roots.push(entry.component.as_str());
3647                    if !entry.pending_component.is_empty() {
3648                        style_roots.push(entry.pending_component.as_str());
3649                    }
3650                    if !entry.error_component.is_empty() {
3651                        style_roots.push(entry.error_component.as_str());
3652                    }
3653                }
3654                style_roots.extend(reachable.iter().map(String::as_str));
3655                let component_styles =
3656                    crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3657                write_webui_data_block(
3658                    context.writer,
3659                    &mut context.json_scratch,
3660                    WebUiBootstrap {
3661                        declaration_id: None,
3662                        enclosing_span_instance_id: None,
3663                        state: WebUiBootstrapState::Complete {
3664                            value: context.state,
3665                            selection: state_selection,
3666                        },
3667                        chain: &chain_json,
3668                        inventory: &inventory_hex,
3669                        nonce: context.nonce,
3670                        css_hrefs: &css_hrefs,
3671                        style_specs: &style_specs,
3672                        component_styles: &component_styles,
3673                        templates: payloads,
3674                    },
3675                )?;
3676
3677                // Let the active plugin emit any framework-specific executable
3678                // side channel. FAST plugins default to no-op; WebUI installs
3679                // templateFns. Client packages parse #webui-data lazily.
3680                if let Some(ref plugin) = context.plugin {
3681                    plugin.emit_bootstrap_extension(
3682                        BootstrapExtensionContext {
3683                            protocol: context.protocol,
3684                            components: &reachable,
3685                            payloads,
3686                            nonce: context.nonce,
3687                        },
3688                        context.writer,
3689                    )?;
3690                }
3691            }
3692
3693            // Per-render `body_inject` HTML — dev livereload script,
3694            // analytics, etc. supplied by the host via RenderOptions.
3695            // Inside the dedup block but outside the plugin-only
3696            // sub-block above, so it fires regardless of whether a
3697            // hydration plugin is active. Appears immediately before
3698            // `</body>`.
3699            if let Some(html) = context.body_inject {
3700                context.writer.write(html)?;
3701            }
3702
3703            // Reserved-state `bodyEnd` HTML, last at this boundary. Same
3704            // precedence as `head_end`: built-ins, `RenderOptions`, state.
3705            if let Some(html) = context.state_inject.body_end {
3706                context.writer.write(html)?;
3707            }
3708        }
3709
3710        // Structural signals are never state lookups. In particular, ordinary
3711        // rendering ignores boundary/root markers byte-for-byte, while authored
3712        // raw bindings with the same visible key remain ordinary state.
3713        Ok(())
3714    }
3715
3716    /// Process a plain (non-structural) signal by resolving its value and
3717    /// writing it to the response, HTML-encoded unless `signal.raw` is set.
3718    ///
3719    /// `owns_html_range` only controls whether the active plugin emits
3720    /// replaceable sibling markers around the value (`on_binding_start/end`);
3721    /// it never affects escaping. In particular, `signal.raw_text_context`
3722    /// (marker ownership inside `<style>`/`<script>`/etc.) is independent of
3723    /// `signal.raw` (escaping): a signal can be marker-free *and* HTML-encoded
3724    /// at the same time. See `WebUIFragment::raw_text_signal` for why an
3725    /// escaped binding inside an HTML raw-text element is an authoring
3726    /// footgun rather than something this function can safely correct.
3727    #[inline]
3728    fn process_state_signal(
3729        &self,
3730        signal: &webui_protocol::WebUIFragmentSignal,
3731        owns_html_range: bool,
3732        context: &mut WebUIProcessContext,
3733    ) -> Result<()> {
3734        if let Some(p) = &mut context.plugin {
3735            p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3736        }
3737
3738        if let Some(value) = resolve_value_from_sources(
3739            &signal.value,
3740            &context.loop_vars,
3741            context.visible_loop_scope,
3742            LocalValueSources {
3743                owned: &context.local_vars,
3744                borrowed: &context.local_borrowed_vars,
3745            },
3746            context.state,
3747        ) {
3748            self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3749        }
3750
3751        if let Some(p) = &mut context.plugin {
3752            p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3753        }
3754        Ok(())
3755    }
3756
3757    /// Write a signal value directly to the writer, avoiding intermediate String allocation.
3758    /// For HTML-escaped output, writes the Cow from `encode_safe` directly.
3759    ///
3760    /// `raw` here is purely the authored escaping choice (`{{value}}` vs.
3761    /// `{{{value}}}`) and is applied uniformly regardless of surrounding HTML
3762    /// context: it does not know whether it is writing into a raw-text
3763    /// element (`<script>`, `<style>`, `<xmp>`, which never decode character
3764    /// references) or an RCDATA element (`<title>`, `<textarea>`, which do).
3765    fn write_signal_value(
3766        &self,
3767        value: &Value,
3768        raw: bool,
3769        writer: &mut dyn ResponseWriter,
3770    ) -> Result<()> {
3771        // Numbers, booleans, and null render the same escaped or not, and never
3772        // need a heap buffer to reach the writer.
3773        if write_plain_json_scalar(writer, value)? {
3774            return Ok(());
3775        }
3776        if raw {
3777            match value {
3778                Value::String(s) => writer.write(s),
3779                _ => writer.write(&value.to_string()),
3780            }
3781        } else {
3782            match value {
3783                Value::String(s) => writer.write_escaped(s),
3784                _ => {
3785                    let s = value.to_string();
3786                    writer.write_escaped(&s)
3787                }
3788            }
3789        }
3790    }
3791
3792    /// Process an if condition fragment.
3793    fn process_if(
3794        &self,
3795        if_cond: &webui_protocol::WebUIFragmentIf,
3796        target: Option<usize>,
3797        context: &mut WebUIProcessContext,
3798    ) -> Result<()> {
3799        let condition = if_cond
3800            .condition
3801            .as_ref()
3802            .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3803        let condition_met = self.evaluate_condition(condition, context)?;
3804
3805        if let Some(p) = &mut context.plugin {
3806            p.on_if_start(&if_cond.fragment_id, context.writer)?;
3807        }
3808
3809        if condition_met {
3810            if let Some(p) = &mut context.plugin {
3811                p.push_scope();
3812            }
3813
3814            self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3815
3816            if let Some(p) = &mut context.plugin {
3817                p.pop_scope();
3818            }
3819        }
3820
3821        if let Some(p) = &mut context.plugin {
3822            p.on_if_end(&if_cond.fragment_id, context.writer)?;
3823        }
3824
3825        Ok(())
3826    }
3827
3828    /// Process an attribute fragment by rendering the attribute name/value pair.
3829    ///
3830    /// `template_target` and `component_name` are prepared once per protocol by
3831    /// [`RenderFragmentIndex`], so neither the template fragment lookup nor the
3832    /// camelCase prop-name conversion is repeated per render.
3833    fn process_attribute<'protocol, 'state>(
3834        &self,
3835        attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3836        template_target: Option<usize>,
3837        component_name: Option<&'protocol str>,
3838        context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3839    ) -> Result<()> {
3840        // Initialize component attribute accumulator on attrStart. Clearing the
3841        // pooled map keeps its bucket capacity instead of allocating a fresh one.
3842        if attr.attr_start {
3843            context.component_attrs.clear();
3844            context.component_borrowed_attrs.clear();
3845            context.collecting_component_attrs = true;
3846        }
3847
3848        // Boolean attribute with condition tree
3849        if let Some(condition) = &attr.condition_tree {
3850            let condition_met = self.evaluate_condition(condition, context)?;
3851
3852            if context.collecting_component_attrs && !attr.attr_skip {
3853                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3854                context.component_borrowed_attrs.remove(name);
3855                context
3856                    .component_attrs
3857                    .insert(name.to_owned(), Value::Bool(condition_met));
3858            }
3859
3860            if condition_met {
3861                context.writer.write(" ")?;
3862                context.writer.write(&attr.name)?;
3863            }
3864            return Ok(());
3865        }
3866
3867        // Template attribute (mixed static + dynamic)
3868        if !attr.template.is_empty() {
3869            if !context.collecting_component_attrs || attr.attr_skip {
3870                return self.write_template_attribute(attr, template_target, context);
3871            }
3872            let raw_value =
3873                self.render_template_attr_value(&attr.template, template_target, context, None)?;
3874            context
3875                .writer
3876                .write_escaped_attribute(&attr.name, &raw_value)?;
3877
3878            if context.collecting_component_attrs && !attr.attr_skip {
3879                let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3880                context.component_borrowed_attrs.remove(name);
3881                context
3882                    .component_attrs
3883                    .insert(name.to_owned(), Value::String(raw_value));
3884            }
3885            return Ok(());
3886        }
3887
3888        // Simple attribute
3889        if attr.raw_value || !attr.value.is_empty() {
3890            if attr.raw_value {
3891                // Static attribute — value is the literal string
3892                context
3893                    .writer
3894                    .write_escaped_attribute(&attr.name, &attr.value)?;
3895                if context.collecting_component_attrs && !attr.attr_skip {
3896                    let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3897                    context.component_borrowed_attrs.remove(name);
3898                    context
3899                        .component_attrs
3900                        .insert(name.to_owned(), Value::String(attr.value.clone()));
3901                }
3902            } else if attr.complex {
3903                // Complex attribute — resolve value, don't render to HTML, store as state
3904                if context.collecting_component_attrs && !attr.attr_skip {
3905                    // Streaming starts borrowed too. The continuation VM
3906                    // materializes these values only when the target component
3907                    // can actually suspend; boundary-free components finish in
3908                    // this call and retain the ordinary zero-copy path.
3909                    let state_backed_value = resolve_state_backed_value(
3910                        &attr.value,
3911                        &context.loop_vars,
3912                        context.visible_loop_scope,
3913                        LocalValueSources {
3914                            owned: &context.local_vars,
3915                            borrowed: &context.local_borrowed_vars,
3916                        },
3917                        context.state,
3918                    );
3919                    if let Some(value) = state_backed_value {
3920                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3921                        remove_owned_component_attr(&mut context.component_attrs, name);
3922                        context.component_borrowed_attrs.insert(name, value);
3923                    } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3924                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3925                        context.component_borrowed_attrs.remove(name);
3926                        context.component_attrs.insert(name.to_owned(), value);
3927                    }
3928                }
3929            } else {
3930                // Dynamic attribute — resolve and render
3931                // As above, a boundary-bearing continuation materializes a
3932                // borrowed component scope before it can escape this call.
3933                let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3934                    resolve_state_backed_value(
3935                        &attr.value,
3936                        &context.loop_vars,
3937                        context.visible_loop_scope,
3938                        LocalValueSources {
3939                            owned: &context.local_vars,
3940                            borrowed: &context.local_borrowed_vars,
3941                        },
3942                        context.state,
3943                    )
3944                } else {
3945                    None
3946                };
3947                // Reuse the immutable-state lookup for both HTML output and the
3948                // child scope instead of resolving every component attribute twice.
3949                let value = match state_backed_value {
3950                    Some(value) => Some(Cow::Borrowed(value)),
3951                    None => resolve_value_from_sources(
3952                        &attr.value,
3953                        &context.loop_vars,
3954                        context.visible_loop_scope,
3955                        LocalValueSources {
3956                            owned: &context.local_vars,
3957                            borrowed: &context.local_borrowed_vars,
3958                        },
3959                        context.state,
3960                    ),
3961                };
3962                match value.as_deref() {
3963                    Some(Value::String(s)) => {
3964                        context.writer.write_escaped_attribute(&attr.name, s)?;
3965                    }
3966                    Some(Value::Null) | None => {}
3967                    Some(other) => {
3968                        let mut scalar = ScalarBuffer::new();
3969                        match format_plain_json_scalar(&mut scalar, other) {
3970                            Some(rendered) => {
3971                                write_attr(context.writer, &attr.name, rendered)?;
3972                            }
3973                            None => {
3974                                let s = other.to_string();
3975                                context.writer.write_escaped_attribute(&attr.name, &s)?;
3976                            }
3977                        }
3978                    }
3979                }
3980
3981                if context.collecting_component_attrs && !attr.attr_skip {
3982                    if let Some(borrowed) = state_backed_value {
3983                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3984                        remove_owned_component_attr(&mut context.component_attrs, name);
3985                        context.component_borrowed_attrs.insert(name, borrowed);
3986                    } else {
3987                        let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3988                        context.component_borrowed_attrs.remove(name);
3989                        context.component_attrs.insert(
3990                            name.to_owned(),
3991                            value
3992                                .map(Cow::into_owned)
3993                                .unwrap_or(Value::String(String::new())),
3994                        );
3995                    }
3996                }
3997            }
3998        }
3999
4000        Ok(())
4001    }
4002
4003    fn write_template_attribute(
4004        &self,
4005        attr: &webui_protocol::WebUIFragmentAttribute,
4006        target: Option<usize>,
4007        context: &mut WebUIProcessContext,
4008    ) -> Result<()> {
4009        let mut scratch = std::mem::take(&mut context.json_scratch);
4010        let previous_capacity = scratch.capacity();
4011        scratch.clear();
4012        if scratch.capacity() < TEMPLATE_SIGNAL_CAPACITY_HINT * 2 {
4013            scratch.reserve(TEMPLATE_SIGNAL_CAPACITY_HINT * 2);
4014        }
4015        let buffer = String::from_utf8(scratch).map_err(invalid_template_buffer_error)?;
4016        let value =
4017            self.render_template_attr_value(&attr.template, target, context, Some(buffer))?;
4018        let result = context.writer.write_escaped_attribute(&attr.name, &value);
4019        context.json_scratch = recycle_template_buffer(value, previous_capacity);
4020        result
4021    }
4022
4023    /// Render a template attribute's fragments into a raw (unescaped) string.
4024    fn render_template_attr_value(
4025        &self,
4026        template_id: &str,
4027        target: Option<usize>,
4028        context: &WebUIProcessContext,
4029        buffer: Option<String>,
4030    ) -> Result<String> {
4031        let fragments = template_attr_fragments(template_id, target, &context.render_fragments)?;
4032        let mut raw_value = buffer
4033            .unwrap_or_else(|| String::with_capacity(template_attr_capacity(fragments.fragments)));
4034        raw_value.clear();
4035        for frag in fragments.fragments {
4036            match frag.fragment.as_ref() {
4037                Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4038                Some(Fragment::Signal(signal)) => {
4039                    if let Some(value) = resolve_value_from_sources(
4040                        &signal.value,
4041                        &context.loop_vars,
4042                        context.visible_loop_scope,
4043                        LocalValueSources {
4044                            owned: &context.local_vars,
4045                            borrowed: &context.local_borrowed_vars,
4046                        },
4047                        context.state,
4048                    ) {
4049                        match value.as_ref() {
4050                            Value::String(s) => raw_value.push_str(s),
4051                            other => {
4052                                let mut scalar = ScalarBuffer::new();
4053                                match format_plain_json_scalar(&mut scalar, other) {
4054                                    Some(rendered) => raw_value.push_str(rendered),
4055                                    None => raw_value.push_str(&other.to_string()),
4056                                }
4057                            }
4058                        }
4059                    }
4060                }
4061                _ => {}
4062            }
4063        }
4064        Ok(raw_value)
4065    }
4066
4067    /// Render the UI based on the protocol and state.
4068    pub fn render<'a>(
4069        &self,
4070        protocol: &'a Protocol,
4071        state: &'a Value,
4072        options: &RenderOptions<'a>,
4073        writer: &'a mut dyn ResponseWriter,
4074    ) -> Result<()> {
4075        protocol.ensure_style_metadata()?;
4076        let document = protocol.protocol();
4077        let render_fragments = protocol.render_fragments().resolve(document);
4078        let Some(entry_index) = render_fragments.index(options.entry_id) else {
4079            return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4080        };
4081        let entry = render_fragments
4082            .list(entry_index)
4083            .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4084        let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4085        let has_document_head_boundary = !entry_owns_css_tree
4086            && !document.style_closures.is_empty()
4087            && entry.fragments.iter().any(|fragment| {
4088                matches!(
4089                    fragment.fragment.as_ref(),
4090                    Some(Fragment::Signal(signal))
4091                        if structural_signal_value(signal) == Some("head_end")
4092                )
4093            });
4094        let doctype_split = (!entry_owns_css_tree
4095            && !has_document_head_boundary
4096            && !document.style_closures.is_empty())
4097        .then(|| {
4098            let Some(Fragment::Raw(raw)) = entry
4099                .fragments
4100                .first()
4101                .and_then(|fragment| fragment.fragment.as_ref())
4102            else {
4103                return None;
4104            };
4105            doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4106        })
4107        .flatten();
4108        let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4109        let mut context = WebUIProcessContext {
4110            protocol: document,
4111            render_fragments,
4112            component_asset_style_manifest,
4113            component_asset_style_links: protocol.component_asset_style_links(),
4114            state,
4115            writer,
4116            local_vars: ScopeMap::default(),
4117            local_borrowed_vars: BorrowedScope::default(),
4118            loop_vars: Vec::new(),
4119            visible_loop_scope: VisibleLoopScope::EMPTY,
4120            component_attrs: ScopeMap::default(),
4121            component_borrowed_attrs: BorrowedScope::default(),
4122            collecting_component_attrs: false,
4123            request_path: options.request_path,
4124            route_base: Cow::Borrowed("/"),
4125            rendered_components: HashSet::new(),
4126            plugin: self.plugin_factory.map(|f| f()),
4127            route_children: Cow::Borrowed(&[]),
4128            entry_id: options.entry_id,
4129            // Same defensive normalisation as `handle()`. See the
4130            // doc-comment there for the CSP-outage rationale.
4131            nonce: options.nonce.filter(|s| !s.is_empty()),
4132            head_inject: options.head_inject.filter(|s| !s.is_empty()),
4133            body_inject: options.body_inject.filter(|s| !s.is_empty()),
4134            state_inject: StateInject::resolve(state),
4135            head_end_emitted: false,
4136            body_start_emitted: false,
4137            component_asset_styles_emitted: false,
4138            component_index: protocol.component_index(),
4139            style_resource_index: protocol.style_resource_index(),
4140            style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4141            style_chunk_index: protocol.protocol().style_chunk_index(),
4142            css_strategy: protocol.css_strategy(),
4143            body_end_emitted: false,
4144            route_index: protocol.route_index(),
4145            route_chain_index: 0,
4146            route_chain: None,
4147            route_document_style_targets: Vec::new(),
4148            reachable_components: None,
4149            streaming: None,
4150            json_scratch: Vec::new(),
4151            scope_pool: Vec::new(),
4152            document_style_resources: HashSet::new(),
4153            shadow_style_roots: Vec::new(),
4154            borrowed_scope_pool: Vec::new(),
4155        };
4156
4157        if entry_owns_css_tree {
4158            Self::push_shadow_style_root(options.entry_id, &mut context)?;
4159        }
4160
4161        let render_result = if let Some((first_raw, split)) = doctype_split {
4162            context.writer.write(&first_raw[..split])?;
4163            self.emit_component_style_closure(
4164                options.entry_id,
4165                StyleClosureInstall::Static,
4166                &mut context,
4167            )?;
4168            self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4169            context.writer.write(&first_raw[split..])?;
4170            self.process_fragment_from(entry, 1, &mut context)
4171        } else {
4172            if !entry_owns_css_tree
4173                && !has_document_head_boundary
4174                && !document.style_closures.is_empty()
4175            {
4176                self.emit_component_style_closure(
4177                    options.entry_id,
4178                    StyleClosureInstall::Static,
4179                    &mut context,
4180                )?;
4181                self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4182            }
4183            self.process_fragment(entry, &mut context)
4184        };
4185
4186        if entry_owns_css_tree {
4187            Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4188        }
4189        render_result?;
4190        writer.end()?;
4191
4192        Ok(())
4193    }
4194}
4195
4196impl Default for WebUIHandler {
4197    fn default() -> Self {
4198        Self::new()
4199    }
4200}
4201
4202/// Bytes reserved for each signal when pre-sizing a template attribute value.
4203/// Signals in attribute templates are typically ids, numbers, or path segments.
4204const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4205const MAX_REUSED_TEMPLATE_CAPACITY: usize = 4 * 1024;
4206
4207#[cold]
4208#[inline(never)]
4209fn invalid_template_buffer_error(error: std::string::FromUtf8Error) -> HandlerError {
4210    HandlerError::Invariant(format!(
4211        "cleared template output buffer is not valid UTF-8: {}",
4212        error.utf8_error()
4213    ))
4214}
4215
4216fn recycle_template_buffer(value: String, previous_capacity: usize) -> Vec<u8> {
4217    if value.capacity() <= MAX_REUSED_TEMPLATE_CAPACITY || value.capacity() <= previous_capacity {
4218        value.into_bytes()
4219    } else {
4220        Vec::new()
4221    }
4222}
4223
4224fn template_attr_fragments<'protocol>(
4225    template_id: &str,
4226    target: Option<usize>,
4227    index: &ResolvedRenderFragmentIndex<'protocol>,
4228) -> Result<RenderFragmentList<'protocol>> {
4229    target
4230        .or_else(|| index.index(template_id))
4231        .and_then(|slot| index.list(slot))
4232        .ok_or_else(|| missing_attribute_template_error(template_id))
4233}
4234
4235#[cold]
4236#[inline(never)]
4237fn missing_attribute_template_error(template_id: &str) -> HandlerError {
4238    HandlerError::MissingFragment(template_id.to_owned())
4239}
4240
4241// Owned component props retain the raw attribute value. Size its buffer from
4242// the static text and a per-signal estimate to avoid repeated growth.
4243fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4244    fragments
4245        .iter()
4246        .map(|frag| match frag.fragment.as_ref() {
4247            Some(Fragment::Raw(raw)) => raw.value.len(),
4248            Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4249            _ => 0,
4250        })
4251        .sum()
4252}
4253
4254/// Write ` name="value"` to the writer without allocating a format string.
4255fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4256    writer.write_attribute(name, value)
4257}
4258
4259#[cfg(test)]
4260fn handle(
4261    protocol: &WebUIProtocol,
4262    state: &Value,
4263    options: &RenderOptions<'_>,
4264    writer: &mut dyn ResponseWriter,
4265) -> Result<()> {
4266    let handler = WebUIHandler::new();
4267    handler.handle(protocol, state, options, writer)
4268}
4269
4270#[cfg(test)]
4271mod scalar_buffer_tests {
4272    use super::*;
4273
4274    /// Every scalar the stack formatter claims must serialize byte-identically
4275    /// to the `Value::to_string` path it replaced.
4276    #[test]
4277    fn plain_scalars_match_value_to_string() {
4278        let cases = [
4279            Value::Null,
4280            Value::Bool(true),
4281            Value::Bool(false),
4282            serde_json::json!(0),
4283            serde_json::json!(-1),
4284            serde_json::json!(42),
4285            serde_json::json!(i64::MIN),
4286            serde_json::json!(i64::MAX),
4287            serde_json::json!(u64::MAX),
4288            serde_json::json!(0.5),
4289            serde_json::json!(-0.0_f64),
4290            serde_json::json!(1.7976931348623157e308_f64),
4291            serde_json::json!(-1.7976931348623157e308_f64),
4292            serde_json::json!(f64::MIN_POSITIVE),
4293        ];
4294        for value in cases {
4295            let mut buffer = ScalarBuffer::new();
4296            let rendered = format_plain_json_scalar(&mut buffer, &value);
4297            assert_eq!(
4298                rendered,
4299                Some(value.to_string().as_str()),
4300                "scalar rendering diverged for {value:?}"
4301            );
4302        }
4303    }
4304
4305    /// Strings, arrays, and objects must fall through to the caller's escaping
4306    /// fallback rather than being emitted unescaped.
4307    #[test]
4308    fn non_plain_values_are_declined() {
4309        let cases = [
4310            serde_json::json!("<script>"),
4311            serde_json::json!([1, 2]),
4312            serde_json::json!({ "a": 1 }),
4313        ];
4314        for value in cases {
4315            let mut buffer = ScalarBuffer::new();
4316            assert_eq!(
4317                format_plain_json_scalar(&mut buffer, &value),
4318                None,
4319                "expected fallback for {value:?}"
4320            );
4321        }
4322    }
4323
4324    /// Overflow must be reported, never silently truncated into bad output.
4325    #[test]
4326    fn overflow_is_reported_instead_of_truncating() {
4327        let mut buffer = ScalarBuffer::new();
4328        assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4329    }
4330}
4331
4332#[cfg(test)]
4333mod tests {
4334    use super::*;
4335    use crate::streaming::STREAMING_MARKER;
4336    use std::cell::RefCell;
4337    use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4338    use webui_protocol::{
4339        web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4340        WebUIFragmentAttribute, WebUiFragmentRoute,
4341    };
4342    use webui_test_utils::test_json;
4343
4344    fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4345        WebUIFragment::signal(
4346            format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4347            true,
4348        )
4349    }
4350
4351    #[test]
4352    fn route_descent_borrows_protocol_children() {
4353        let routes = vec![WebUiFragmentRoute {
4354            children: vec![WebUiFragmentRoute {
4355                fragment_id: "child".to_string(),
4356                ..Default::default()
4357            }],
4358            ..Default::default()
4359        }];
4360        let mut level = RouteChildren::Borrowed(&routes);
4361
4362        let descended = descend_into(&mut level, 0);
4363
4364        let Cow::Borrowed(children) = descended else {
4365            panic!("protocol-owned route children should remain borrowed");
4366        };
4367        assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4368    }
4369
4370    #[test]
4371    fn route_descent_moves_parked_children() {
4372        let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4373            children: vec![WebUiFragmentRoute {
4374                fragment_id: "child".to_string(),
4375                ..Default::default()
4376            }],
4377            ..Default::default()
4378        }]);
4379
4380        let descended = descend_into(&mut level, 0);
4381
4382        let Cow::Owned(children) = descended else {
4383            panic!("parked route children must not borrow session-owned storage");
4384        };
4385        assert_eq!(children[0].fragment_id, "child");
4386        let Cow::Owned(source) = level else {
4387            panic!("the parked source level must stay owned");
4388        };
4389        assert!(
4390            source[0].children.is_empty(),
4391            "owned route children must move out instead of being cloned"
4392        );
4393    }
4394
4395    #[test]
4396    fn borrowed_collection_reuses_state_array() {
4397        let state = test_json!({
4398            "items": [
4399                {"name": "first"},
4400                {"name": "second"}
4401            ]
4402        });
4403        let local_vars = ScopeMap::default();
4404        let local_borrowed_vars = BorrowedScope::default();
4405        let loop_vars = Vec::new();
4406        let items = resolve_borrowed_collection(
4407            "items",
4408            &loop_vars,
4409            VisibleLoopScope::EMPTY,
4410            LocalValueSources {
4411                owned: &local_vars,
4412                borrowed: &local_borrowed_vars,
4413            },
4414            &state,
4415        )
4416        .unwrap_or_else(|| panic!("state array should be borrowed"));
4417        let state_items = state["items"]
4418            .as_array()
4419            .unwrap_or_else(|| panic!("fixture items should be an array"));
4420        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4421    }
4422
4423    #[test]
4424    fn owned_local_collection_keeps_precedence() {
4425        let state = test_json!({"items": [{"name": "global"}]});
4426        let local_vars =
4427            ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4428        let local_borrowed_vars = BorrowedScope::default();
4429        assert!(resolve_borrowed_collection(
4430            "items",
4431            &[],
4432            VisibleLoopScope::EMPTY,
4433            LocalValueSources {
4434                owned: &local_vars,
4435                borrowed: &local_borrowed_vars,
4436            },
4437            &state,
4438        )
4439        .is_none());
4440    }
4441
4442    #[test]
4443    fn borrowed_component_collection_reuses_state_array() {
4444        let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4445        let contacts = &state["teams"][0]["contacts"];
4446        let mut local_borrowed_vars = BorrowedScope::default();
4447        local_borrowed_vars.insert("contacts", contacts);
4448        let local_vars = ScopeMap::default();
4449        let items = resolve_borrowed_collection(
4450            "contacts",
4451            &[],
4452            VisibleLoopScope::EMPTY,
4453            LocalValueSources {
4454                owned: &local_vars,
4455                borrowed: &local_borrowed_vars,
4456            },
4457            &state,
4458        )
4459        .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4460        let state_items = contacts
4461            .as_array()
4462            .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4463        assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4464    }
4465
4466    #[test]
4467    fn nested_borrowed_collection_reuses_child_array() {
4468        let state = test_json!({
4469            "items": [{
4470                "children": [
4471                    {"name": "first"},
4472                    {"name": "second"}
4473                ]
4474            }]
4475        });
4476        let item = &state["items"][0];
4477        let loop_vars = vec![LoopBinding {
4478            name: "item",
4479            value: item,
4480        }];
4481        let local_vars = ScopeMap::default();
4482        let local_borrowed_vars = BorrowedScope::default();
4483        let children = resolve_borrowed_collection(
4484            "item.children",
4485            &loop_vars,
4486            VisibleLoopScope { start: 0, end: 1 },
4487            LocalValueSources {
4488                owned: &local_vars,
4489                borrowed: &local_borrowed_vars,
4490            },
4491            &state,
4492        )
4493        .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4494        let state_children = item["children"]
4495            .as_array()
4496            .unwrap_or_else(|| panic!("fixture children should be an array"));
4497        assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4498    }
4499
4500    #[test]
4501    fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4502        let state = test_json!({
4503            "item": {"name": "global", "fallback": "global fallback"},
4504            "outer": {"name": "outer"},
4505            "inner": {"name": "inner"}
4506        });
4507        let loop_vars = vec![
4508            LoopBinding {
4509                name: "item",
4510                value: &state["outer"],
4511            },
4512            LoopBinding {
4513                name: "item",
4514                value: &state["inner"],
4515            },
4516        ];
4517        let local_vars = ScopeMap::default();
4518        let local_borrowed_vars = BorrowedScope::default();
4519        let sources = LocalValueSources {
4520            owned: &local_vars,
4521            borrowed: &local_borrowed_vars,
4522        };
4523
4524        assert_eq!(
4525            resolve_value_from_sources(
4526                "item.name",
4527                &loop_vars,
4528                VisibleLoopScope { start: 0, end: 2 },
4529                sources,
4530                &state,
4531            )
4532            .as_deref()
4533            .and_then(Value::as_str),
4534            Some("inner")
4535        );
4536        assert_eq!(
4537            resolve_value_from_sources(
4538                "item.fallback",
4539                &loop_vars,
4540                VisibleLoopScope { start: 0, end: 2 },
4541                sources,
4542                &state,
4543            )
4544            .as_deref()
4545            .and_then(Value::as_str),
4546            Some("global fallback")
4547        );
4548        assert_eq!(
4549            resolve_value_from_sources(
4550                "item.name",
4551                &loop_vars,
4552                VisibleLoopScope { start: 2, end: 2 },
4553                sources,
4554                &state,
4555            )
4556            .as_deref()
4557            .and_then(Value::as_str),
4558            Some("global")
4559        );
4560    }
4561
4562    #[test]
4563    fn state_backed_resolver_matches_general_borrowed_resolution() {
4564        let state = test_json!({
4565            "global": {"name": "global"},
4566            "inner": {"fallback": "global fallback"},
4567            "borrowed": {"name": "borrowed"},
4568            "items": [1, 2, 3]
4569        });
4570        let loop_values = test_json!({
4571            "outer": {"name": "outer"},
4572            "inner": {"name": "inner"}
4573        });
4574        let loop_vars = vec![
4575            LoopBinding {
4576                name: "outer",
4577                value: &loop_values["outer"],
4578            },
4579            LoopBinding {
4580                name: "inner",
4581                value: &loop_values["inner"],
4582            },
4583        ];
4584        let mut local_borrowed_vars = BorrowedScope::default();
4585        local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4586        let local_vars =
4587            ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4588        let sources = LocalValueSources {
4589            owned: &local_vars,
4590            borrowed: &local_borrowed_vars,
4591        };
4592        let scope = VisibleLoopScope { start: 0, end: 2 };
4593
4594        for path in [
4595            "global.name",
4596            "inner.name",
4597            "inner.fallback",
4598            "outer.name",
4599            "borrowed.name",
4600        ] {
4601            let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4602                .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4603            let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4604                .unwrap_or_else(|| panic!("{path} should resolve generally"));
4605            let Cow::Borrowed(resolved) = resolved else {
4606                panic!("{path} should remain borrowed");
4607            };
4608            assert!(
4609                std::ptr::eq(state_backed, resolved),
4610                "{path} resolved from different sources"
4611            );
4612        }
4613
4614        assert!(
4615            resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4616            "owned local values must use the general resolver"
4617        );
4618        assert!(
4619            resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4620                .is_none(),
4621            "synthetic values must use the general resolver"
4622        );
4623    }
4624
4625    #[test]
4626    fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4627        let values = [
4628            Value::String("a".to_string()),
4629            Value::String("b".to_string()),
4630            Value::String("c".to_string()),
4631            Value::String("d".to_string()),
4632            Value::String("e".to_string()),
4633            Value::String("f".to_string()),
4634            Value::String("replacement".to_string()),
4635        ];
4636        let mut scope = BorrowedScope::default();
4637        for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4638            assert!(scope.insert(name, &values[index]).is_none());
4639        }
4640        assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4641        assert_eq!(scope.overflow.len(), 2);
4642        assert_eq!(scope.get("a"), Some(&values[0]));
4643        assert_eq!(scope.get("f"), Some(&values[5]));
4644        assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4645        assert_eq!(scope.get("a"), Some(&values[6]));
4646        assert_eq!(scope.remove("b"), Some(&values[1]));
4647        assert!(scope.get("b").is_none());
4648        assert_eq!(scope.get("f"), Some(&values[5]));
4649        scope.clear();
4650        assert!(scope.get("a").is_none());
4651        assert!(scope.overflow.is_empty());
4652    }
4653
4654    // A simple test writer implementation
4655    struct TestWriter {
4656        content: RefCell<String>,
4657        ended: RefCell<bool>,
4658    }
4659
4660    impl TestWriter {
4661        fn new() -> Self {
4662            Self {
4663                content: RefCell::new(String::new()),
4664                ended: RefCell::new(false),
4665            }
4666        }
4667
4668        fn get_content(&self) -> String {
4669            self.content.borrow().clone()
4670        }
4671
4672        fn is_ended(&self) -> bool {
4673            *self.ended.borrow()
4674        }
4675    }
4676
4677    impl ResponseWriter for TestWriter {
4678        fn write(&mut self, content: &str) -> Result<()> {
4679            self.content.borrow_mut().push_str(content);
4680            Ok(())
4681        }
4682
4683        fn end(&mut self) -> Result<()> {
4684            *self.ended.borrow_mut() = true;
4685            Ok(())
4686        }
4687    }
4688
4689    #[test]
4690    fn generated_string_writer_methods_preserve_exact_attribute_output() {
4691        struct AttributeWriter {
4692            output: String,
4693        }
4694
4695        impl ResponseWriter for AttributeWriter {
4696            fn write(&mut self, content: &str) -> Result<()> {
4697                self.output.push_str(content);
4698                Ok(())
4699            }
4700
4701            crate::string_response_writer_methods!(output);
4702
4703            fn end(&mut self) -> Result<()> {
4704                Ok(())
4705            }
4706        }
4707
4708        let mut writer = AttributeWriter {
4709            output: String::new(),
4710        };
4711        write_attr(&mut writer, "data-id", "42")
4712            .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4713        writer
4714            .write_boolean_attribute("disabled")
4715            .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4716        assert_eq!(writer.output, " data-id=\"42\" disabled");
4717    }
4718
4719    #[test]
4720    fn template_attributes_preserve_escaping_and_raw_component_values() -> Result<()> {
4721        crate::define_string_response_writer!(StringWriter, output);
4722        crate::define_bytes_response_writer!(BytesWriter, output);
4723
4724        let values = [
4725            None,
4726            Some(Value::Null),
4727            Some(test_json!(false)),
4728            Some(test_json!(u64::MAX)),
4729            Some(test_json!(-0.0)),
4730            Some(test_json!("é水🙂<&\"'/&amp;")),
4731            Some(Value::String("é<&\"'/".repeat(1024))),
4732            Some(test_json!(["<item>", 7])),
4733            Some(test_json!({ "key": "quoted \"value\"" })),
4734        ];
4735        for component_prop in [false, true] {
4736            let mut attribute = WebUIFragment::attribute_template("data-value", "value-template");
4737            if let Some(Fragment::Attribute(attribute)) = &mut attribute.fragment {
4738                attribute.attr_start = component_prop;
4739            }
4740            let body = if component_prop {
4741                WebUIFragment::component("value-host")
4742            } else {
4743                WebUIFragment::signal("label", false)
4744            };
4745            let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4746                (
4747                    "index.html".to_owned(),
4748                    FragmentList {
4749                        fragments: vec![
4750                            WebUIFragment::raw("<value-host"),
4751                            attribute,
4752                            WebUIFragment::raw(">"),
4753                            body,
4754                            WebUIFragment::raw("</value-host>"),
4755                        ],
4756                        contains_boundary: false,
4757                    },
4758                ),
4759                (
4760                    "value-template".to_owned(),
4761                    FragmentList {
4762                        fragments: vec![
4763                            WebUIFragment::raw("prefix/"),
4764                            WebUIFragment::signal("value", true),
4765                            WebUIFragment::raw("/suffix&"),
4766                        ],
4767                        contains_boundary: false,
4768                    },
4769                ),
4770                (
4771                    "value-host".to_owned(),
4772                    FragmentList {
4773                        fragments: vec![WebUIFragment::signal("dataValue", false)],
4774                        contains_boundary: false,
4775                    },
4776                ),
4777            ])));
4778            assert_eq!(std::mem::size_of::<RenderFragmentMetadata>(), 12);
4779            for value in &values {
4780                let mut state = test_json!({ "label": "outside<&" });
4781                if let Some(value) = value {
4782                    state["value"] = value.clone();
4783                }
4784                let rendered = match value {
4785                    None => Cow::Borrowed(""),
4786                    Some(Value::String(value)) => Cow::Borrowed(value.as_str()),
4787                    Some(value) => Cow::Owned(value.to_string()),
4788                };
4789                let raw_value = format!("prefix/{rendered}/suffix&");
4790                let escaped = encode_safe(&raw_value);
4791                let content = if component_prop {
4792                    escaped.as_ref()
4793                } else {
4794                    "outside&lt;&amp;"
4795                };
4796                let expected =
4797                    format!("<value-host data-value=\"{escaped}\">{content}</value-host>");
4798                let mut default = TestWriter::new();
4799                let mut text = StringWriter::with_capacity(expected.len());
4800                let mut bytes = BytesWriter::with_capacity(expected.len());
4801                for writer in [
4802                    &mut default as &mut dyn ResponseWriter,
4803                    &mut text,
4804                    &mut bytes,
4805                ] {
4806                    WebUIHandler::new().render(
4807                        &protocol,
4808                        &state,
4809                        &RenderOptions::new("index.html", "/"),
4810                        writer,
4811                    )?;
4812                }
4813                assert_eq!(default.get_content(), expected);
4814                assert_eq!(text.output, expected);
4815                assert_eq!(bytes.output, expected.as_bytes());
4816            }
4817        }
4818        Ok(())
4819    }
4820
4821    #[test]
4822    fn template_buffer_reuse_does_not_add_a_large_high_water_mark() {
4823        let small = String::with_capacity(64);
4824        assert_eq!(recycle_template_buffer(small, 0).capacity(), 64);
4825
4826        let large = String::with_capacity(MAX_REUSED_TEMPLATE_CAPACITY * 2);
4827        assert_eq!(recycle_template_buffer(large, 0).capacity(), 0);
4828
4829        let existing = String::with_capacity(MAX_REUSED_TEMPLATE_CAPACITY * 2);
4830        let capacity = existing.capacity();
4831        assert_eq!(
4832            recycle_template_buffer(existing, capacity).capacity(),
4833            capacity
4834        );
4835    }
4836
4837    #[test]
4838    fn shared_attribute_template_preserves_raw_and_component_rendering() -> Result<()> {
4839        crate::define_string_response_writer!(BufferedWriter, output);
4840        let mut component_attribute =
4841            WebUIFragment::attribute_template("data-value", "shared-template");
4842        if let Some(Fragment::Attribute(attribute)) = &mut component_attribute.fragment {
4843            attribute.attr_start = true;
4844        }
4845        let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4846            (
4847                "index.html".to_owned(),
4848                FragmentList {
4849                    fragments: vec![
4850                        WebUIFragment::raw("<a"),
4851                        WebUIFragment::attribute_template("title", "shared-template"),
4852                        WebUIFragment::raw("></a><value-host"),
4853                        component_attribute,
4854                        WebUIFragment::raw(">"),
4855                        WebUIFragment::component("value-host"),
4856                        WebUIFragment::raw("</value-host><section>"),
4857                        WebUIFragment::component("shared-template"),
4858                        WebUIFragment::raw("</section>"),
4859                    ],
4860                    contains_boundary: false,
4861                },
4862            ),
4863            (
4864                "shared-template".to_owned(),
4865                FragmentList {
4866                    fragments: vec![
4867                        WebUIFragment::raw("prefix/&"),
4868                        WebUIFragment::signal("value", false),
4869                        WebUIFragment::raw("<em>suffix</em>"),
4870                    ],
4871                    contains_boundary: false,
4872                },
4873            ),
4874            (
4875                "value-host".to_owned(),
4876                FragmentList {
4877                    fragments: vec![WebUIFragment::signal("dataValue", false)],
4878                    contains_boundary: false,
4879                },
4880            ),
4881        ])));
4882        let value = "é<&\"'/";
4883        let state = test_json!({ "value": value });
4884        let raw_value = format!("prefix/&{value}<em>suffix</em>");
4885        let escaped = encode_safe(&raw_value);
4886        let expected = format!(
4887            "<a title=\"{escaped}\"></a><value-host data-value=\"{escaped}\">\
4888             {escaped}</value-host><section>prefix/&{}<em>suffix</em></section>",
4889            encode_safe(value)
4890        );
4891        let mut writer = BufferedWriter::with_capacity(expected.len());
4892        WebUIHandler::new().render(
4893            &protocol,
4894            &state,
4895            &RenderOptions::new("index.html", "/"),
4896            &mut writer,
4897        )?;
4898        assert_eq!(writer.output, expected);
4899        Ok(())
4900    }
4901
4902    #[test]
4903    fn split_template_attribute_stops_at_the_first_writer_error() {
4904        struct FailAfter {
4905            writes: usize,
4906            fail_at: usize,
4907            ended: bool,
4908        }
4909
4910        impl ResponseWriter for FailAfter {
4911            fn write(&mut self, _content: &str) -> Result<()> {
4912                self.writes += 1;
4913                if self.writes == self.fail_at {
4914                    Err(HandlerError::ClientDisconnected)
4915                } else {
4916                    Ok(())
4917                }
4918            }
4919
4920            fn end(&mut self) -> Result<()> {
4921                self.ended = true;
4922                Ok(())
4923            }
4924        }
4925
4926        let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4927            (
4928                "index.html".to_owned(),
4929                FragmentList {
4930                    fragments: vec![
4931                        WebUIFragment::raw("<a"),
4932                        WebUIFragment::attribute_template("href", "value-template"),
4933                        WebUIFragment::raw(">must not be written</a>"),
4934                    ],
4935                    contains_boundary: false,
4936                },
4937            ),
4938            (
4939                "value-template".to_owned(),
4940                FragmentList {
4941                    fragments: vec![
4942                        WebUIFragment::raw("prefix/"),
4943                        WebUIFragment::signal("value", false),
4944                        WebUIFragment::raw("/suffix"),
4945                    ],
4946                    contains_boundary: false,
4947                },
4948            ),
4949        ])));
4950        let state = test_json!({ "value": "é<&\"'/" });
4951        let options = RenderOptions::new("index.html", "/");
4952        let handler = WebUIHandler::new();
4953        let mut complete = FailAfter {
4954            writes: 0,
4955            fail_at: usize::MAX,
4956            ended: false,
4957        };
4958        handler
4959            .render(&protocol, &state, &options, &mut complete)
4960            .unwrap();
4961        assert!(complete.writes > 1);
4962        assert!(complete.ended);
4963        for fail_at in 1..complete.writes {
4964            let mut writer = FailAfter {
4965                writes: 0,
4966                fail_at,
4967                ended: false,
4968            };
4969            let result = handler.render(&protocol, &state, &options, &mut writer);
4970            assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
4971            assert_eq!(writer.writes, fail_at);
4972            assert!(!writer.ended);
4973        }
4974    }
4975
4976    #[test]
4977    fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4978        let mut fragments = HashMap::new();
4979        fragments.insert(
4980            "index.html".to_string(),
4981            FragmentList {
4982                fragments: vec![
4983                    WebUIFragment {
4984                        fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4985                            name: ":data-title".to_string(),
4986                            value: "source".to_string(),
4987                            complex: true,
4988                            attr_start: true,
4989                            ..Default::default()
4990                        })),
4991                    },
4992                    WebUIFragment::component("prepared-child"),
4993                ],
4994                contains_boundary: false,
4995            },
4996        );
4997        fragments.insert(
4998            "prepared-child".to_string(),
4999            FragmentList {
5000                fragments: vec![WebUIFragment::signal("dataTitle", false)],
5001                contains_boundary: false,
5002            },
5003        );
5004        let protocol = Protocol::new(WebUIProtocol::new(fragments));
5005        let resolved = protocol.render_fragments().resolve(protocol.protocol());
5006        let entry = resolved
5007            .list_by_id("index.html")
5008            .unwrap_or_else(|| panic!("entry render plan should exist"));
5009        let child_index = protocol
5010            .render_fragments()
5011            .index("prepared-child")
5012            .unwrap_or_else(|| panic!("component target should be indexed"));
5013        assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
5014        assert_eq!(entry.target(1), Some(child_index));
5015        assert!(!entry.has_routes);
5016        let source_entry = protocol
5017            .protocol()
5018            .fragments
5019            .get("index.html")
5020            .unwrap_or_else(|| panic!("source entry should exist"));
5021        assert!(std::ptr::eq(
5022            entry.fragments.as_ptr(),
5023            source_entry.fragments.as_ptr()
5024        ));
5025
5026        let state = test_json!({"source": "Prepared"});
5027        let options = RenderOptions::new("index.html", "/");
5028        for handler in [
5029            WebUIHandler::new(),
5030            WebUIHandler::with_plugin(|| {
5031                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
5032            }),
5033        ] {
5034            let mut writer = TestWriter::new();
5035            assert!(handler
5036                .render(&protocol, &state, &options, &mut writer)
5037                .is_ok());
5038            assert!(writer.get_content().contains("Prepared"));
5039        }
5040    }
5041
5042    #[test]
5043    fn prepared_graph_records_route_presence_per_fragment_list() {
5044        let mut fragments = HashMap::new();
5045        fragments.insert(
5046            "index.html".to_string(),
5047            FragmentList {
5048                fragments: vec![WebUIFragment::route("/", "dash-page")],
5049                contains_boundary: false,
5050            },
5051        );
5052        fragments.insert(
5053            "dash-page".to_string(),
5054            FragmentList {
5055                fragments: vec![WebUIFragment::raw("dashboard")],
5056                contains_boundary: false,
5057            },
5058        );
5059        let protocol = Protocol::new(WebUIProtocol::new(fragments));
5060        let resolved = protocol.render_fragments().resolve(protocol.protocol());
5061        let entry = resolved
5062            .list_by_id("index.html")
5063            .unwrap_or_else(|| panic!("entry render plan should exist"));
5064        let page = resolved
5065            .list_by_id("dash-page")
5066            .unwrap_or_else(|| panic!("page render plan should exist"));
5067        // Only lists that actually contain a route pay for the sibling scan.
5068        assert!(entry.has_routes);
5069        assert!(!page.has_routes);
5070    }
5071
5072    #[test]
5073    fn prepared_prop_names_match_attribute_to_camel_exactly() {
5074        // Preparation writes the camelCase conversion straight into the shared
5075        // arena instead of calling `attribute_to_camel`, which allocates. The two
5076        // must stay byte-identical, including the irregular lookup-table entries.
5077        let names = [
5078            "data-title",
5079            ":data-title",
5080            "aria-labelledby",
5081            "aria-valuemax",
5082            "readonly",
5083            "tabindex",
5084            "class",
5085            "for",
5086            "maxlength",
5087            "novalidate",
5088            "already-camel",
5089            "trailing-",
5090            "-leading",
5091            "multi-part-attribute-name",
5092            "single",
5093            "",
5094        ];
5095        for name in names {
5096            let stripped = name.strip_prefix(':').unwrap_or(name);
5097            assert_eq!(
5098                component_attr_name(name),
5099                webui_protocol::attrs::attribute_to_camel(stripped),
5100                "prepared name diverged for `{name}`"
5101            );
5102        }
5103    }
5104
5105    #[test]
5106    fn prepared_component_prop_names_match_per_branch_stripping() {
5107        // The render path strips a leading `:` twice on the complex-value branch
5108        // and once elsewhere. Preparation must reproduce that per branch or a
5109        // `::`-prefixed binding would change the emitted component prop name.
5110        let complex = WebUIFragmentAttribute {
5111            name: "::data-title".to_string(),
5112            value: "source".to_string(),
5113            complex: true,
5114            ..Default::default()
5115        };
5116        assert_eq!(component_attr_source(&complex), ":data-title");
5117        assert_eq!(
5118            component_attr_name(component_attr_source(&complex)),
5119            "dataTitle"
5120        );
5121
5122        // `raw_value` takes the static branch, which strips only once.
5123        let raw = WebUIFragmentAttribute {
5124            raw_value: true,
5125            ..complex.clone()
5126        };
5127        assert_eq!(component_attr_source(&raw), "::data-title");
5128
5129        // A template attribute returns before the complex branch is reached.
5130        let templated = WebUIFragmentAttribute {
5131            template: "tpl".to_string(),
5132            ..complex.clone()
5133        };
5134        assert_eq!(component_attr_source(&templated), "::data-title");
5135
5136        // An empty value never reaches the complex branch either.
5137        let empty_value = WebUIFragmentAttribute {
5138            value: String::new(),
5139            ..complex.clone()
5140        };
5141        assert_eq!(component_attr_source(&empty_value), "::data-title");
5142
5143        // Ordinary single-colon bindings are unaffected on every branch.
5144        let ordinary = WebUIFragmentAttribute {
5145            name: ":data-title".to_string(),
5146            ..complex
5147        };
5148        assert_eq!(component_attr_source(&ordinary), "data-title");
5149        assert_eq!(
5150            component_attr_name(component_attr_source(&ordinary)),
5151            "dataTitle"
5152        );
5153    }
5154
5155    #[test]
5156    fn render_slots_match_continuation_slots() {
5157        // The streaming VM reads prepared metadata with the continuation slot
5158        // directly. That is only sound while both numberings index the same
5159        // sorted fragment IDs, so pin the invariant here rather than paying for
5160        // an ID lookup per streamed fragment frame.
5161        let mut fragments = HashMap::new();
5162        for id in [
5163            "index.html",
5164            "zeta-last",
5165            "alpha-first",
5166            "middle-one",
5167            "middle-two",
5168        ] {
5169            fragments.insert(
5170                id.to_string(),
5171                FragmentList {
5172                    fragments: vec![WebUIFragment::raw(id)],
5173                    contains_boundary: false,
5174                },
5175            );
5176        }
5177        let protocol = Protocol::new(WebUIProtocol::new(fragments));
5178        let index = protocol.render_fragments();
5179        for slot in 0..u32::try_from(protocol.protocol().fragments.len())
5180            .unwrap_or_else(|_| panic!("fragment count fits in u32"))
5181        {
5182            let id = protocol
5183                .fragment_id(slot)
5184                .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
5185            assert_eq!(
5186                index.index(id),
5187                Some(slot as usize),
5188                "render slot must equal continuation slot for `{id}`"
5189            );
5190            assert_eq!(protocol.fragment_slot(id), Some(slot));
5191        }
5192    }
5193
5194    #[test]
5195    fn prepared_graph_reports_missing_fragments_without_panicking() {
5196        let mut fragments = HashMap::new();
5197        fragments.insert(
5198            "index.html".to_string(),
5199            FragmentList {
5200                fragments: vec![WebUIFragment::component("absent-child")],
5201                contains_boundary: false,
5202            },
5203        );
5204        let protocol = Protocol::new(WebUIProtocol::new(fragments));
5205        let resolved = protocol.render_fragments().resolve(protocol.protocol());
5206        let entry = resolved
5207            .list_by_id("index.html")
5208            .unwrap_or_else(|| panic!("entry render plan should exist"));
5209        // An unresolvable reference keeps a `None` target and must not index
5210        // out of range or silently render an unrelated fragment list.
5211        assert_eq!(entry.target(0), None);
5212        assert!(resolved.list_by_id("absent-child").is_none());
5213        assert!(resolved.list(usize::MAX).is_none());
5214
5215        let state = test_json!({});
5216        let options = RenderOptions::new("index.html", "/");
5217        let mut writer = TestWriter::new();
5218        match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
5219            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
5220            other => panic!("expected a missing fragment error, got {other:?}"),
5221        }
5222
5223        let mut writer = TestWriter::new();
5224        match WebUIHandler::new().render(
5225            &protocol,
5226            &state,
5227            &RenderOptions::new("absent-entry", "/"),
5228            &mut writer,
5229        ) {
5230            Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
5231            other => panic!("expected a missing entry error, got {other:?}"),
5232        }
5233        // Bindings check the entry before sizing an output buffer, so the
5234        // check must agree with render's own entry lookup.
5235        assert!(protocol.has_fragment("index.html"));
5236        assert!(!protocol.has_fragment("absent-entry"));
5237    }
5238
5239    #[test]
5240    fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
5241        let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
5242        let mut fragments = HashMap::with_capacity(list_count);
5243        for index in 0..list_count {
5244            fragments.insert(
5245                format!("fragment-{index}"),
5246                FragmentList {
5247                    fragments: vec![WebUIFragment::raw("content")],
5248                    contains_boundary: false,
5249                },
5250            );
5251        }
5252        let protocol = Protocol::new(WebUIProtocol::new(fragments));
5253        let resolved = protocol.render_fragments().resolve(protocol.protocol());
5254        // Nothing is materialized until a slot is actually rendered.
5255        assert!(resolved.cache[0].get().is_none());
5256        assert!(resolved.spilled.get().is_none());
5257        assert!(resolved.list(0).is_some());
5258        assert!(resolved.cache[0].get().is_some());
5259        // Rendering only inline slots never allocates the spill region.
5260        assert!(resolved.spilled.get().is_none());
5261        // Slots past the inline cache spill once, then stay memoized so a deep
5262        // descent never re-hashes the fragment ID.
5263        assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
5264        let spilled = resolved
5265            .spilled
5266            .get()
5267            .expect("a slot past the inline cache should allocate the spill region");
5268        assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
5269        assert!(spilled[0].get().is_some());
5270        assert!(resolved.list(list_count - 1).is_some());
5271
5272        for (id, source) in &protocol.protocol().fragments {
5273            let prepared = resolved
5274                .list_by_id(id)
5275                .unwrap_or_else(|| panic!("render plan should contain {id}"));
5276            // Borrowed, never duplicated: the prepared list points at protocol storage.
5277            assert!(std::ptr::eq(
5278                prepared.fragments.as_ptr(),
5279                source.fragments.as_ptr()
5280            ));
5281        }
5282    }
5283
5284    #[test]
5285    fn test_handle_raw() {
5286        // Create a simple protocol
5287        let mut fragments = HashMap::new();
5288        fragments.insert(
5289            "index.html".to_string(),
5290            FragmentList {
5291                fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
5292                contains_boundary: false,
5293            },
5294        );
5295
5296        let protocol = WebUIProtocol::new(fragments);
5297        let state = test_json!({});
5298
5299        // Create a test writer
5300        let mut writer = TestWriter::new();
5301
5302        // Handle the protocol
5303        assert!(
5304            handle(
5305                &protocol,
5306                &state,
5307                &RenderOptions::new("index.html", "/"),
5308                &mut writer
5309            )
5310            .is_ok(),
5311            "Failed to handle raw protocol"
5312        );
5313
5314        // Check the output
5315        assert_eq!(writer.get_content(), "Hello, WebUI!");
5316        assert!(writer.is_ended());
5317    }
5318
5319    #[test]
5320    fn test_handle_signal() {
5321        // Create a protocol with a signal
5322        let mut fragments = HashMap::new();
5323        fragments.insert(
5324            "index.html".to_string(),
5325            FragmentList {
5326                fragments: vec![
5327                    WebUIFragment::raw("Hello, "),
5328                    WebUIFragment::signal("name", false),
5329                    WebUIFragment::raw("!"),
5330                ],
5331                contains_boundary: false,
5332            },
5333        );
5334
5335        let protocol = WebUIProtocol::new(fragments);
5336        let state = test_json!({"name": "WebUI"});
5337
5338        // Create a test writer
5339        let mut writer = TestWriter::new();
5340
5341        // Handle the protocol
5342        assert!(
5343            handle(
5344                &protocol,
5345                &state,
5346                &RenderOptions::new("index.html", "/"),
5347                &mut writer
5348            )
5349            .is_ok(),
5350            "Failed to handle signal protocol"
5351        );
5352
5353        // Check the output
5354        assert_eq!(writer.get_content(), "Hello, WebUI!");
5355        assert!(writer.is_ended());
5356    }
5357
5358    #[test]
5359    fn test_handle_for_loop() {
5360        // Create a protocol with a for loop
5361        let mut fragments = HashMap::new();
5362        fragments.insert(
5363            "index.html".to_string(),
5364            FragmentList {
5365                fragments: vec![
5366                    WebUIFragment::raw("People: "),
5367                    WebUIFragment::for_loop("person", "people", "person-item"),
5368                ],
5369                contains_boundary: false,
5370            },
5371        );
5372
5373        fragments.insert(
5374            "person-item".to_string(),
5375            FragmentList {
5376                fragments: vec![
5377                    WebUIFragment::signal("person.name", false),
5378                    WebUIFragment::raw(", "),
5379                ],
5380                contains_boundary: false,
5381            },
5382        );
5383
5384        let protocol = WebUIProtocol::new(fragments);
5385        let state = test_json!({
5386            "people": [
5387                {"name": "Alice"},
5388                {"name": "Bob"},
5389                {"name": "Charlie"}
5390            ]
5391        });
5392
5393        // Create a test writer
5394        let mut writer = TestWriter::new();
5395
5396        // Handle the protocol
5397        assert!(
5398            handle(
5399                &protocol,
5400                &state,
5401                &RenderOptions::new("index.html", "/"),
5402                &mut writer
5403            )
5404            .is_ok(),
5405            "Failed to handle for loop protocol"
5406        );
5407
5408        // Check the output
5409        assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5410        assert!(writer.is_ended());
5411    }
5412
5413    #[test]
5414    fn test_handle_if_condition() {
5415        // Create a protocol with an if condition
5416        let mut fragments = HashMap::new();
5417        fragments.insert(
5418            "index.html".to_string(),
5419            FragmentList {
5420                fragments: vec![
5421                    WebUIFragment::raw("Status: "),
5422                    WebUIFragment::if_cond(
5423                        webui_protocol::ConditionExpr::identifier("isActive"),
5424                        "active-content",
5425                    ),
5426                    WebUIFragment::raw("End"),
5427                ],
5428                contains_boundary: false,
5429            },
5430        );
5431
5432        fragments.insert(
5433            "active-content".to_string(),
5434            FragmentList {
5435                fragments: vec![WebUIFragment::raw("Active")],
5436                contains_boundary: false,
5437            },
5438        );
5439
5440        let protocol = WebUIProtocol::new(fragments);
5441
5442        // Test with isActive = true
5443        let state_true = test_json!({"isActive": true});
5444        let mut writer_true = TestWriter::new();
5445        assert!(
5446            handle(
5447                &protocol,
5448                &state_true,
5449                &RenderOptions::new("index.html", "/"),
5450                &mut writer_true
5451            )
5452            .is_ok(),
5453            "Failed to handle if condition (true case)"
5454        );
5455        assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5456        assert!(writer_true.is_ended());
5457
5458        // Test with isActive = false
5459        let state_false = test_json!({"isActive": false});
5460        let mut writer_false = TestWriter::new();
5461        assert!(
5462            handle(
5463                &protocol,
5464                &state_false,
5465                &RenderOptions::new("index.html", "/"),
5466                &mut writer_false
5467            )
5468            .is_ok(),
5469            "Failed to handle if condition (false case)"
5470        );
5471        assert_eq!(writer_false.get_content(), "Status: End");
5472        assert!(writer_false.is_ended());
5473    }
5474
5475    #[test]
5476    fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5477        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>"#;
5478        let mut parser = HtmlParser::new();
5479        parser
5480            .parse("index.html", source)
5481            .expect("parse missing-path condition fixture");
5482        let protocol = WebUIProtocol::new(parser.into_fragment_records());
5483        let state = test_json!({
5484            "items": [
5485                {"label": "Normal"},
5486                {"label": "Search", "searchPresentation": true}
5487            ]
5488        });
5489        let mut writer = TestWriter::new();
5490
5491        handle(
5492            &protocol,
5493            &state,
5494            &RenderOptions::new("index.html", "/"),
5495            &mut writer,
5496        )
5497        .expect("render missing-path condition fixture");
5498
5499        assert_eq!(
5500            writer.get_content(),
5501            "top-negated<span>Normal</span><mark>Search</mark>"
5502        );
5503    }
5504
5505    #[test]
5506    fn native_dynamic_attribute_does_not_leak_into_next_component() {
5507        let mut fragments = HashMap::new();
5508        fragments.insert(
5509            "index.html".to_string(),
5510            FragmentList {
5511                fragments: vec![
5512                    WebUIFragment::raw("<div"),
5513                    WebUIFragment {
5514                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5515                            WebUIFragmentAttribute {
5516                                name: "title".into(),
5517                                value: "nativeTitle".into(),
5518                                ..Default::default()
5519                            },
5520                        )),
5521                    },
5522                    WebUIFragment::raw("></div><my-comp>"),
5523                    WebUIFragment::component("my-comp"),
5524                    WebUIFragment::raw("</my-comp>"),
5525                ],
5526                contains_boundary: false,
5527            },
5528        );
5529        fragments.insert(
5530            "my-comp".to_string(),
5531            FragmentList {
5532                fragments: vec![WebUIFragment::signal("title", false)],
5533                contains_boundary: false,
5534            },
5535        );
5536        let protocol = WebUIProtocol::new(fragments);
5537        let state = test_json!({
5538            "nativeTitle": "native",
5539            "title": "global"
5540        });
5541        let mut writer = TestWriter::new();
5542        handle(
5543            &protocol,
5544            &state,
5545            &RenderOptions::new("index.html", "/"),
5546            &mut writer,
5547        )
5548        .unwrap_or_else(|error| panic!("render failed: {error}"));
5549        assert_eq!(
5550            writer.get_content(),
5551            "<div title=\"native\"></div><my-comp>global</my-comp>"
5552        );
5553    }
5554
5555    #[test]
5556    fn test_handle_component() {
5557        // Create a protocol with a component
5558        let mut fragments = HashMap::new();
5559        fragments.insert(
5560            "index.html".to_string(),
5561            FragmentList {
5562                fragments: vec![
5563                    WebUIFragment::raw("Component: "),
5564                    WebUIFragment::component("my-component"),
5565                ],
5566                contains_boundary: false,
5567            },
5568        );
5569
5570        fragments.insert(
5571            "my-component".to_string(),
5572            FragmentList {
5573                fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5574                contains_boundary: false,
5575            },
5576        );
5577
5578        let protocol = WebUIProtocol::new(fragments);
5579        let state = test_json!({});
5580
5581        // Create a test writer
5582        let mut writer = TestWriter::new();
5583
5584        // Handle the protocol
5585        assert!(
5586            handle(
5587                &protocol,
5588                &state,
5589                &RenderOptions::new("index.html", "/"),
5590                &mut writer
5591            )
5592            .is_ok(),
5593            "Failed to handle component protocol"
5594        );
5595
5596        // Check the output
5597        assert_eq!(
5598            writer.get_content(),
5599            "Component: <div>Component Content</div>"
5600        );
5601        assert!(writer.is_ended());
5602    }
5603
5604    #[test]
5605    fn test_missing_fragment() {
5606        // Create a protocol with a missing fragment reference
5607        let mut fragments = HashMap::new();
5608        fragments.insert(
5609            "index.html".to_string(),
5610            FragmentList {
5611                fragments: vec![WebUIFragment::component("missing-component")],
5612                contains_boundary: false,
5613            },
5614        );
5615
5616        let protocol = WebUIProtocol::new(fragments);
5617        let state = test_json!({});
5618
5619        // Create a test writer
5620        let mut writer = TestWriter::new();
5621
5622        // Handle the protocol
5623        let result = handle(
5624            &protocol,
5625            &state,
5626            &RenderOptions::new("index.html", "/"),
5627            &mut writer,
5628        );
5629
5630        // Expect an error
5631        assert!(result.is_err());
5632        if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5633            assert_eq!(fragment_id, "missing-component");
5634        } else {
5635            panic!("Expected MissingFragment error");
5636        }
5637    }
5638
5639    #[test]
5640    fn test_missing_signal_renders_empty() {
5641        // A signal referencing a field absent from state should render as empty
5642        let mut fragments = HashMap::new();
5643        fragments.insert(
5644            "index.html".to_string(),
5645            FragmentList {
5646                fragments: vec![
5647                    WebUIFragment::raw("Hello, "),
5648                    WebUIFragment::signal("missing_field", false),
5649                    WebUIFragment::raw("!"),
5650                ],
5651                contains_boundary: false,
5652            },
5653        );
5654
5655        let protocol = WebUIProtocol::new(fragments);
5656        let state = test_json!({});
5657
5658        let mut writer = TestWriter::new();
5659
5660        assert!(
5661            handle(
5662                &protocol,
5663                &state,
5664                &RenderOptions::new("index.html", "/"),
5665                &mut writer
5666            )
5667            .is_ok(),
5668            "Missing signal should not produce an error"
5669        );
5670
5671        assert_eq!(writer.get_content(), "Hello, !");
5672        assert!(writer.is_ended());
5673    }
5674
5675    // ── Boolean attribute rendering tests ─────────────────────────────
5676
5677    #[test]
5678    fn test_boolean_attr_true() {
5679        let mut fragments = HashMap::new();
5680        fragments.insert(
5681            "index.html".to_string(),
5682            FragmentList {
5683                fragments: vec![
5684                    WebUIFragment::raw("<button"),
5685                    WebUIFragment::attribute_boolean(
5686                        "disabled",
5687                        ConditionExpr::identifier("isDisabled"),
5688                    ),
5689                    WebUIFragment::raw(">Click</button>"),
5690                ],
5691                contains_boundary: false,
5692            },
5693        );
5694        let protocol = WebUIProtocol::new(fragments);
5695        let state = test_json!({"isDisabled": true});
5696        let mut writer = TestWriter::new();
5697        handle(
5698            &protocol,
5699            &state,
5700            &RenderOptions::new("index.html", "/"),
5701            &mut writer,
5702        )
5703        .unwrap();
5704        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5705    }
5706
5707    #[test]
5708    fn test_boolean_attr_false() {
5709        let mut fragments = HashMap::new();
5710        fragments.insert(
5711            "index.html".to_string(),
5712            FragmentList {
5713                fragments: vec![
5714                    WebUIFragment::raw("<button"),
5715                    WebUIFragment::attribute_boolean(
5716                        "disabled",
5717                        ConditionExpr::identifier("isDisabled"),
5718                    ),
5719                    WebUIFragment::raw(">Click</button>"),
5720                ],
5721                contains_boundary: false,
5722            },
5723        );
5724        let protocol = WebUIProtocol::new(fragments);
5725        let state = test_json!({"isDisabled": false});
5726        let mut writer = TestWriter::new();
5727        handle(
5728            &protocol,
5729            &state,
5730            &RenderOptions::new("index.html", "/"),
5731            &mut writer,
5732        )
5733        .unwrap();
5734        assert_eq!(writer.get_content(), "<button>Click</button>");
5735    }
5736
5737    #[test]
5738    fn test_boolean_attr_missing() {
5739        let mut fragments = HashMap::new();
5740        fragments.insert(
5741            "index.html".to_string(),
5742            FragmentList {
5743                fragments: vec![
5744                    WebUIFragment::raw("<input type=\"checkbox\""),
5745                    WebUIFragment::attribute_boolean(
5746                        "checked",
5747                        ConditionExpr::identifier("checked"),
5748                    ),
5749                    WebUIFragment::raw(">"),
5750                ],
5751                contains_boundary: false,
5752            },
5753        );
5754        let protocol = WebUIProtocol::new(fragments);
5755        let state = test_json!({});
5756        let mut writer = TestWriter::new();
5757        handle(
5758            &protocol,
5759            &state,
5760            &RenderOptions::new("index.html", "/"),
5761            &mut writer,
5762        )
5763        .unwrap();
5764        assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5765    }
5766
5767    #[test]
5768    fn test_boolean_attr_multiple() {
5769        let mut fragments = HashMap::new();
5770        fragments.insert(
5771            "index.html".to_string(),
5772            FragmentList {
5773                fragments: vec![
5774                    WebUIFragment::raw("<input type=\"checkbox\""),
5775                    WebUIFragment::attribute_boolean(
5776                        "checked",
5777                        ConditionExpr::identifier("checked"),
5778                    ),
5779                    WebUIFragment::attribute_boolean(
5780                        "disabled",
5781                        ConditionExpr::identifier("disabled"),
5782                    ),
5783                    WebUIFragment::raw(">"),
5784                ],
5785                contains_boundary: false,
5786            },
5787        );
5788        let protocol = WebUIProtocol::new(fragments);
5789        let state = test_json!({"checked": true, "disabled": false});
5790        let mut writer = TestWriter::new();
5791        handle(
5792            &protocol,
5793            &state,
5794            &RenderOptions::new("index.html", "/"),
5795            &mut writer,
5796        )
5797        .unwrap();
5798        assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5799    }
5800
5801    // ── Simple attribute rendering tests ──────────────────────────────
5802
5803    #[test]
5804    fn test_attribute_with_value() {
5805        let mut fragments = HashMap::new();
5806        fragments.insert(
5807            "index.html".to_string(),
5808            FragmentList {
5809                fragments: vec![
5810                    WebUIFragment::raw("<input"),
5811                    WebUIFragment::attribute("value", "inputValue"),
5812                    WebUIFragment::raw(">"),
5813                ],
5814                contains_boundary: false,
5815            },
5816        );
5817        let protocol = WebUIProtocol::new(fragments);
5818        let state = test_json!({"inputValue": "Hello"});
5819        let mut writer = TestWriter::new();
5820        handle(
5821            &protocol,
5822            &state,
5823            &RenderOptions::new("index.html", "/"),
5824            &mut writer,
5825        )
5826        .unwrap();
5827        assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5828    }
5829
5830    #[test]
5831    fn test_dynamic_attribute_preserves_nullish_and_falsy_values() -> Result<()> {
5832        let mut fragments = HashMap::new();
5833        fragments.insert(
5834            "index.html".to_string(),
5835            FragmentList {
5836                fragments: vec![
5837                    WebUIFragment::raw("<input"),
5838                    WebUIFragment::attribute("pattern", "pattern"),
5839                    WebUIFragment::raw(">"),
5840                ],
5841                contains_boundary: false,
5842            },
5843        );
5844        let protocol = WebUIProtocol::new(fragments);
5845
5846        for (state, expected) in [
5847            (test_json!({}), "<input>"),
5848            (test_json!({"pattern": null}), "<input>"),
5849            (test_json!({"pattern": ""}), "<input pattern=\"\">"),
5850            (test_json!({"pattern": 0}), "<input pattern=\"0\">"),
5851            (test_json!({"pattern": false}), "<input pattern=\"false\">"),
5852            (
5853                test_json!({"pattern": "[a-z]+"}),
5854                "<input pattern=\"[a-z]+\">",
5855            ),
5856        ] {
5857            let mut writer = TestWriter::new();
5858            handle(
5859                &protocol,
5860                &state,
5861                &RenderOptions::new("index.html", "/"),
5862                &mut writer,
5863            )?;
5864            assert_eq!(writer.get_content(), expected, "state: {state}");
5865        }
5866        Ok(())
5867    }
5868
5869    #[test]
5870    fn test_dynamic_attribute_omission_preserves_component_props() -> Result<()> {
5871        let mut fragments = HashMap::new();
5872        fragments.insert(
5873            "index.html".to_string(),
5874            FragmentList {
5875                fragments: vec![
5876                    WebUIFragment::raw("<my-input"),
5877                    WebUIFragment {
5878                        fragment: Some(web_ui_fragment::Fragment::Attribute(
5879                            WebUIFragmentAttribute {
5880                                name: "pattern".into(),
5881                                value: "constraint".into(),
5882                                attr_start: true,
5883                                ..Default::default()
5884                            },
5885                        )),
5886                    },
5887                    WebUIFragment::raw(">"),
5888                    WebUIFragment::component("my-input"),
5889                    WebUIFragment::raw("</my-input>"),
5890                ],
5891                contains_boundary: false,
5892            },
5893        );
5894        fragments.insert(
5895            "my-input".to_string(),
5896            FragmentList {
5897                fragments: vec![
5898                    WebUIFragment::raw("<input"),
5899                    WebUIFragment::attribute("pattern", "pattern"),
5900                    WebUIFragment::raw(">"),
5901                    WebUIFragment::signal("pattern", false),
5902                ],
5903                contains_boundary: false,
5904            },
5905        );
5906        let protocol = WebUIProtocol::new(fragments);
5907
5908        for (state, expected) in [
5909            (test_json!({}), "<my-input><input pattern=\"\"></my-input>"),
5910            (
5911                test_json!({"constraint": null}),
5912                "<my-input><input>null</my-input>",
5913            ),
5914            (
5915                test_json!({"pattern": "global"}),
5916                "<my-input><input pattern=\"\"></my-input>",
5917            ),
5918            (
5919                test_json!({"constraint": ""}),
5920                "<my-input pattern=\"\"><input pattern=\"\"></my-input>",
5921            ),
5922            (
5923                test_json!({"constraint": "[a-z]+"}),
5924                "<my-input pattern=\"[a-z]+\"><input pattern=\"[a-z]+\">[a-z]+</my-input>",
5925            ),
5926        ] {
5927            let mut writer = TestWriter::new();
5928            handle(
5929                &protocol,
5930                &state,
5931                &RenderOptions::new("index.html", "/"),
5932                &mut writer,
5933            )?;
5934            assert_eq!(writer.get_content(), expected, "state: {state}");
5935        }
5936        Ok(())
5937    }
5938
5939    #[test]
5940    fn test_attribute_with_falsy_numeric() {
5941        let mut fragments = HashMap::new();
5942        fragments.insert(
5943            "index.html".to_string(),
5944            FragmentList {
5945                fragments: vec![
5946                    WebUIFragment::raw("<div name=\"test\""),
5947                    WebUIFragment::attribute("handle", "number"),
5948                    WebUIFragment::raw("></div>"),
5949                ],
5950                contains_boundary: false,
5951            },
5952        );
5953        let protocol = WebUIProtocol::new(fragments);
5954        let state = test_json!({"number": 0});
5955        let mut writer = TestWriter::new();
5956        handle(
5957            &protocol,
5958            &state,
5959            &RenderOptions::new("index.html", "/"),
5960            &mut writer,
5961        )
5962        .unwrap();
5963        assert_eq!(
5964            writer.get_content(),
5965            "<div name=\"test\" handle=\"0\"></div>"
5966        );
5967    }
5968
5969    // ── Dynamic attribute escaping for non-string JSON types ─────────
5970
5971    #[test]
5972    fn test_attribute_array_value_is_escaped() {
5973        let mut fragments = HashMap::new();
5974        fragments.insert(
5975            "index.html".to_string(),
5976            FragmentList {
5977                fragments: vec![
5978                    WebUIFragment::raw("<a"),
5979                    WebUIFragment::attribute("href", "value"),
5980                    WebUIFragment::raw(">demo</a>"),
5981                ],
5982                contains_boundary: false,
5983            },
5984        );
5985        let protocol = WebUIProtocol::new(fragments);
5986        let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5987        let mut writer = TestWriter::new();
5988        handle(
5989            &protocol,
5990            &state,
5991            &RenderOptions::new("index.html", "/"),
5992            &mut writer,
5993        )
5994        .unwrap();
5995        let content = writer.get_content();
5996        // All inner double quotes must be entity-escaped so that the
5997        // browser never sees a second attribute boundary.
5998        assert!(
5999            content.contains("&quot;"),
6000            "Double quotes inside attribute value must be escaped: {content}"
6001        );
6002        // The href attribute value must be a single contiguous quoted
6003        // string — no extra attributes should appear.
6004        assert_eq!(
6005            content.matches("=\"").count(),
6006            1,
6007            "Only one attribute assignment expected: {content}"
6008        );
6009    }
6010
6011    #[test]
6012    fn test_attribute_object_value_is_escaped() {
6013        let mut fragments = HashMap::new();
6014        fragments.insert(
6015            "index.html".to_string(),
6016            FragmentList {
6017                fragments: vec![
6018                    WebUIFragment::raw("<div"),
6019                    WebUIFragment::attribute("data-cfg", "cfg"),
6020                    WebUIFragment::raw("></div>"),
6021                ],
6022                contains_boundary: false,
6023            },
6024        );
6025        let protocol = WebUIProtocol::new(fragments);
6026        let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
6027        let mut writer = TestWriter::new();
6028        handle(
6029            &protocol,
6030            &state,
6031            &RenderOptions::new("index.html", "/"),
6032            &mut writer,
6033        )
6034        .unwrap();
6035        let content = writer.get_content();
6036        assert!(
6037            content.contains("&quot;"),
6038            "Double quotes inside attribute value must be escaped: {content}"
6039        );
6040        assert_eq!(
6041            content.matches("=\"").count(),
6042            1,
6043            "Only one attribute assignment expected: {content}"
6044        );
6045    }
6046
6047    // ── Template attribute rendering tests ────────────────────────────
6048
6049    #[test]
6050    fn test_mixed_attribute_template() {
6051        let mut fragments = HashMap::new();
6052        fragments.insert(
6053            "index.html".to_string(),
6054            FragmentList {
6055                fragments: vec![
6056                    WebUIFragment::raw("<input"),
6057                    WebUIFragment::attribute_template("value", "attr-1"),
6058                    WebUIFragment::raw(">"),
6059                ],
6060                contains_boundary: false,
6061            },
6062        );
6063        fragments.insert(
6064            "attr-1".to_string(),
6065            FragmentList {
6066                fragments: vec![
6067                    WebUIFragment::raw("hello "),
6068                    WebUIFragment::signal("item", false),
6069                ],
6070                contains_boundary: false,
6071            },
6072        );
6073        let protocol = WebUIProtocol::new(fragments);
6074        let state = test_json!({"item": "world"});
6075        let mut writer = TestWriter::new();
6076        handle(
6077            &protocol,
6078            &state,
6079            &RenderOptions::new("index.html", "/"),
6080            &mut writer,
6081        )
6082        .unwrap();
6083        assert_eq!(writer.get_content(), "<input value=\"hello world\">");
6084    }
6085
6086    // ── Raw signal rendering test ─────────────────────────────────────
6087
6088    #[test]
6089    fn test_raw_signal_not_escaped() {
6090        let mut fragments = HashMap::new();
6091        fragments.insert(
6092            "index.html".to_string(),
6093            FragmentList {
6094                fragments: vec![
6095                    WebUIFragment::signal("html", false),
6096                    WebUIFragment::signal("html", true),
6097                ],
6098                contains_boundary: false,
6099            },
6100        );
6101        let protocol = WebUIProtocol::new(fragments);
6102        let state = test_json!({"html": "<strong>hi</strong>"});
6103        let mut writer = TestWriter::new();
6104        handle(
6105            &protocol,
6106            &state,
6107            &RenderOptions::new("index.html", "/"),
6108            &mut writer,
6109        )
6110        .unwrap();
6111        assert_eq!(
6112            writer.get_content(),
6113            "&lt;strong&gt;hi&lt;&#x2F;strong&gt;<strong>hi</strong>"
6114        );
6115    }
6116
6117    // ── Nested for loop tests ─────────────────────────────────────────
6118
6119    fn render_named_for(template: &str, state: &Value) -> String {
6120        let mut parser = HtmlParser::with_options(DomStrategy::Light);
6121        parser.parse("index.html", template).unwrap();
6122        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
6123        let mut writer = TestWriter::new();
6124        WebUIHandler::new()
6125            .render(
6126                &protocol,
6127                state,
6128                &RenderOptions::new("index.html", "/"),
6129                &mut writer,
6130            )
6131            .unwrap();
6132        writer.get_content()
6133    }
6134
6135    #[test]
6136    fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
6137        let source = r#"<ul><for each="child in items" id="tree-item"><li>{{child.name}}</li><if condition="{{child.children}}"><ul><for id="tree-item" each="child in child.children" /></ul></if><i>{{child.name}}</i></for></ul><b>{{child.name}}</b>"#;
6138        let state = test_json!({
6139            "child": { "name": "global" },
6140            "items": [
6141                { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
6142                { "name": "Name", "children": [] },
6143                { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
6144            ]
6145        });
6146        assert_eq!(render_named_for(source, &state), "<ul><li>Colors</li><ul><li>Ali</li><i>Ali</i><li>Alice</li><i>Alice</i><li>Bob</li><i>Bob</i></ul><i>Colors</i><li>Name</li><i>Name</i><li>Hobbies</li><ul><li>Sports</li><ul><li>Futbol</li><i>Futbol</i><li>Cricket</li><i>Cricket</i></ul><i>Sports</i></ul><i>Hobbies</i></ul><b>global</b>");
6147    }
6148
6149    #[test]
6150    fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
6151        let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
6152        let state = test_json!({
6153            "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
6154        });
6155        assert_eq!(render_named_for(source, &state), "abbcca");
6156        assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
6157    }
6158
6159    #[test]
6160    fn named_for_forward_and_mutual_references_follow_finite_data() {
6161        let source = r#"<for id="a" each="item in items" /><for id="a" each="item in empty"><b>{{item.name}}</b><for id="b" each="item in item.children" /></for><for id="b" each="item in empty"><i>{{item.name}}</i><for id="a" each="item in item.children" /></for>"#;
6162        let state = test_json!({
6163            "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
6164            "empty": []
6165        });
6166        assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
6167    }
6168
6169    #[test]
6170    fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
6171        let source = r#"<for each="group in groups"><for id="tree" each="item in group.items">{{group.name}}:{{item.name}};<for id="tree" each="item in item.children" /></for></for>"#;
6172        let state = test_json!({
6173            "groups": [
6174                { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
6175                { "name": "two", "items": [{ "name": "last" }] }
6176            ]
6177        });
6178        assert_eq!(
6179            render_named_for(source, &state),
6180            "one:&lt;root&gt;;one:&amp;leaf;two:last;"
6181        );
6182    }
6183
6184    #[test]
6185    fn named_for_non_array_collection_returns_the_existing_type_error() {
6186        let mut parser = HtmlParser::new();
6187        parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
6188        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
6189        let mut writer = TestWriter::new();
6190        let error = WebUIHandler::new()
6191            .render(
6192                &protocol,
6193                &test_json!({"items": [{"children": 1}]}),
6194                &RenderOptions::new("index.html", "/"),
6195                &mut writer,
6196            )
6197            .unwrap_err();
6198        assert!(matches!(error, HandlerError::TypeError(_)));
6199    }
6200
6201    #[test]
6202    fn test_nested_for_loop() {
6203        let mut fragments = HashMap::new();
6204        fragments.insert(
6205            "index.html".to_string(),
6206            FragmentList {
6207                fragments: vec![
6208                    WebUIFragment::raw("<div>"),
6209                    WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
6210                    WebUIFragment::raw("</div>"),
6211                ],
6212                contains_boundary: false,
6213            },
6214        );
6215        fragments.insert(
6216            "outer".to_string(),
6217            FragmentList {
6218                fragments: vec![
6219                    WebUIFragment::raw("<div>"),
6220                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
6221                    WebUIFragment::raw("</div>"),
6222                ],
6223                contains_boundary: false,
6224            },
6225        );
6226        fragments.insert(
6227            "inner".to_string(),
6228            FragmentList {
6229                fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
6230                contains_boundary: false,
6231            },
6232        );
6233        let protocol = WebUIProtocol::new(fragments);
6234        let state = test_json!({
6235            "outerItems": [
6236                {"innerItems": [{"name": "A"}, {"name": "B"}]},
6237                {"innerItems": [{"name": "C"}]}
6238            ]
6239        });
6240        let mut writer = TestWriter::new();
6241        handle(
6242            &protocol,
6243            &state,
6244            &RenderOptions::new("index.html", "/"),
6245            &mut writer,
6246        )
6247        .unwrap();
6248        assert_eq!(
6249            writer.get_content(),
6250            "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
6251        );
6252    }
6253
6254    #[test]
6255    fn test_nested_for_with_signals() {
6256        let mut fragments = HashMap::new();
6257        fragments.insert(
6258            "index.html".to_string(),
6259            FragmentList {
6260                fragments: vec![
6261                    WebUIFragment::raw("<div>"),
6262                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6263                    WebUIFragment::raw("</div>"),
6264                ],
6265                contains_boundary: false,
6266            },
6267        );
6268        fragments.insert(
6269            "outerTemplate".to_string(),
6270            FragmentList {
6271                fragments: vec![
6272                    WebUIFragment::raw("<div>"),
6273                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6274                    WebUIFragment::raw("</div>"),
6275                ],
6276                contains_boundary: false,
6277            },
6278        );
6279        fragments.insert(
6280            "innerTemplate".to_string(),
6281            FragmentList {
6282                fragments: vec![
6283                    WebUIFragment::raw("<span>"),
6284                    WebUIFragment::signal("innerItem.name", false),
6285                    WebUIFragment::raw("</span>"),
6286                ],
6287                contains_boundary: false,
6288            },
6289        );
6290        let protocol = WebUIProtocol::new(fragments);
6291        let state = test_json!({
6292            "outerItems": [
6293                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6294                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
6295            ]
6296        });
6297        let mut writer = TestWriter::new();
6298        handle(
6299            &protocol,
6300            &state,
6301            &RenderOptions::new("index.html", "/"),
6302            &mut writer,
6303        )
6304        .unwrap();
6305        assert_eq!(
6306            writer.get_content(),
6307            "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
6308        );
6309    }
6310
6311    #[test]
6312    fn test_nested_for_with_global_state() {
6313        let mut fragments = HashMap::new();
6314        fragments.insert(
6315            "index.html".to_string(),
6316            FragmentList {
6317                fragments: vec![
6318                    WebUIFragment::raw("<div>"),
6319                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6320                    WebUIFragment::raw("</div>"),
6321                ],
6322                contains_boundary: false,
6323            },
6324        );
6325        fragments.insert(
6326            "outerTemplate".to_string(),
6327            FragmentList {
6328                fragments: vec![
6329                    WebUIFragment::raw("<div>"),
6330                    WebUIFragment::signal("globalOuter", false),
6331                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6332                    WebUIFragment::raw("</div>"),
6333                ],
6334                contains_boundary: false,
6335            },
6336        );
6337        fragments.insert(
6338            "innerTemplate".to_string(),
6339            FragmentList {
6340                fragments: vec![
6341                    WebUIFragment::raw("<span>"),
6342                    WebUIFragment::signal("innerItem.name", false),
6343                    WebUIFragment::signal("globalInner", false),
6344                    WebUIFragment::raw("</span>"),
6345                ],
6346                contains_boundary: false,
6347            },
6348        );
6349        let protocol = WebUIProtocol::new(fragments);
6350        let state = test_json!({
6351            "globalOuter": "GO",
6352            "globalInner": "GI",
6353            "outerItems": [
6354                {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6355                {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
6356            ]
6357        });
6358        let mut writer = TestWriter::new();
6359        handle(
6360            &protocol,
6361            &state,
6362            &RenderOptions::new("index.html", "/"),
6363            &mut writer,
6364        )
6365        .unwrap();
6366        assert_eq!(
6367            writer.get_content(),
6368            "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
6369        );
6370    }
6371
6372    // ── For + If state scoping tests ──────────────────────────────────
6373
6374    #[test]
6375    fn test_if_in_for_uses_local_state() {
6376        let mut fragments = HashMap::new();
6377        fragments.insert(
6378            "index.html".to_string(),
6379            FragmentList {
6380                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6381                contains_boundary: false,
6382            },
6383        );
6384        fragments.insert(
6385            "item-tpl".to_string(),
6386            FragmentList {
6387                fragments: vec![WebUIFragment::if_cond(
6388                    ConditionExpr::identifier("item.visible"),
6389                    "visible-tpl",
6390                )],
6391                contains_boundary: false,
6392            },
6393        );
6394        fragments.insert(
6395            "visible-tpl".to_string(),
6396            FragmentList {
6397                fragments: vec![WebUIFragment::signal("item.name", false)],
6398                contains_boundary: false,
6399            },
6400        );
6401        let protocol = WebUIProtocol::new(fragments);
6402        let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
6403        let mut writer = TestWriter::new();
6404        handle(
6405            &protocol,
6406            &state,
6407            &RenderOptions::new("index.html", "/"),
6408            &mut writer,
6409        )
6410        .unwrap();
6411        assert_eq!(writer.get_content(), "Show");
6412    }
6413
6414    #[test]
6415    fn test_for_if_local_overrides_global() {
6416        let mut fragments = HashMap::new();
6417        fragments.insert(
6418            "index.html".to_string(),
6419            FragmentList {
6420                fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6421                contains_boundary: false,
6422            },
6423        );
6424        fragments.insert(
6425            "item-tpl".to_string(),
6426            FragmentList {
6427                fragments: vec![WebUIFragment::if_cond(
6428                    ConditionExpr::identifier("item.flag"),
6429                    "show-tpl",
6430                )],
6431                contains_boundary: false,
6432            },
6433        );
6434        fragments.insert(
6435            "show-tpl".to_string(),
6436            FragmentList {
6437                fragments: vec![WebUIFragment::raw("yes")],
6438                contains_boundary: false,
6439            },
6440        );
6441        let protocol = WebUIProtocol::new(fragments);
6442        // Global flag is true, but local item.flag is false for second item
6443        let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6444        let mut writer = TestWriter::new();
6445        handle(
6446            &protocol,
6447            &state,
6448            &RenderOptions::new("index.html", "/"),
6449            &mut writer,
6450        )
6451        .unwrap();
6452        assert_eq!(writer.get_content(), "yes");
6453    }
6454
6455    // ── Component attribute state tests ───────────────────────────────
6456
6457    #[test]
6458    fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6459        let cases = [
6460            (
6461                r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6462                test_json!({}),
6463                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6464            ),
6465            (
6466                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6467                test_json!({"open": true}),
6468                r#"<dialog open aria-label="Canvas information"></dialog>"#,
6469            ),
6470            (
6471                r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6472                test_json!({"open": false}),
6473                r#"<dialog aria-label="Canvas information"></dialog>"#,
6474            ),
6475            (
6476                r#"<my-drawer aria-label=""></my-drawer>"#,
6477                test_json!({"ariaLabel": "Fallback"}),
6478                r#"<dialog aria-label=""></dialog>"#,
6479            ),
6480            (
6481                r#"<my-drawer open aria-label="Canvas &amp; information"></my-drawer>"#,
6482                test_json!({}),
6483                r#"<dialog open aria-label="Canvas &amp; information"></dialog>"#,
6484            ),
6485        ];
6486        for (input, state, expected) in cases {
6487            let mut parser = HtmlParser::with_options(DomStrategy::Light);
6488            parser
6489                .component_registry_mut()
6490                .register_component(ComponentRegistration::new(
6491                    "my-drawer",
6492                    r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6493                    None,
6494                    true,
6495                ))
6496                .unwrap();
6497            parser.parse("index.html", input).unwrap();
6498            let protocol = WebUIProtocol::new(parser.into_fragment_records());
6499            let mut writer = TestWriter::new();
6500            handle(
6501                &protocol,
6502                &state,
6503                &RenderOptions::new("index.html", "/"),
6504                &mut writer,
6505            )
6506            .unwrap();
6507            assert!(
6508                writer.get_content().contains(expected),
6509                "{input}: {}",
6510                writer.get_content()
6511            );
6512        }
6513    }
6514
6515    #[test]
6516    fn test_component_attr_state_simple() {
6517        let mut fragments = HashMap::new();
6518        fragments.insert(
6519            "index.html".to_string(),
6520            FragmentList {
6521                fragments: vec![
6522                    WebUIFragment::raw("<my-comp"),
6523                    WebUIFragment {
6524                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6525                            WebUIFragmentAttribute {
6526                                name: "title".into(),
6527                                value: "Attribute Title".into(),
6528                                attr_start: true,
6529                                raw_value: true,
6530                                ..Default::default()
6531                            },
6532                        )),
6533                    },
6534                    WebUIFragment::raw(">"),
6535                    WebUIFragment::component("my-comp"),
6536                    WebUIFragment::raw("</my-comp>"),
6537                ],
6538
6539                contains_boundary: false,
6540            },
6541        );
6542        fragments.insert(
6543            "my-comp".to_string(),
6544            FragmentList {
6545                fragments: vec![
6546                    WebUIFragment::raw("<span>"),
6547                    WebUIFragment::signal("title", false),
6548                    WebUIFragment::raw("</span>"),
6549                ],
6550                contains_boundary: false,
6551            },
6552        );
6553        let protocol = WebUIProtocol::new(fragments);
6554        let state = test_json!({"title": "Global Title"});
6555        let mut writer = TestWriter::new();
6556        handle(
6557            &protocol,
6558            &state,
6559            &RenderOptions::new("index.html", "/"),
6560            &mut writer,
6561        )
6562        .unwrap();
6563        assert_eq!(
6564            writer.get_content(),
6565            "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6566        );
6567    }
6568
6569    #[test]
6570    fn test_component_attr_state_template() {
6571        let mut fragments = HashMap::new();
6572        fragments.insert(
6573            "index.html".to_string(),
6574            FragmentList {
6575                fragments: vec![
6576                    WebUIFragment::raw("<my-comp"),
6577                    WebUIFragment {
6578                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6579                            WebUIFragmentAttribute {
6580                                name: "title".into(),
6581                                template: "title-attr".into(),
6582                                attr_start: true,
6583                                ..Default::default()
6584                            },
6585                        )),
6586                    },
6587                    WebUIFragment::raw(">"),
6588                    WebUIFragment::component("my-comp"),
6589                    WebUIFragment::raw("</my-comp>"),
6590                ],
6591
6592                contains_boundary: false,
6593            },
6594        );
6595        fragments.insert(
6596            "title-attr".to_string(),
6597            FragmentList {
6598                fragments: vec![
6599                    WebUIFragment::raw("hello "),
6600                    WebUIFragment::signal("item", false),
6601                ],
6602                contains_boundary: false,
6603            },
6604        );
6605        fragments.insert(
6606            "my-comp".to_string(),
6607            FragmentList {
6608                fragments: vec![
6609                    WebUIFragment::raw("<span>"),
6610                    WebUIFragment::signal("title", false),
6611                    WebUIFragment::raw("</span>"),
6612                ],
6613                contains_boundary: false,
6614            },
6615        );
6616        let protocol = WebUIProtocol::new(fragments);
6617        let state = test_json!({"item": "<world>"});
6618        let mut writer = TestWriter::new();
6619        handle(
6620            &protocol,
6621            &state,
6622            &RenderOptions::new("index.html", "/"),
6623            &mut writer,
6624        )
6625        .unwrap();
6626        assert_eq!(
6627            writer.get_content(),
6628            "<my-comp title=\"hello &lt;world&gt;\"><span>hello &lt;world&gt;</span></my-comp>"
6629        );
6630    }
6631
6632    #[test]
6633    fn test_component_attr_camel_case() {
6634        let mut fragments = HashMap::new();
6635        fragments.insert(
6636            "index.html".to_string(),
6637            FragmentList {
6638                fragments: vec![
6639                    WebUIFragment::raw("<my-comp"),
6640                    WebUIFragment {
6641                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6642                            WebUIFragmentAttribute {
6643                                name: "data-title".into(),
6644                                template: "dt-attr".into(),
6645                                attr_start: true,
6646                                ..Default::default()
6647                            },
6648                        )),
6649                    },
6650                    WebUIFragment::raw(">"),
6651                    WebUIFragment::component("my-comp"),
6652                    WebUIFragment::raw("</my-comp>"),
6653                ],
6654
6655                contains_boundary: false,
6656            },
6657        );
6658        fragments.insert(
6659            "dt-attr".to_string(),
6660            FragmentList {
6661                fragments: vec![
6662                    WebUIFragment::raw("prefix "),
6663                    WebUIFragment::signal("item", false),
6664                ],
6665                contains_boundary: false,
6666            },
6667        );
6668        fragments.insert(
6669            "my-comp".to_string(),
6670            FragmentList {
6671                fragments: vec![
6672                    WebUIFragment::raw("<span>"),
6673                    WebUIFragment::signal("dataTitle", false),
6674                    WebUIFragment::raw("</span>"),
6675                ],
6676                contains_boundary: false,
6677            },
6678        );
6679        let protocol = WebUIProtocol::new(fragments);
6680        let state = test_json!({"item": "a&b"});
6681        let mut writer = TestWriter::new();
6682        handle(
6683            &protocol,
6684            &state,
6685            &RenderOptions::new("index.html", "/"),
6686            &mut writer,
6687        )
6688        .unwrap();
6689        assert_eq!(
6690            writer.get_content(),
6691            "<my-comp data-title=\"prefix a&amp;b\"><span>prefix a&amp;b</span></my-comp>"
6692        );
6693    }
6694
6695    #[test]
6696    fn test_component_complex_attr() {
6697        let mut fragments = HashMap::new();
6698        fragments.insert(
6699            "index.html".to_string(),
6700            FragmentList {
6701                fragments: vec![
6702                    WebUIFragment::raw("<my-comp"),
6703                    WebUIFragment {
6704                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6705                            WebUIFragmentAttribute {
6706                                name: ":item".into(),
6707                                value: "complexItem".into(),
6708                                attr_start: true,
6709                                complex: true,
6710                                ..Default::default()
6711                            },
6712                        )),
6713                    },
6714                    WebUIFragment::raw(">"),
6715                    WebUIFragment::component("my-comp"),
6716                    WebUIFragment::raw("</my-comp>"),
6717                ],
6718
6719                contains_boundary: false,
6720            },
6721        );
6722        fragments.insert(
6723            "my-comp".to_string(),
6724            FragmentList {
6725                fragments: vec![
6726                    WebUIFragment::raw("<span>"),
6727                    WebUIFragment::signal("item.foo", false),
6728                    WebUIFragment::raw("</span><p>"),
6729                    WebUIFragment::signal("item.bar", false),
6730                    WebUIFragment::raw("</p>"),
6731                ],
6732                contains_boundary: false,
6733            },
6734        );
6735        let protocol = WebUIProtocol::new(fragments);
6736        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6737        let mut writer = TestWriter::new();
6738        handle(
6739            &protocol,
6740            &state,
6741            &RenderOptions::new("index.html", "/"),
6742            &mut writer,
6743        )
6744        .unwrap();
6745        assert_eq!(
6746            writer.get_content(),
6747            "<my-comp><span>1</span><p>true</p></my-comp>"
6748        );
6749    }
6750
6751    #[test]
6752    fn test_component_no_parent_pollution() {
6753        let mut fragments = HashMap::new();
6754        fragments.insert(
6755            "index.html".to_string(),
6756            FragmentList {
6757                fragments: vec![
6758                    WebUIFragment::raw("<parent"),
6759                    WebUIFragment {
6760                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6761                            WebUIFragmentAttribute {
6762                                name: "var".into(),
6763                                value: "var".into(),
6764                                attr_start: true,
6765                                ..Default::default()
6766                            },
6767                        )),
6768                    },
6769                    WebUIFragment::raw(">"),
6770                    WebUIFragment::component("parent"),
6771                    WebUIFragment::raw("</parent>"),
6772                ],
6773
6774                contains_boundary: false,
6775            },
6776        );
6777        fragments.insert(
6778            "parent".to_string(),
6779            FragmentList {
6780                fragments: vec![
6781                    WebUIFragment::raw("Before: "),
6782                    WebUIFragment::signal("var", false),
6783                    WebUIFragment::raw("<child foo"),
6784                    WebUIFragment {
6785                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6786                            WebUIFragmentAttribute {
6787                                name: "var".into(),
6788                                value: "replaced".into(),
6789                                raw_value: true,
6790                                ..Default::default()
6791                            },
6792                        )),
6793                    },
6794                    WebUIFragment::raw(">"),
6795                    WebUIFragment::component("child"),
6796                    WebUIFragment::raw("Label</child>After: "),
6797                    WebUIFragment::signal("var", false),
6798                ],
6799
6800                contains_boundary: false,
6801            },
6802        );
6803        fragments.insert(
6804            "child".to_string(),
6805            FragmentList {
6806                fragments: vec![],
6807                contains_boundary: false,
6808            },
6809        );
6810        let protocol = WebUIProtocol::new(fragments);
6811        let state = test_json!({"var": "original<&"});
6812        let mut writer = TestWriter::new();
6813        handle(
6814            &protocol,
6815            &state,
6816            &RenderOptions::new("index.html", "/"),
6817            &mut writer,
6818        )
6819        .unwrap();
6820        assert_eq!(
6821            writer.get_content(),
6822            "<parent var=\"original&lt;&amp;\">Before: original&lt;&amp;<child foo var=\"replaced\">Label</child>After: original&lt;&amp;</parent>"
6823        );
6824    }
6825
6826    #[test]
6827    fn test_component_boolean_attr_state() {
6828        let mut fragments = HashMap::new();
6829        fragments.insert(
6830            "index.html".to_string(),
6831            FragmentList {
6832                fragments: vec![
6833                    WebUIFragment::raw("<my-comp"),
6834                    WebUIFragment {
6835                        fragment: Some(web_ui_fragment::Fragment::Attribute(
6836                            WebUIFragmentAttribute {
6837                                name: "disabled".into(),
6838                                attr_start: true,
6839                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6840                                ..Default::default()
6841                            },
6842                        )),
6843                    },
6844                    WebUIFragment::raw(">"),
6845                    WebUIFragment::component("my-comp"),
6846                    WebUIFragment::raw("</my-comp>"),
6847                ],
6848
6849                contains_boundary: false,
6850            },
6851        );
6852        fragments.insert(
6853            "my-comp".to_string(),
6854            FragmentList {
6855                fragments: vec![WebUIFragment::if_cond(
6856                    ConditionExpr::identifier("disabled"),
6857                    "show",
6858                )],
6859                contains_boundary: false,
6860            },
6861        );
6862        fragments.insert(
6863            "show".to_string(),
6864            FragmentList {
6865                fragments: vec![WebUIFragment::raw("disabled!")],
6866                contains_boundary: false,
6867            },
6868        );
6869        let protocol = WebUIProtocol::new(fragments);
6870        let state = test_json!({"isDisabled": true});
6871        let mut writer = TestWriter::new();
6872        handle(
6873            &protocol,
6874            &state,
6875            &RenderOptions::new("index.html", "/"),
6876            &mut writer,
6877        )
6878        .unwrap();
6879        assert_eq!(
6880            writer.get_content(),
6881            "<my-comp disabled>disabled!</my-comp>"
6882        );
6883    }
6884
6885    // ===== HTML Escape Tests (ported from utils.test.js escapeHtml) =====
6886
6887    /// Helper: render a signal value through the handler and return the escaped output.
6888    fn render_signal(value: &str) -> String {
6889        let mut fragments = HashMap::new();
6890        fragments.insert(
6891            "index.html".to_string(),
6892            FragmentList {
6893                fragments: vec![WebUIFragment::signal("v", false)],
6894                contains_boundary: false,
6895            },
6896        );
6897        let protocol = WebUIProtocol::new(fragments);
6898        let state = test_json!({"v": value});
6899        let mut writer = TestWriter::new();
6900        handle(
6901            &protocol,
6902            &state,
6903            &RenderOptions::new("index.html", "/"),
6904            &mut writer,
6905        )
6906        .unwrap();
6907        writer.get_content()
6908    }
6909
6910    #[test]
6911    fn test_escape_ampersand() {
6912        assert_eq!(render_signal("&"), "&amp;");
6913    }
6914
6915    #[test]
6916    fn test_escape_less_than() {
6917        assert_eq!(render_signal("<"), "&lt;");
6918    }
6919
6920    #[test]
6921    fn test_escape_greater_than() {
6922        assert_eq!(render_signal(">"), "&gt;");
6923    }
6924
6925    #[test]
6926    fn test_escape_double_quote() {
6927        assert_eq!(render_signal("\""), "&quot;");
6928    }
6929
6930    #[test]
6931    fn test_escape_single_quote() {
6932        // encode_safe escapes ' as &#x27;
6933        let result = render_signal("'");
6934        assert_eq!(
6935            result, "&#x27;",
6936            "Expected &#x27; for single quote, got: {result}"
6937        );
6938    }
6939
6940    #[test]
6941    fn test_escape_multiple_special_chars() {
6942        let result = render_signal("<script>alert('xss');</script>");
6943        assert!(
6944            result.contains("&lt;") && result.contains("&gt;"),
6945            "Expected escaped HTML, got: {}",
6946            result
6947        );
6948        assert!(
6949            !result.contains("<script>"),
6950            "Should not contain raw <script> tag"
6951        );
6952    }
6953
6954    #[test]
6955    fn test_escape_no_special_chars() {
6956        assert_eq!(render_signal("Hello World"), "Hello World");
6957    }
6958
6959    #[test]
6960    fn test_escape_empty_string() {
6961        assert_eq!(render_signal(""), "");
6962    }
6963
6964    #[test]
6965    fn test_escape_special_at_beginning() {
6966        let result = render_signal("<Hello");
6967        assert!(
6968            result.starts_with("&lt;"),
6969            "Expected &lt; at start, got: {}",
6970            result
6971        );
6972    }
6973
6974    #[test]
6975    fn test_escape_special_at_end() {
6976        let result = render_signal("Hello>");
6977        assert!(
6978            result.ends_with("&gt;"),
6979            "Expected &gt; at end, got: {}",
6980            result
6981        );
6982    }
6983
6984    #[test]
6985    fn test_escape_special_in_middle() {
6986        let result = render_signal("Hel&lo");
6987        assert!(
6988            result.contains("&amp;"),
6989            "Expected &amp; in middle, got: {}",
6990            result
6991        );
6992    }
6993
6994    // ── GROUP 5: Boolean Attribute Edge Cases ─────────────────────────
6995
6996    #[test]
6997    fn test_boolean_attr_truthy_values() {
6998        // checked: 1
6999        {
7000            let mut fragments = HashMap::new();
7001            fragments.insert(
7002                "index.html".to_string(),
7003                FragmentList {
7004                    fragments: vec![
7005                        WebUIFragment::raw("<input"),
7006                        WebUIFragment::attribute_boolean(
7007                            "checked",
7008                            ConditionExpr::identifier("checked"),
7009                        ),
7010                        WebUIFragment::raw(">"),
7011                    ],
7012                    contains_boundary: false,
7013                },
7014            );
7015            let protocol = WebUIProtocol::new(fragments);
7016            let state = test_json!({"checked": 1});
7017            let mut writer = TestWriter::new();
7018            handle(
7019                &protocol,
7020                &state,
7021                &RenderOptions::new("index.html", "/"),
7022                &mut writer,
7023            )
7024            .unwrap();
7025            assert_eq!(writer.get_content(), "<input checked>");
7026        }
7027        // checked: "yes"
7028        {
7029            let mut fragments = HashMap::new();
7030            fragments.insert(
7031                "index.html".to_string(),
7032                FragmentList {
7033                    fragments: vec![
7034                        WebUIFragment::raw("<input"),
7035                        WebUIFragment::attribute_boolean(
7036                            "checked",
7037                            ConditionExpr::identifier("checked"),
7038                        ),
7039                        WebUIFragment::raw(">"),
7040                    ],
7041                    contains_boundary: false,
7042                },
7043            );
7044            let protocol = WebUIProtocol::new(fragments);
7045            let state = test_json!({"checked": "yes"});
7046            let mut writer = TestWriter::new();
7047            handle(
7048                &protocol,
7049                &state,
7050                &RenderOptions::new("index.html", "/"),
7051                &mut writer,
7052            )
7053            .unwrap();
7054            assert_eq!(writer.get_content(), "<input checked>");
7055        }
7056        // checked: {} (empty object is truthy)
7057        {
7058            let mut fragments = HashMap::new();
7059            fragments.insert(
7060                "index.html".to_string(),
7061                FragmentList {
7062                    fragments: vec![
7063                        WebUIFragment::raw("<input"),
7064                        WebUIFragment::attribute_boolean(
7065                            "checked",
7066                            ConditionExpr::identifier("checked"),
7067                        ),
7068                        WebUIFragment::raw(">"),
7069                    ],
7070                    contains_boundary: false,
7071                },
7072            );
7073            let protocol = WebUIProtocol::new(fragments);
7074            let state = test_json!({"checked": {}});
7075            let mut writer = TestWriter::new();
7076            handle(
7077                &protocol,
7078                &state,
7079                &RenderOptions::new("index.html", "/"),
7080                &mut writer,
7081            )
7082            .unwrap();
7083            // Empty object is falsy in this expression evaluator
7084            assert_eq!(writer.get_content(), "<input>");
7085        }
7086        // checked: "false" (string "false" is truthy)
7087        {
7088            let mut fragments = HashMap::new();
7089            fragments.insert(
7090                "index.html".to_string(),
7091                FragmentList {
7092                    fragments: vec![
7093                        WebUIFragment::raw("<input"),
7094                        WebUIFragment::attribute_boolean(
7095                            "checked",
7096                            ConditionExpr::identifier("checked"),
7097                        ),
7098                        WebUIFragment::raw(">"),
7099                    ],
7100                    contains_boundary: false,
7101                },
7102            );
7103            let protocol = WebUIProtocol::new(fragments);
7104            let state = test_json!({"checked": "false"});
7105            let mut writer = TestWriter::new();
7106            handle(
7107                &protocol,
7108                &state,
7109                &RenderOptions::new("index.html", "/"),
7110                &mut writer,
7111            )
7112            .unwrap();
7113            assert_eq!(writer.get_content(), "<input checked>");
7114        }
7115    }
7116
7117    #[test]
7118    fn test_boolean_attr_falsy_values() {
7119        // checked: 0
7120        {
7121            let mut fragments = HashMap::new();
7122            fragments.insert(
7123                "index.html".to_string(),
7124                FragmentList {
7125                    fragments: vec![
7126                        WebUIFragment::raw("<input"),
7127                        WebUIFragment::attribute_boolean(
7128                            "checked",
7129                            ConditionExpr::identifier("checked"),
7130                        ),
7131                        WebUIFragment::raw(">"),
7132                    ],
7133                    contains_boundary: false,
7134                },
7135            );
7136            let protocol = WebUIProtocol::new(fragments);
7137            let state = test_json!({"checked": 0});
7138            let mut writer = TestWriter::new();
7139            handle(
7140                &protocol,
7141                &state,
7142                &RenderOptions::new("index.html", "/"),
7143                &mut writer,
7144            )
7145            .unwrap();
7146            assert_eq!(writer.get_content(), "<input>");
7147        }
7148        // checked: ""
7149        {
7150            let mut fragments = HashMap::new();
7151            fragments.insert(
7152                "index.html".to_string(),
7153                FragmentList {
7154                    fragments: vec![
7155                        WebUIFragment::raw("<input"),
7156                        WebUIFragment::attribute_boolean(
7157                            "checked",
7158                            ConditionExpr::identifier("checked"),
7159                        ),
7160                        WebUIFragment::raw(">"),
7161                    ],
7162                    contains_boundary: false,
7163                },
7164            );
7165            let protocol = WebUIProtocol::new(fragments);
7166            let state = test_json!({"checked": ""});
7167            let mut writer = TestWriter::new();
7168            handle(
7169                &protocol,
7170                &state,
7171                &RenderOptions::new("index.html", "/"),
7172                &mut writer,
7173            )
7174            .unwrap();
7175            assert_eq!(writer.get_content(), "<input>");
7176        }
7177        // checked: false
7178        {
7179            let mut fragments = HashMap::new();
7180            fragments.insert(
7181                "index.html".to_string(),
7182                FragmentList {
7183                    fragments: vec![
7184                        WebUIFragment::raw("<input"),
7185                        WebUIFragment::attribute_boolean(
7186                            "checked",
7187                            ConditionExpr::identifier("checked"),
7188                        ),
7189                        WebUIFragment::raw(">"),
7190                    ],
7191                    contains_boundary: false,
7192                },
7193            );
7194            let protocol = WebUIProtocol::new(fragments);
7195            let state = test_json!({"checked": false});
7196            let mut writer = TestWriter::new();
7197            handle(
7198                &protocol,
7199                &state,
7200                &RenderOptions::new("index.html", "/"),
7201                &mut writer,
7202            )
7203            .unwrap();
7204            assert_eq!(writer.get_content(), "<input>");
7205        }
7206        // no checked key at all
7207        {
7208            let mut fragments = HashMap::new();
7209            fragments.insert(
7210                "index.html".to_string(),
7211                FragmentList {
7212                    fragments: vec![
7213                        WebUIFragment::raw("<input"),
7214                        WebUIFragment::attribute_boolean(
7215                            "checked",
7216                            ConditionExpr::identifier("checked"),
7217                        ),
7218                        WebUIFragment::raw(">"),
7219                    ],
7220                    contains_boundary: false,
7221                },
7222            );
7223            let protocol = WebUIProtocol::new(fragments);
7224            let state = test_json!({});
7225            let mut writer = TestWriter::new();
7226            handle(
7227                &protocol,
7228                &state,
7229                &RenderOptions::new("index.html", "/"),
7230                &mut writer,
7231            )
7232            .unwrap();
7233            assert_eq!(writer.get_content(), "<input>");
7234        }
7235    }
7236
7237    #[test]
7238    fn test_boolean_attr_expression_true() {
7239        let mut fragments = HashMap::new();
7240        fragments.insert(
7241            "index.html".to_string(),
7242            FragmentList {
7243                fragments: vec![
7244                    WebUIFragment::raw("<button"),
7245                    WebUIFragment::attribute_boolean(
7246                        "disabled",
7247                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
7248                    ),
7249                    WebUIFragment::raw(">Click</button>"),
7250                ],
7251                contains_boundary: false,
7252            },
7253        );
7254        let protocol = WebUIProtocol::new(fragments);
7255        let state = test_json!({"itemCount": 5});
7256        let mut writer = TestWriter::new();
7257        handle(
7258            &protocol,
7259            &state,
7260            &RenderOptions::new("index.html", "/"),
7261            &mut writer,
7262        )
7263        .unwrap();
7264        assert_eq!(writer.get_content(), "<button disabled>Click</button>");
7265    }
7266
7267    #[test]
7268    fn test_boolean_attr_expression_false() {
7269        let mut fragments = HashMap::new();
7270        fragments.insert(
7271            "index.html".to_string(),
7272            FragmentList {
7273                fragments: vec![
7274                    WebUIFragment::raw("<button"),
7275                    WebUIFragment::attribute_boolean(
7276                        "disabled",
7277                        ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
7278                    ),
7279                    WebUIFragment::raw(">Click</button>"),
7280                ],
7281                contains_boundary: false,
7282            },
7283        );
7284        let protocol = WebUIProtocol::new(fragments);
7285        let state = test_json!({"itemCount": 3});
7286        let mut writer = TestWriter::new();
7287        handle(
7288            &protocol,
7289            &state,
7290            &RenderOptions::new("index.html", "/"),
7291            &mut writer,
7292        )
7293        .unwrap();
7294        assert_eq!(writer.get_content(), "<button>Click</button>");
7295    }
7296
7297    // ── GROUP 6: Mixed Attributes ─────────────────────────────────────
7298
7299    #[test]
7300    fn test_nested_component_attr_capture() {
7301        let mut fragments = HashMap::new();
7302        fragments.insert(
7303            "index.html".to_string(),
7304            FragmentList {
7305                fragments: vec![
7306                    WebUIFragment::raw("<parent-component"),
7307                    WebUIFragment {
7308                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7309                            WebUIFragmentAttribute {
7310                                name: "title".into(),
7311                                template: "parent-title".into(),
7312                                attr_start: true,
7313                                ..Default::default()
7314                            },
7315                        )),
7316                    },
7317                    WebUIFragment::raw(">"),
7318                    WebUIFragment::component("parent-component"),
7319                    WebUIFragment::raw("</parent-component>"),
7320                ],
7321
7322                contains_boundary: false,
7323            },
7324        );
7325        fragments.insert(
7326            "parent-title".to_string(),
7327            FragmentList {
7328                fragments: vec![
7329                    WebUIFragment::raw("Hello "),
7330                    WebUIFragment::signal("who", false),
7331                ],
7332                contains_boundary: false,
7333            },
7334        );
7335        fragments.insert(
7336            "parent-component".to_string(),
7337            FragmentList {
7338                fragments: vec![
7339                    WebUIFragment::raw("<child-component"),
7340                    WebUIFragment {
7341                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7342                            WebUIFragmentAttribute {
7343                                name: "title".into(),
7344                                template: "child-title".into(),
7345                                attr_start: true,
7346                                ..Default::default()
7347                            },
7348                        )),
7349                    },
7350                    WebUIFragment::raw(">"),
7351                    WebUIFragment::component("child-component"),
7352                    WebUIFragment::raw("</child-component>"),
7353                ],
7354
7355                contains_boundary: false,
7356            },
7357        );
7358        fragments.insert(
7359            "child-title".to_string(),
7360            FragmentList {
7361                fragments: vec![
7362                    WebUIFragment::raw("Child of "),
7363                    WebUIFragment::signal("title", false),
7364                ],
7365                contains_boundary: false,
7366            },
7367        );
7368        fragments.insert(
7369            "child-component".to_string(),
7370            FragmentList {
7371                fragments: vec![
7372                    WebUIFragment::raw("<span>"),
7373                    WebUIFragment::signal("title", false),
7374                    WebUIFragment::raw("</span>"),
7375                ],
7376                contains_boundary: false,
7377            },
7378        );
7379        let protocol = WebUIProtocol::new(fragments);
7380        let state = test_json!({"who": "<world>"});
7381        let mut writer = TestWriter::new();
7382        handle(
7383            &protocol,
7384            &state,
7385            &RenderOptions::new("index.html", "/"),
7386            &mut writer,
7387        )
7388        .unwrap();
7389        assert_eq!(
7390            writer.get_content(),
7391            "<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>"
7392        );
7393    }
7394
7395    #[test]
7396    fn test_grandchild_attr_propagation() {
7397        let mut fragments = HashMap::new();
7398        fragments.insert(
7399            "index.html".to_string(),
7400            FragmentList {
7401                fragments: vec![
7402                    WebUIFragment::raw("<parent-component"),
7403                    WebUIFragment {
7404                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7405                            WebUIFragmentAttribute {
7406                                name: "title".into(),
7407                                template: "p-title".into(),
7408                                attr_start: true,
7409                                ..Default::default()
7410                            },
7411                        )),
7412                    },
7413                    WebUIFragment::raw(">"),
7414                    WebUIFragment::component("parent-component"),
7415                    WebUIFragment::raw("</parent-component>"),
7416                ],
7417
7418                contains_boundary: false,
7419            },
7420        );
7421        fragments.insert(
7422            "p-title".to_string(),
7423            FragmentList {
7424                fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7425                contains_boundary: false,
7426            },
7427        );
7428        fragments.insert(
7429            "parent-component".to_string(),
7430            FragmentList {
7431                fragments: vec![
7432                    WebUIFragment::raw("<child-component"),
7433                    WebUIFragment {
7434                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7435                            WebUIFragmentAttribute {
7436                                name: "title".into(),
7437                                template: "c-title".into(),
7438                                attr_start: true,
7439                                ..Default::default()
7440                            },
7441                        )),
7442                    },
7443                    WebUIFragment::raw(">"),
7444                    WebUIFragment::component("child-component"),
7445                    WebUIFragment::raw("</child-component>"),
7446                ],
7447
7448                contains_boundary: false,
7449            },
7450        );
7451        fragments.insert(
7452            "c-title".to_string(),
7453            FragmentList {
7454                fragments: vec![
7455                    WebUIFragment::raw("C("),
7456                    WebUIFragment::signal("title", false),
7457                    WebUIFragment::raw(")-"),
7458                    WebUIFragment::signal("cExtra", false),
7459                ],
7460                contains_boundary: false,
7461            },
7462        );
7463        fragments.insert(
7464            "child-component".to_string(),
7465            FragmentList {
7466                fragments: vec![
7467                    WebUIFragment::raw("<grandchild-component"),
7468                    WebUIFragment {
7469                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7470                            WebUIFragmentAttribute {
7471                                name: "title".into(),
7472                                value: "title".into(),
7473                                attr_start: true,
7474                                ..Default::default()
7475                            },
7476                        )),
7477                    },
7478                    WebUIFragment::raw(">"),
7479                    WebUIFragment::component("grandchild-component"),
7480                    WebUIFragment::raw("</grandchild-component>"),
7481                ],
7482
7483                contains_boundary: false,
7484            },
7485        );
7486        fragments.insert(
7487            "grandchild-component".to_string(),
7488            FragmentList {
7489                fragments: vec![
7490                    WebUIFragment::raw("<span>"),
7491                    WebUIFragment::signal("title", false),
7492                    WebUIFragment::raw("</span>"),
7493                ],
7494                contains_boundary: false,
7495            },
7496        );
7497        let protocol = WebUIProtocol::new(fragments);
7498        let state = test_json!({"p": "<p>", "cExtra": "x&y"});
7499        let mut writer = TestWriter::new();
7500        handle(
7501            &protocol,
7502            &state,
7503            &RenderOptions::new("index.html", "/"),
7504            &mut writer,
7505        )
7506        .unwrap();
7507        assert_eq!(
7508            writer.get_content(),
7509            "<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>"
7510        );
7511    }
7512
7513    #[test]
7514    fn test_for_loop_component_attr() {
7515        let mut fragments = HashMap::new();
7516        fragments.insert(
7517            "index.html".to_string(),
7518            FragmentList {
7519                fragments: vec![
7520                    WebUIFragment::raw("<parent-component"),
7521                    WebUIFragment {
7522                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7523                            WebUIFragmentAttribute {
7524                                name: "title".into(),
7525                                template: "parent-title-loop".into(),
7526                                attr_start: true,
7527                                ..Default::default()
7528                            },
7529                        )),
7530                    },
7531                    WebUIFragment::raw(">"),
7532                    WebUIFragment::component("parent-component"),
7533                    WebUIFragment::raw("</parent-component>"),
7534                ],
7535
7536                contains_boundary: false,
7537            },
7538        );
7539        fragments.insert(
7540            "parent-title-loop".to_string(),
7541            FragmentList {
7542                fragments: vec![
7543                    WebUIFragment::raw("Parent:"),
7544                    WebUIFragment::signal("who", false),
7545                ],
7546                contains_boundary: false,
7547            },
7548        );
7549        fragments.insert(
7550            "parent-component".to_string(),
7551            FragmentList {
7552                fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7553                contains_boundary: false,
7554            },
7555        );
7556        fragments.insert(
7557            "child-loop".to_string(),
7558            FragmentList {
7559                fragments: vec![
7560                    WebUIFragment::raw("<child-component"),
7561                    WebUIFragment {
7562                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7563                            WebUIFragmentAttribute {
7564                                name: "title".into(),
7565                                template: "child-title-loop".into(),
7566                                attr_start: true,
7567                                ..Default::default()
7568                            },
7569                        )),
7570                    },
7571                    WebUIFragment::raw(">"),
7572                    WebUIFragment::component("child-component"),
7573                    WebUIFragment::raw("</child-component>"),
7574                ],
7575
7576                contains_boundary: false,
7577            },
7578        );
7579        fragments.insert(
7580            "child-title-loop".to_string(),
7581            FragmentList {
7582                fragments: vec![
7583                    WebUIFragment::raw("Hi "),
7584                    WebUIFragment::signal("item.name", false),
7585                    WebUIFragment::raw(" / "),
7586                    WebUIFragment::signal("title", false),
7587                ],
7588                contains_boundary: false,
7589            },
7590        );
7591        fragments.insert(
7592            "child-component".to_string(),
7593            FragmentList {
7594                fragments: vec![
7595                    WebUIFragment::raw("<span>"),
7596                    WebUIFragment::signal("title", false),
7597                    WebUIFragment::raw("</span>"),
7598                ],
7599                contains_boundary: false,
7600            },
7601        );
7602        let protocol = WebUIProtocol::new(fragments);
7603        let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7604        let mut writer = TestWriter::new();
7605        handle(
7606            &protocol,
7607            &state,
7608            &RenderOptions::new("index.html", "/"),
7609            &mut writer,
7610        )
7611        .unwrap();
7612        assert_eq!(
7613            writer.get_content(),
7614            "<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>"
7615        );
7616    }
7617
7618    #[test]
7619    fn test_multiple_template_attrs() {
7620        let mut fragments = HashMap::new();
7621        fragments.insert(
7622            "index.html".to_string(),
7623            FragmentList {
7624                fragments: vec![
7625                    WebUIFragment::raw("<my-component"),
7626                    WebUIFragment {
7627                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7628                            WebUIFragmentAttribute {
7629                                name: "title".into(),
7630                                template: "attr-title".into(),
7631                                attr_start: true,
7632                                ..Default::default()
7633                            },
7634                        )),
7635                    },
7636                    WebUIFragment {
7637                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7638                            WebUIFragmentAttribute {
7639                                name: "data-title".into(),
7640                                template: "attr-data-title".into(),
7641                                attr_start: false,
7642                                ..Default::default()
7643                            },
7644                        )),
7645                    },
7646                    WebUIFragment {
7647                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7648                            WebUIFragmentAttribute {
7649                                name: "aria-label".into(),
7650                                template: "attr-aria-label".into(),
7651                                attr_start: false,
7652                                ..Default::default()
7653                            },
7654                        )),
7655                    },
7656                    WebUIFragment::raw(">"),
7657                    WebUIFragment::component("my-component"),
7658                    WebUIFragment::raw("</my-component>"),
7659                ],
7660
7661                contains_boundary: false,
7662            },
7663        );
7664        fragments.insert(
7665            "attr-title".to_string(),
7666            FragmentList {
7667                fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7668                contains_boundary: false,
7669            },
7670        );
7671        fragments.insert(
7672            "attr-data-title".to_string(),
7673            FragmentList {
7674                fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7675                contains_boundary: false,
7676            },
7677        );
7678        fragments.insert(
7679            "attr-aria-label".to_string(),
7680            FragmentList {
7681                fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7682                contains_boundary: false,
7683            },
7684        );
7685        fragments.insert(
7686            "my-component".to_string(),
7687            FragmentList {
7688                fragments: vec![
7689                    WebUIFragment::raw("<span>"),
7690                    WebUIFragment::signal("title", false),
7691                    WebUIFragment::raw("|"),
7692                    WebUIFragment::signal("dataTitle", false),
7693                    WebUIFragment::raw("|"),
7694                    WebUIFragment::signal("ariaLabel", false),
7695                    WebUIFragment::raw("</span>"),
7696                ],
7697                contains_boundary: false,
7698            },
7699        );
7700        let protocol = WebUIProtocol::new(fragments);
7701        let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7702        let mut writer = TestWriter::new();
7703        handle(
7704            &protocol,
7705            &state,
7706            &RenderOptions::new("index.html", "/"),
7707            &mut writer,
7708        )
7709        .unwrap();
7710        assert_eq!(
7711            writer.get_content(),
7712            "<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>"
7713        );
7714    }
7715
7716    #[test]
7717    fn test_attr_priority_over_global() {
7718        let mut fragments = HashMap::new();
7719        fragments.insert(
7720            "index.html".to_string(),
7721            FragmentList {
7722                fragments: vec![
7723                    WebUIFragment::raw("<my-component"),
7724                    WebUIFragment {
7725                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7726                            WebUIFragmentAttribute {
7727                                name: "title".into(),
7728                                value: "Attribute Title".into(),
7729                                raw_value: true,
7730                                attr_start: true,
7731                                ..Default::default()
7732                            },
7733                        )),
7734                    },
7735                    WebUIFragment::raw(">"),
7736                    WebUIFragment::component("my-component"),
7737                    WebUIFragment::raw("</my-component>"),
7738                ],
7739
7740                contains_boundary: false,
7741            },
7742        );
7743        fragments.insert(
7744            "my-component".to_string(),
7745            FragmentList {
7746                fragments: vec![
7747                    WebUIFragment::raw("<span>"),
7748                    WebUIFragment::signal("title", false),
7749                    WebUIFragment::raw("</span>"),
7750                ],
7751                contains_boundary: false,
7752            },
7753        );
7754        let protocol = WebUIProtocol::new(fragments);
7755        let state = test_json!({"title": "Global Title"});
7756        let mut writer = TestWriter::new();
7757        handle(
7758            &protocol,
7759            &state,
7760            &RenderOptions::new("index.html", "/"),
7761            &mut writer,
7762        )
7763        .unwrap();
7764        assert_eq!(
7765            writer.get_content(),
7766            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7767        );
7768    }
7769
7770    #[test]
7771    fn test_attr_priority_over_local_and_global() {
7772        let mut fragments = HashMap::new();
7773        fragments.insert(
7774            "index.html".to_string(),
7775            FragmentList {
7776                fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7777                contains_boundary: false,
7778            },
7779        );
7780        fragments.insert(
7781            "loop".to_string(),
7782            FragmentList {
7783                fragments: vec![
7784                    WebUIFragment::raw("<my-component"),
7785                    WebUIFragment {
7786                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7787                            WebUIFragmentAttribute {
7788                                name: "title".into(),
7789                                value: "Attribute Title".into(),
7790                                raw_value: true,
7791                                attr_start: true,
7792                                ..Default::default()
7793                            },
7794                        )),
7795                    },
7796                    WebUIFragment::raw(">"),
7797                    WebUIFragment::component("my-component"),
7798                    WebUIFragment::raw("</my-component>"),
7799                ],
7800
7801                contains_boundary: false,
7802            },
7803        );
7804        fragments.insert(
7805            "my-component".to_string(),
7806            FragmentList {
7807                fragments: vec![
7808                    WebUIFragment::raw("<span>"),
7809                    WebUIFragment::signal("title", false),
7810                    WebUIFragment::raw("</span>"),
7811                ],
7812                contains_boundary: false,
7813            },
7814        );
7815        let protocol = WebUIProtocol::new(fragments);
7816        let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7817        let mut writer = TestWriter::new();
7818        handle(
7819            &protocol,
7820            &state,
7821            &RenderOptions::new("index.html", "/"),
7822            &mut writer,
7823        )
7824        .unwrap();
7825        assert_eq!(
7826            writer.get_content(),
7827            "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7828        );
7829    }
7830
7831    #[test]
7832    fn test_boolean_attr_first_component_attr() {
7833        let mut fragments = HashMap::new();
7834        fragments.insert(
7835            "index.html".to_string(),
7836            FragmentList {
7837                fragments: vec![
7838                    WebUIFragment::raw("<my-component"),
7839                    WebUIFragment {
7840                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7841                            WebUIFragmentAttribute {
7842                                name: "disabled".into(),
7843                                attr_start: true,
7844                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7845                                ..Default::default()
7846                            },
7847                        )),
7848                    },
7849                    WebUIFragment {
7850                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7851                            WebUIFragmentAttribute {
7852                                name: "label".into(),
7853                                value: "Component Label".into(),
7854                                raw_value: true,
7855                                attr_start: false,
7856                                ..Default::default()
7857                            },
7858                        )),
7859                    },
7860                    WebUIFragment::raw(">"),
7861                    WebUIFragment::component("my-component"),
7862                    WebUIFragment::raw("</my-component>"),
7863                ],
7864
7865                contains_boundary: false,
7866            },
7867        );
7868        fragments.insert(
7869            "my-component".to_string(),
7870            FragmentList {
7871                fragments: vec![
7872                    WebUIFragment::if_cond(
7873                        ConditionExpr::identifier("disabled"),
7874                        "disabledTemplate",
7875                    ),
7876                    WebUIFragment::raw("<span>"),
7877                    WebUIFragment::signal("label", false),
7878                    WebUIFragment::raw("</span>"),
7879                ],
7880                contains_boundary: false,
7881            },
7882        );
7883        fragments.insert(
7884            "disabledTemplate".to_string(),
7885            FragmentList {
7886                fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7887                contains_boundary: false,
7888            },
7889        );
7890        let protocol = WebUIProtocol::new(fragments);
7891        let state = test_json!({"isDisabled": true});
7892        let mut writer = TestWriter::new();
7893        handle(
7894            &protocol,
7895            &state,
7896            &RenderOptions::new("index.html", "/"),
7897            &mut writer,
7898        )
7899        .unwrap();
7900        assert_eq!(
7901            writer.get_content(),
7902            "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7903        );
7904    }
7905
7906    #[test]
7907    fn test_hyphenated_attr_camelcase() {
7908        let mut fragments = HashMap::new();
7909        fragments.insert(
7910            "index.html".to_string(),
7911            FragmentList {
7912                fragments: vec![
7913                    WebUIFragment::raw("<my-component"),
7914                    WebUIFragment {
7915                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7916                            WebUIFragmentAttribute {
7917                                name: "key-hyphen".into(),
7918                                value: "Local Value".into(),
7919                                raw_value: true,
7920                                attr_start: true,
7921                                ..Default::default()
7922                            },
7923                        )),
7924                    },
7925                    WebUIFragment::raw(">"),
7926                    WebUIFragment::component("my-component"),
7927                    WebUIFragment::raw("</my-component>"),
7928                ],
7929
7930                contains_boundary: false,
7931            },
7932        );
7933        fragments.insert(
7934            "my-component".to_string(),
7935            FragmentList {
7936                fragments: vec![
7937                    WebUIFragment::raw("<span>"),
7938                    WebUIFragment::signal("keyHyphen", false),
7939                    WebUIFragment::raw("</span>"),
7940                ],
7941                contains_boundary: false,
7942            },
7943        );
7944        let protocol = WebUIProtocol::new(fragments);
7945        let state = test_json!({"keyHyphen": "Global Value"});
7946        let mut writer = TestWriter::new();
7947        handle(
7948            &protocol,
7949            &state,
7950            &RenderOptions::new("index.html", "/"),
7951            &mut writer,
7952        )
7953        .unwrap();
7954        assert_eq!(
7955            writer.get_content(),
7956            "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7957        );
7958    }
7959
7960    #[test]
7961    fn test_skipped_component_attrs() {
7962        // Skipped attributes: class, style, role, data-*, aria-*
7963        // Plus framework-specific prefixes/names that the parser marks with attr_skip.
7964        // These render on the HTML element but are NOT passed into component attribute state.
7965        let mut fragments = HashMap::new();
7966        fragments.insert(
7967            "index.html".to_string(),
7968            FragmentList {
7969                fragments: vec![
7970                    WebUIFragment::raw("<test-component"),
7971                    // Skipped: class
7972                    WebUIFragment {
7973                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7974                            WebUIFragmentAttribute {
7975                                name: "class".into(),
7976                                value: "skippedClass".into(),
7977                                attr_start: true,
7978                                attr_skip: true,
7979                                ..Default::default()
7980                            },
7981                        )),
7982                    },
7983                    // Skipped: style
7984                    WebUIFragment {
7985                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7986                            WebUIFragmentAttribute {
7987                                name: "style".into(),
7988                                value: "skippedStyle".into(),
7989                                attr_skip: true,
7990                                ..Default::default()
7991                            },
7992                        )),
7993                    },
7994                    // Skipped: role
7995                    WebUIFragment {
7996                        fragment: Some(web_ui_fragment::Fragment::Attribute(
7997                            WebUIFragmentAttribute {
7998                                name: "role".into(),
7999                                value: "skippedRole".into(),
8000                                attr_skip: true,
8001                                ..Default::default()
8002                            },
8003                        )),
8004                    },
8005                    // Skipped: data-testid (data-* prefix)
8006                    WebUIFragment {
8007                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8008                            WebUIFragmentAttribute {
8009                                name: "data-testid".into(),
8010                                value: "skippedDataTestid".into(),
8011                                attr_skip: true,
8012                                ..Default::default()
8013                            },
8014                        )),
8015                    },
8016                    // Skipped: aria-label (aria-* prefix)
8017                    WebUIFragment {
8018                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8019                            WebUIFragmentAttribute {
8020                                name: "aria-label".into(),
8021                                value: "skippedAriaLabel".into(),
8022                                attr_skip: true,
8023                                ..Default::default()
8024                            },
8025                        )),
8026                    },
8027                    // NOT skipped: title
8028                    WebUIFragment {
8029                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8030                            WebUIFragmentAttribute {
8031                                name: "title".into(),
8032                                value: "title".into(),
8033                                ..Default::default()
8034                            },
8035                        )),
8036                    },
8037                    WebUIFragment::raw(">"),
8038                    WebUIFragment::component("test-component"),
8039                    WebUIFragment::raw("</test-component>"),
8040                ],
8041
8042                contains_boundary: false,
8043            },
8044        );
8045        fragments.insert(
8046            "test-component".to_string(),
8047            FragmentList {
8048                fragments: vec![
8049                    WebUIFragment::raw("<span>"),
8050                    WebUIFragment::signal("title", false),
8051                    WebUIFragment::raw("-"),
8052                    WebUIFragment::signal("class", false),
8053                    WebUIFragment::raw("-"),
8054                    WebUIFragment::signal("style", false),
8055                    WebUIFragment::raw("-"),
8056                    WebUIFragment::signal("role", false),
8057                    WebUIFragment::raw("-"),
8058                    WebUIFragment::signal("dataTestid", false),
8059                    WebUIFragment::raw("-"),
8060                    WebUIFragment::signal("ariaLabel", false),
8061                    WebUIFragment::raw("</span>"),
8062                ],
8063                contains_boundary: false,
8064            },
8065        );
8066        let protocol = WebUIProtocol::new(fragments);
8067        let state = test_json!({
8068            "title": "Hello",
8069            "skippedClass": "my-class",
8070            "skippedStyle": "color:red",
8071            "skippedRole": "button",
8072            "skippedDataTestid": "test-id",
8073            "skippedAriaLabel": "label-text"
8074        });
8075        let mut writer = TestWriter::new();
8076        handle(
8077            &protocol,
8078            &state,
8079            &RenderOptions::new("index.html", "/"),
8080            &mut writer,
8081        )
8082        .unwrap();
8083        // Skipped attrs render on the element but their values are NOT accessible inside the component.
8084        // The component's signals for skipped attrs resolve to empty strings.
8085        // Only "title" (non-skipped) is accessible.
8086        assert_eq!(
8087            writer.get_content(),
8088            "<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>"
8089        );
8090    }
8091
8092    // ── GROUP 7: Attribute Inheritance ─────────────────────────────────
8093
8094    #[test]
8095    fn test_attr_inherit_parent_to_child() {
8096        let mut fragments = HashMap::new();
8097        fragments.insert(
8098            "index.html".to_string(),
8099            FragmentList {
8100                fragments: vec![
8101                    WebUIFragment::raw("<parent-component"),
8102                    WebUIFragment {
8103                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8104                            WebUIFragmentAttribute {
8105                                name: "title".into(),
8106                                value: "Parent Title".into(),
8107                                raw_value: true,
8108                                attr_start: true,
8109                                ..Default::default()
8110                            },
8111                        )),
8112                    },
8113                    WebUIFragment::raw(">"),
8114                    WebUIFragment::component("parent-component"),
8115                    WebUIFragment::raw("</parent-component>"),
8116                ],
8117
8118                contains_boundary: false,
8119            },
8120        );
8121        fragments.insert(
8122            "parent-component".to_string(),
8123            FragmentList {
8124                fragments: vec![
8125                    WebUIFragment::raw("<h1>"),
8126                    WebUIFragment::signal("title", false),
8127                    WebUIFragment::raw("</h1><child-component"),
8128                    WebUIFragment {
8129                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8130                            WebUIFragmentAttribute {
8131                                name: "title".into(),
8132                                value: "title".into(),
8133                                attr_start: true,
8134                                ..Default::default()
8135                            },
8136                        )),
8137                    },
8138                    WebUIFragment::raw(">"),
8139                    WebUIFragment::component("child-component"),
8140                    WebUIFragment::raw("</child-component>"),
8141                ],
8142
8143                contains_boundary: false,
8144            },
8145        );
8146        fragments.insert(
8147            "child-component".to_string(),
8148            FragmentList {
8149                fragments: vec![
8150                    WebUIFragment::raw("<h2>"),
8151                    WebUIFragment::signal("title", false),
8152                    WebUIFragment::raw("</h2>"),
8153                ],
8154                contains_boundary: false,
8155            },
8156        );
8157        let protocol = WebUIProtocol::new(fragments);
8158        let state = test_json!({});
8159        let mut writer = TestWriter::new();
8160        handle(
8161            &protocol,
8162            &state,
8163            &RenderOptions::new("index.html", "/"),
8164            &mut writer,
8165        )
8166        .unwrap();
8167        assert_eq!(
8168            writer.get_content(),
8169            "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
8170        );
8171    }
8172
8173    #[test]
8174    fn test_attr_inherit_deep() {
8175        let mut fragments = HashMap::new();
8176        fragments.insert(
8177            "index.html".to_string(),
8178            FragmentList {
8179                fragments: vec![
8180                    WebUIFragment::raw("<parent-component"),
8181                    WebUIFragment {
8182                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8183                            WebUIFragmentAttribute {
8184                                name: "title".into(),
8185                                value: "Parent Title".into(),
8186                                raw_value: true,
8187                                attr_start: true,
8188                                ..Default::default()
8189                            },
8190                        )),
8191                    },
8192                    WebUIFragment::raw(">"),
8193                    WebUIFragment::component("parent-component"),
8194                    WebUIFragment::raw("</parent-component>"),
8195                ],
8196
8197                contains_boundary: false,
8198            },
8199        );
8200        fragments.insert(
8201            "parent-component".to_string(),
8202            FragmentList {
8203                fragments: vec![
8204                    WebUIFragment::raw("<child-component"),
8205                    WebUIFragment {
8206                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8207                            WebUIFragmentAttribute {
8208                                name: "title".into(),
8209                                value: "Child Title".into(),
8210                                raw_value: true,
8211                                attr_start: true,
8212                                ..Default::default()
8213                            },
8214                        )),
8215                    },
8216                    WebUIFragment::raw(">"),
8217                    WebUIFragment::component("child-component"),
8218                    WebUIFragment::raw("</child-component>"),
8219                ],
8220
8221                contains_boundary: false,
8222            },
8223        );
8224        fragments.insert(
8225            "child-component".to_string(),
8226            FragmentList {
8227                fragments: vec![
8228                    WebUIFragment::raw("<grandchild-component"),
8229                    WebUIFragment {
8230                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8231                            WebUIFragmentAttribute {
8232                                name: "title".into(),
8233                                value: "title".into(),
8234                                attr_start: true,
8235                                ..Default::default()
8236                            },
8237                        )),
8238                    },
8239                    WebUIFragment::raw(">"),
8240                    WebUIFragment::component("grandchild-component"),
8241                    WebUIFragment::raw("</grandchild-component>"),
8242                ],
8243
8244                contains_boundary: false,
8245            },
8246        );
8247        fragments.insert(
8248            "grandchild-component".to_string(),
8249            FragmentList {
8250                fragments: vec![
8251                    WebUIFragment::raw("<h3>"),
8252                    WebUIFragment::signal("title", false),
8253                    WebUIFragment::raw("</h3>"),
8254                ],
8255                contains_boundary: false,
8256            },
8257        );
8258        let protocol = WebUIProtocol::new(fragments);
8259        let state = test_json!({});
8260        let mut writer = TestWriter::new();
8261        handle(
8262            &protocol,
8263            &state,
8264            &RenderOptions::new("index.html", "/"),
8265            &mut writer,
8266        )
8267        .unwrap();
8268        assert_eq!(
8269            writer.get_content(),
8270            "<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>"
8271        );
8272    }
8273
8274    #[test]
8275    fn test_complex_attr_access() {
8276        let mut fragments = HashMap::new();
8277        fragments.insert(
8278            "index.html".to_string(),
8279            FragmentList {
8280                fragments: vec![
8281                    WebUIFragment::raw("<my-component"),
8282                    WebUIFragment {
8283                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8284                            WebUIFragmentAttribute {
8285                                name: ":item".into(),
8286                                value: "complexItem".into(),
8287                                attr_start: true,
8288                                complex: true,
8289                                ..Default::default()
8290                            },
8291                        )),
8292                    },
8293                    WebUIFragment::raw(">"),
8294                    WebUIFragment::component("my-component"),
8295                    WebUIFragment::raw("</my-component>"),
8296                ],
8297
8298                contains_boundary: false,
8299            },
8300        );
8301        fragments.insert(
8302            "my-component".to_string(),
8303            FragmentList {
8304                fragments: vec![
8305                    WebUIFragment::raw("<span>"),
8306                    WebUIFragment::signal("item.foo", false),
8307                    WebUIFragment::raw("</span><p>"),
8308                    WebUIFragment::signal("item.bar", false),
8309                    WebUIFragment::raw("</p>"),
8310                ],
8311                contains_boundary: false,
8312            },
8313        );
8314        let protocol = WebUIProtocol::new(fragments);
8315        let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
8316        let mut writer = TestWriter::new();
8317        handle(
8318            &protocol,
8319            &state,
8320            &RenderOptions::new("index.html", "/"),
8321            &mut writer,
8322        )
8323        .unwrap();
8324        assert_eq!(
8325            writer.get_content(),
8326            "<my-component><span>1</span><p>true</p></my-component>"
8327        );
8328    }
8329
8330    #[test]
8331    fn test_complex_attr_for_loop() {
8332        let mut fragments = HashMap::new();
8333        fragments.insert(
8334            "index.html".to_string(),
8335            FragmentList {
8336                fragments: vec![WebUIFragment::for_loop(
8337                    "item",
8338                    "list.items",
8339                    "listTemplate",
8340                )],
8341                contains_boundary: false,
8342            },
8343        );
8344        fragments.insert(
8345            "listTemplate".to_string(),
8346            FragmentList {
8347                fragments: vec![
8348                    WebUIFragment {
8349                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8350                            WebUIFragmentAttribute {
8351                                name: ":item".into(),
8352                                value: "item".into(),
8353                                attr_start: true,
8354                                complex: true,
8355                                ..Default::default()
8356                            },
8357                        )),
8358                    },
8359                    WebUIFragment::component("item_component"),
8360                ],
8361
8362                contains_boundary: false,
8363            },
8364        );
8365        fragments.insert(
8366            "item_component".to_string(),
8367            FragmentList {
8368                fragments: vec![
8369                    WebUIFragment::raw("<span>"),
8370                    WebUIFragment::signal("item.name", false),
8371                    WebUIFragment::raw("</span>"),
8372                ],
8373                contains_boundary: false,
8374            },
8375        );
8376        let protocol = WebUIProtocol::new(fragments);
8377        let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
8378        let mut writer = TestWriter::new();
8379        handle(
8380            &protocol,
8381            &state,
8382            &RenderOptions::new("index.html", "/"),
8383            &mut writer,
8384        )
8385        .unwrap();
8386        assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
8387    }
8388
8389    #[test]
8390    fn test_complex_attr_nested_for() {
8391        let mut fragments = HashMap::new();
8392        fragments.insert(
8393            "index.html".to_string(),
8394            FragmentList {
8395                fragments: vec![WebUIFragment::for_loop(
8396                    "outer",
8397                    "data.outer",
8398                    "outerTemplate",
8399                )],
8400                contains_boundary: false,
8401            },
8402        );
8403        fragments.insert(
8404            "outerTemplate".to_string(),
8405            FragmentList {
8406                fragments: vec![WebUIFragment::for_loop(
8407                    "middle",
8408                    "outer.middle",
8409                    "middleTemplate",
8410                )],
8411                contains_boundary: false,
8412            },
8413        );
8414        fragments.insert(
8415            "middleTemplate".to_string(),
8416            FragmentList {
8417                fragments: vec![WebUIFragment::for_loop(
8418                    "inner",
8419                    "middle.inner",
8420                    "innerTemplate",
8421                )],
8422                contains_boundary: false,
8423            },
8424        );
8425        fragments.insert(
8426            "innerTemplate".to_string(),
8427            FragmentList {
8428                fragments: vec![
8429                    WebUIFragment::raw("<card"),
8430                    WebUIFragment {
8431                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8432                            WebUIFragmentAttribute {
8433                                name: ":outer".into(),
8434                                value: "outer".into(),
8435                                attr_start: true,
8436                                complex: true,
8437                                ..Default::default()
8438                            },
8439                        )),
8440                    },
8441                    WebUIFragment {
8442                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8443                            WebUIFragmentAttribute {
8444                                name: ":middle".into(),
8445                                value: "middle".into(),
8446                                attr_start: false,
8447                                complex: true,
8448                                ..Default::default()
8449                            },
8450                        )),
8451                    },
8452                    WebUIFragment {
8453                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8454                            WebUIFragmentAttribute {
8455                                name: ":inner".into(),
8456                                value: "inner".into(),
8457                                attr_start: false,
8458                                complex: true,
8459                                ..Default::default()
8460                            },
8461                        )),
8462                    },
8463                    WebUIFragment::raw(">"),
8464                    WebUIFragment::component("card_component"),
8465                    WebUIFragment::raw("</card>"),
8466                ],
8467
8468                contains_boundary: false,
8469            },
8470        );
8471        fragments.insert(
8472            "card_component".to_string(),
8473            FragmentList {
8474                fragments: vec![
8475                    WebUIFragment::raw("<p>"),
8476                    WebUIFragment::signal("outer.label", false),
8477                    WebUIFragment::raw(" / "),
8478                    WebUIFragment::signal("middle.label", false),
8479                    WebUIFragment::raw(" / "),
8480                    WebUIFragment::signal("inner.label", false),
8481                    WebUIFragment::raw("</p>"),
8482                ],
8483                contains_boundary: false,
8484            },
8485        );
8486        let protocol = WebUIProtocol::new(fragments);
8487        let state = test_json!({"data": {"outer": [
8488            {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8489            {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8490        ]}});
8491        let mut writer = TestWriter::new();
8492        handle(
8493            &protocol,
8494            &state,
8495            &RenderOptions::new("index.html", "/"),
8496            &mut writer,
8497        )
8498        .unwrap();
8499        assert_eq!(
8500            writer.get_content(),
8501            "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8502        );
8503    }
8504
8505    // ── GROUP 8: Boolean Component State ──────────────────────────────
8506
8507    #[test]
8508    fn test_bool_component_state_true() {
8509        let mut fragments = HashMap::new();
8510        fragments.insert(
8511            "index.html".to_string(),
8512            FragmentList {
8513                fragments: vec![
8514                    WebUIFragment::raw("<my-component"),
8515                    WebUIFragment {
8516                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8517                            WebUIFragmentAttribute {
8518                                name: "disabled".into(),
8519                                attr_start: true,
8520                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8521                                ..Default::default()
8522                            },
8523                        )),
8524                    },
8525                    WebUIFragment::raw(">"),
8526                    WebUIFragment::component("my-component"),
8527                    WebUIFragment::raw("</my-component>"),
8528                ],
8529
8530                contains_boundary: false,
8531            },
8532        );
8533        fragments.insert(
8534            "my-component".to_string(),
8535            FragmentList {
8536                fragments: vec![
8537                    WebUIFragment::if_cond(
8538                        ConditionExpr::identifier("disabled"),
8539                        "disabledTemplate",
8540                    ),
8541                    WebUIFragment::if_cond(
8542                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8543                        "enabledTemplate",
8544                    ),
8545                ],
8546                contains_boundary: false,
8547            },
8548        );
8549        fragments.insert(
8550            "disabledTemplate".to_string(),
8551            FragmentList {
8552                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8553                contains_boundary: false,
8554            },
8555        );
8556        fragments.insert(
8557            "enabledTemplate".to_string(),
8558            FragmentList {
8559                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8560                contains_boundary: false,
8561            },
8562        );
8563        let protocol = WebUIProtocol::new(fragments);
8564        let state = test_json!({"isDisabled": true});
8565        let mut writer = TestWriter::new();
8566        handle(
8567            &protocol,
8568            &state,
8569            &RenderOptions::new("index.html", "/"),
8570            &mut writer,
8571        )
8572        .unwrap();
8573        assert_eq!(
8574            writer.get_content(),
8575            "<my-component disabled><span>Disabled</span></my-component>"
8576        );
8577    }
8578
8579    #[test]
8580    fn test_bool_component_state_false() {
8581        let mut fragments = HashMap::new();
8582        fragments.insert(
8583            "index.html".to_string(),
8584            FragmentList {
8585                fragments: vec![
8586                    WebUIFragment::raw("<my-component"),
8587                    WebUIFragment {
8588                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8589                            WebUIFragmentAttribute {
8590                                name: "disabled".into(),
8591                                attr_start: true,
8592                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8593                                ..Default::default()
8594                            },
8595                        )),
8596                    },
8597                    WebUIFragment::raw(">"),
8598                    WebUIFragment::component("my-component"),
8599                    WebUIFragment::raw("</my-component>"),
8600                ],
8601
8602                contains_boundary: false,
8603            },
8604        );
8605        fragments.insert(
8606            "my-component".to_string(),
8607            FragmentList {
8608                fragments: vec![
8609                    WebUIFragment::if_cond(
8610                        ConditionExpr::identifier("disabled"),
8611                        "disabledTemplate",
8612                    ),
8613                    WebUIFragment::if_cond(
8614                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8615                        "enabledTemplate",
8616                    ),
8617                ],
8618                contains_boundary: false,
8619            },
8620        );
8621        fragments.insert(
8622            "disabledTemplate".to_string(),
8623            FragmentList {
8624                fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8625                contains_boundary: false,
8626            },
8627        );
8628        fragments.insert(
8629            "enabledTemplate".to_string(),
8630            FragmentList {
8631                fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8632                contains_boundary: false,
8633            },
8634        );
8635        let protocol = WebUIProtocol::new(fragments);
8636        let state = test_json!({"isDisabled": false});
8637        let mut writer = TestWriter::new();
8638        handle(
8639            &protocol,
8640            &state,
8641            &RenderOptions::new("index.html", "/"),
8642            &mut writer,
8643        )
8644        .unwrap();
8645        assert_eq!(
8646            writer.get_content(),
8647            "<my-component><span>Enabled</span></my-component>"
8648        );
8649    }
8650
8651    #[test]
8652    fn test_bool_component_state_forward() {
8653        let mut fragments = HashMap::new();
8654        fragments.insert(
8655            "index.html".to_string(),
8656            FragmentList {
8657                fragments: vec![
8658                    WebUIFragment::raw("<parent-component"),
8659                    WebUIFragment {
8660                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8661                            WebUIFragmentAttribute {
8662                                name: "disabled".into(),
8663                                attr_start: true,
8664                                condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8665                                ..Default::default()
8666                            },
8667                        )),
8668                    },
8669                    WebUIFragment::raw(">"),
8670                    WebUIFragment::component("parent-component"),
8671                    WebUIFragment::raw("</parent-component>"),
8672                ],
8673
8674                contains_boundary: false,
8675            },
8676        );
8677        fragments.insert(
8678            "parent-component".to_string(),
8679            FragmentList {
8680                fragments: vec![
8681                    WebUIFragment::if_cond(
8682                        ConditionExpr::identifier("disabled"),
8683                        "parentDisabledTemplate",
8684                    ),
8685                    WebUIFragment::raw("<child-component"),
8686                    WebUIFragment {
8687                        fragment: Some(web_ui_fragment::Fragment::Attribute(
8688                            WebUIFragmentAttribute {
8689                                name: "disabled".into(),
8690                                attr_start: true,
8691                                condition_tree: Some(ConditionExpr::identifier("disabled")),
8692                                ..Default::default()
8693                            },
8694                        )),
8695                    },
8696                    WebUIFragment::raw(">"),
8697                    WebUIFragment::component("child-component"),
8698                    WebUIFragment::raw("</child-component>"),
8699                ],
8700
8701                contains_boundary: false,
8702            },
8703        );
8704        fragments.insert(
8705            "parentDisabledTemplate".to_string(),
8706            FragmentList {
8707                fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8708                contains_boundary: false,
8709            },
8710        );
8711        fragments.insert(
8712            "child-component".to_string(),
8713            FragmentList {
8714                fragments: vec![
8715                    WebUIFragment::if_cond(
8716                        ConditionExpr::identifier("disabled"),
8717                        "childDisabledTemplate",
8718                    ),
8719                    WebUIFragment::if_cond(
8720                        ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8721                        "childEnabledTemplate",
8722                    ),
8723                ],
8724                contains_boundary: false,
8725            },
8726        );
8727        fragments.insert(
8728            "childDisabledTemplate".to_string(),
8729            FragmentList {
8730                fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8731                contains_boundary: false,
8732            },
8733        );
8734        fragments.insert(
8735            "childEnabledTemplate".to_string(),
8736            FragmentList {
8737                fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8738                contains_boundary: false,
8739            },
8740        );
8741
8742        // Test case 1: isDisabled = true
8743        {
8744            let protocol = WebUIProtocol::new(fragments.clone());
8745            let state = test_json!({"isDisabled": true});
8746            let mut writer = TestWriter::new();
8747            handle(
8748                &protocol,
8749                &state,
8750                &RenderOptions::new("index.html", "/"),
8751                &mut writer,
8752            )
8753            .unwrap();
8754            assert_eq!(
8755                writer.get_content(),
8756                "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8757            );
8758        }
8759
8760        // Test case 2: isDisabled = false
8761        {
8762            let protocol = WebUIProtocol::new(fragments.clone());
8763            let state = test_json!({"isDisabled": false});
8764            let mut writer = TestWriter::new();
8765            handle(
8766                &protocol,
8767                &state,
8768                &RenderOptions::new("index.html", "/"),
8769                &mut writer,
8770            )
8771            .unwrap();
8772            assert_eq!(
8773                writer.get_content(),
8774                "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8775            );
8776        }
8777    }
8778
8779    // ── GROUP 9: Hydration (SKIP) ─────────────────────────────────────
8780
8781    // TODO: test_hydration - requires FAST handler plugin integration; see plugin/fast.rs
8782
8783    // ── Component tests ──────────────────────────────────────────────
8784
8785    #[test]
8786    fn test_component_with_template() {
8787        let mut fragments = HashMap::new();
8788        fragments.insert(
8789            "index.html".to_string(),
8790            FragmentList {
8791                fragments: vec![
8792                    WebUIFragment::raw("<custom-element>"),
8793                    WebUIFragment::component("custom-element"),
8794                    WebUIFragment::raw("</custom-element>"),
8795                ],
8796                contains_boundary: false,
8797            },
8798        );
8799        fragments.insert(
8800            "custom-element".to_string(),
8801            FragmentList {
8802                fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8803                contains_boundary: false,
8804            },
8805        );
8806        let protocol = WebUIProtocol::new(fragments);
8807        let state = test_json!({});
8808        let mut writer = TestWriter::new();
8809        handle(
8810            &protocol,
8811            &state,
8812            &RenderOptions::new("index.html", "/"),
8813            &mut writer,
8814        )
8815        .unwrap();
8816        assert_eq!(
8817            writer.get_content(),
8818            "<custom-element><div>Custom Element</div></custom-element>"
8819        );
8820        assert!(writer.is_ended());
8821    }
8822
8823    #[test]
8824    fn test_component_with_slots() {
8825        let mut fragments = HashMap::new();
8826        fragments.insert(
8827            "index.html".to_string(),
8828            FragmentList {
8829                fragments: vec![
8830                    WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8831                    WebUIFragment::component("custom-element"),
8832                    WebUIFragment::raw("Hello World</custom-element>"),
8833                ],
8834                contains_boundary: false,
8835            },
8836        );
8837        fragments.insert(
8838            "custom-element".to_string(),
8839            FragmentList {
8840                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8841                contains_boundary: false,
8842            },
8843        );
8844        let protocol = WebUIProtocol::new(fragments);
8845        let state = test_json!({});
8846        let mut writer = TestWriter::new();
8847        handle(
8848            &protocol,
8849            &state,
8850            &RenderOptions::new("index.html", "/"),
8851            &mut writer,
8852        )
8853        .unwrap();
8854        assert_eq!(
8855            writer.get_content(),
8856            "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8857        );
8858        assert!(writer.is_ended());
8859    }
8860
8861    #[test]
8862    fn test_multiple_nested_components() {
8863        let mut fragments = HashMap::new();
8864        fragments.insert(
8865            "index.html".to_string(),
8866            FragmentList {
8867                fragments: vec![
8868                    WebUIFragment::raw("<div>"),
8869                    WebUIFragment::for_loop("item", "items", "templateRepeat"),
8870                    WebUIFragment::raw("</div>"),
8871                ],
8872                contains_boundary: false,
8873            },
8874        );
8875        fragments.insert(
8876            "custom-button".to_string(),
8877            FragmentList {
8878                fragments: vec![WebUIFragment::raw("<slot></slot>")],
8879                contains_boundary: false,
8880            },
8881        );
8882        fragments.insert(
8883            "custom-element".to_string(),
8884            FragmentList {
8885                fragments: vec![
8886                    WebUIFragment::raw("<custom-child>"),
8887                    WebUIFragment::component("custom-child"),
8888                    WebUIFragment::raw("</custom-child><slot></slot>"),
8889                ],
8890                contains_boundary: false,
8891            },
8892        );
8893        fragments.insert(
8894            "custom-child".to_string(),
8895            FragmentList {
8896                fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8897                contains_boundary: false,
8898            },
8899        );
8900        fragments.insert(
8901            "templateRepeat".to_string(),
8902            FragmentList {
8903                fragments: vec![
8904                    WebUIFragment::raw("<custom-element>"),
8905                    WebUIFragment::component("custom-element"),
8906                    WebUIFragment::raw("<custom-button>"),
8907                    WebUIFragment::component("custom-button"),
8908                    WebUIFragment::raw("Ok</custom-button></custom-element>"),
8909                ],
8910                contains_boundary: false,
8911            },
8912        );
8913        let protocol = WebUIProtocol::new(fragments);
8914        let state = test_json!({"items": [{"name": "Item1"}]});
8915        let mut writer = TestWriter::new();
8916        handle(
8917            &protocol,
8918            &state,
8919            &RenderOptions::new("index.html", "/"),
8920            &mut writer,
8921        )
8922        .unwrap();
8923        assert_eq!(
8924            writer.get_content(),
8925            "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8926        );
8927        assert!(writer.is_ended());
8928    }
8929
8930    // ── Conditional tests ────────────────────────────────────────────
8931
8932    #[test]
8933    fn test_if_with_binary_expression() {
8934        let mut fragments = HashMap::new();
8935        fragments.insert(
8936            "index.html".to_string(),
8937            FragmentList {
8938                fragments: vec![
8939                    WebUIFragment::raw("<div>"),
8940                    WebUIFragment::if_cond(
8941                        ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8942                        "if-1",
8943                    ),
8944                    WebUIFragment::raw("</div>"),
8945                ],
8946                contains_boundary: false,
8947            },
8948        );
8949        fragments.insert(
8950            "if-1".to_string(),
8951            FragmentList {
8952                fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8953                contains_boundary: false,
8954            },
8955        );
8956        let protocol = WebUIProtocol::new(fragments);
8957
8958        // True case: x = 10 > 5
8959        let state_true = test_json!({"x": 10});
8960        let mut writer_true = TestWriter::new();
8961        handle(
8962            &protocol,
8963            &state_true,
8964            &RenderOptions::new("index.html", "/"),
8965            &mut writer_true,
8966        )
8967        .unwrap();
8968        assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8969
8970        // False case: x = 1 <= 5
8971        let state_false = test_json!({"x": 1});
8972        let mut writer_false = TestWriter::new();
8973        handle(
8974            &protocol,
8975            &state_false,
8976            &RenderOptions::new("index.html", "/"),
8977            &mut writer_false,
8978        )
8979        .unwrap();
8980        assert_eq!(writer_false.get_content(), "<div></div>");
8981    }
8982
8983    #[test]
8984    fn test_for_if_overlapping_local_state() {
8985        let mut fragments = HashMap::new();
8986        fragments.insert(
8987            "index.html".to_string(),
8988            FragmentList {
8989                fragments: vec![
8990                    WebUIFragment::raw("<div>"),
8991                    WebUIFragment::for_loop("item", "items", "template1"),
8992                    WebUIFragment::raw("</div>"),
8993                ],
8994                contains_boundary: false,
8995            },
8996        );
8997        fragments.insert(
8998            "template1".to_string(),
8999            FragmentList {
9000                fragments: vec![
9001                    WebUIFragment::raw("<div>"),
9002                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
9003                    WebUIFragment::raw("</div>"),
9004                ],
9005                contains_boundary: false,
9006            },
9007        );
9008        fragments.insert(
9009            "ifBlock".to_string(),
9010            FragmentList {
9011                fragments: vec![
9012                    WebUIFragment::raw("<span>"),
9013                    WebUIFragment::signal("item.label", false),
9014                    WebUIFragment::raw("</span>"),
9015                ],
9016                contains_boundary: false,
9017            },
9018        );
9019        let protocol = WebUIProtocol::new(fragments);
9020        let state = test_json!({
9021            "flag": false,
9022            "items": [
9023                {"label": "A", "flag": true},
9024                {"label": "B", "flag": false},
9025                {"label": "C", "flag": true}
9026            ]
9027        });
9028        let mut writer = TestWriter::new();
9029        handle(
9030            &protocol,
9031            &state,
9032            &RenderOptions::new("index.html", "/"),
9033            &mut writer,
9034        )
9035        .unwrap();
9036        assert_eq!(
9037            writer.get_content(),
9038            "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
9039        );
9040    }
9041
9042    #[test]
9043    fn test_for_if_global_flag_no_effect() {
9044        let mut fragments = HashMap::new();
9045        fragments.insert(
9046            "index.html".to_string(),
9047            FragmentList {
9048                fragments: vec![
9049                    WebUIFragment::raw("<div>"),
9050                    WebUIFragment::for_loop("item", "items", "template1"),
9051                    WebUIFragment::raw("</div>"),
9052                ],
9053                contains_boundary: false,
9054            },
9055        );
9056        fragments.insert(
9057            "template1".to_string(),
9058            FragmentList {
9059                fragments: vec![
9060                    WebUIFragment::raw("<div>"),
9061                    WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
9062                    WebUIFragment::raw("</div>"),
9063                ],
9064                contains_boundary: false,
9065            },
9066        );
9067        fragments.insert(
9068            "ifBlock".to_string(),
9069            FragmentList {
9070                fragments: vec![
9071                    WebUIFragment::raw("<span>"),
9072                    WebUIFragment::signal("item.label", false),
9073                    WebUIFragment::raw("</span>"),
9074                ],
9075                contains_boundary: false,
9076            },
9077        );
9078        let protocol = WebUIProtocol::new(fragments);
9079        let state = test_json!({
9080            "item": {"flag": true},
9081            "items": [
9082                {"label": "A", "flag": false},
9083                {"label": "B", "flag": true},
9084                {"label": "C", "flag": false}
9085            ]
9086        });
9087        let mut writer = TestWriter::new();
9088        handle(
9089            &protocol,
9090            &state,
9091            &RenderOptions::new("index.html", "/"),
9092            &mut writer,
9093        )
9094        .unwrap();
9095        assert_eq!(
9096            writer.get_content(),
9097            "<div><div></div><div><span>B</span></div><div></div></div>"
9098        );
9099    }
9100
9101    // ── Recursive template test ──────────────────────────────────────
9102
9103    #[test]
9104    fn test_recursive_template_refs() {
9105        let mut fragments = HashMap::new();
9106        fragments.insert(
9107            "index.html".to_string(),
9108            FragmentList {
9109                fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
9110                contains_boundary: false,
9111            },
9112        );
9113        fragments.insert(
9114            "static".to_string(),
9115            FragmentList {
9116                fragments: vec![
9117                    WebUIFragment::raw("<div expanded=\""),
9118                    WebUIFragment::signal("item.expanded", false),
9119                    WebUIFragment::raw("\" class=\""),
9120                    WebUIFragment::signal("testScenario", false),
9121                    WebUIFragment::raw("\"><span>"),
9122                    WebUIFragment::signal("item.name", false),
9123                    WebUIFragment::raw("</span>"),
9124                    WebUIFragment::for_loop("item", "item.children", "static"),
9125                    WebUIFragment::raw("</div>"),
9126                ],
9127                contains_boundary: false,
9128            },
9129        );
9130        let protocol = WebUIProtocol::new(fragments);
9131        let state = test_json!({
9132            "testScenario": "RecursiveTemplatesWithGlobalState",
9133            "items": [
9134                {"name": "A", "expanded": "false", "children": []},
9135                {"name": "B", "expanded": "true", "children": [
9136                    {"name": "C", "expanded": "false"},
9137                    {"name": "D", "expanded": "false"}
9138                ]},
9139                {"name": "E", "expanded": "false"}
9140            ]
9141        });
9142        let mut writer = TestWriter::new();
9143        handle(
9144            &protocol,
9145            &state,
9146            &RenderOptions::new("index.html", "/"),
9147            &mut writer,
9148        )
9149        .unwrap();
9150        assert_eq!(
9151            writer.get_content(),
9152            "<div 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>"
9153        );
9154    }
9155
9156    // ── Advanced state management tests ──────────────────────────────
9157
9158    #[test]
9159    fn test_component_in_for_no_local_access() {
9160        let mut fragments = HashMap::new();
9161        fragments.insert(
9162            "index.html".to_string(),
9163            FragmentList {
9164                fragments: vec![
9165                    WebUIFragment::raw("<div>"),
9166                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9167                    WebUIFragment::raw("</div>"),
9168                ],
9169                contains_boundary: false,
9170            },
9171        );
9172        fragments.insert(
9173            "templateComponent".to_string(),
9174            FragmentList {
9175                fragments: vec![
9176                    WebUIFragment::raw("<component-tag>"),
9177                    WebUIFragment::component("my-component"),
9178                    WebUIFragment::raw("</component-tag>"),
9179                ],
9180                contains_boundary: false,
9181            },
9182        );
9183        fragments.insert(
9184            "my-component".to_string(),
9185            FragmentList {
9186                fragments: vec![
9187                    WebUIFragment::raw("<span>"),
9188                    WebUIFragment::signal("name", false),
9189                    WebUIFragment::raw("</span>"),
9190                ],
9191                contains_boundary: false,
9192            },
9193        );
9194        let protocol = WebUIProtocol::new(fragments);
9195        let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
9196        let mut writer = TestWriter::new();
9197        handle(
9198            &protocol,
9199            &state,
9200            &RenderOptions::new("index.html", "/"),
9201            &mut writer,
9202        )
9203        .unwrap();
9204        assert_eq!(
9205            writer.get_content(),
9206            "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
9207        );
9208    }
9209
9210    #[test]
9211    fn test_nested_for_hierarchical_state() {
9212        let mut fragments = HashMap::new();
9213        fragments.insert(
9214            "index.html".to_string(),
9215            FragmentList {
9216                fragments: vec![
9217                    WebUIFragment::raw("<div>"),
9218                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9219                    WebUIFragment::raw("</div>"),
9220                ],
9221                contains_boundary: false,
9222            },
9223        );
9224        fragments.insert(
9225            "outerTemplate".to_string(),
9226            FragmentList {
9227                fragments: vec![
9228                    WebUIFragment::raw("<section>"),
9229                    WebUIFragment::signal("globalPrefix", false),
9230                    WebUIFragment::signal("outerItem.outerLabel", false),
9231                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9232                    WebUIFragment::raw("</section>"),
9233                ],
9234                contains_boundary: false,
9235            },
9236        );
9237        fragments.insert(
9238            "innerTemplate".to_string(),
9239            FragmentList {
9240                fragments: vec![
9241                    WebUIFragment::raw("<p>"),
9242                    WebUIFragment::signal("globalPrefix", false),
9243                    WebUIFragment::signal("outerItem.outerLabel", false),
9244                    WebUIFragment::raw(": "),
9245                    WebUIFragment::signal("innerItem.innerLabel", false),
9246                    WebUIFragment::raw("</p>"),
9247                ],
9248                contains_boundary: false,
9249            },
9250        );
9251        let protocol = WebUIProtocol::new(fragments);
9252        let state = test_json!({
9253            "globalPrefix": "Prefix: ",
9254            "outerItems": [
9255                {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9256                {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
9257            ]
9258        });
9259        let mut writer = TestWriter::new();
9260        handle(
9261            &protocol,
9262            &state,
9263            &RenderOptions::new("index.html", "/"),
9264            &mut writer,
9265        )
9266        .unwrap();
9267        assert_eq!(
9268            writer.get_content(),
9269            "<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>"
9270        );
9271    }
9272
9273    #[test]
9274    fn test_component_in_for_global_only() {
9275        let mut fragments = HashMap::new();
9276        fragments.insert(
9277            "index.html".to_string(),
9278            FragmentList {
9279                fragments: vec![
9280                    WebUIFragment::raw("<div>"),
9281                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9282                    WebUIFragment::raw("</div>"),
9283                ],
9284                contains_boundary: false,
9285            },
9286        );
9287        fragments.insert(
9288            "templateComponent".to_string(),
9289            FragmentList {
9290                fragments: vec![
9291                    WebUIFragment::raw("<component-tag>"),
9292                    WebUIFragment::component("my-component"),
9293                    WebUIFragment::raw("</component-tag>"),
9294                ],
9295                contains_boundary: false,
9296            },
9297        );
9298        fragments.insert(
9299            "my-component".to_string(),
9300            FragmentList {
9301                fragments: vec![
9302                    WebUIFragment::raw("<span>"),
9303                    WebUIFragment::signal("name", false),
9304                    WebUIFragment::raw("-"),
9305                    WebUIFragment::signal("globalSuffix", false),
9306                    WebUIFragment::raw("</span>"),
9307                ],
9308                contains_boundary: false,
9309            },
9310        );
9311        let protocol = WebUIProtocol::new(fragments);
9312        let state =
9313            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
9314        let mut writer = TestWriter::new();
9315        handle(
9316            &protocol,
9317            &state,
9318            &RenderOptions::new("index.html", "/"),
9319            &mut writer,
9320        )
9321        .unwrap();
9322        assert_eq!(
9323            writer.get_content(),
9324            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9325        );
9326    }
9327
9328    #[test]
9329    fn test_component_no_item_moniker() {
9330        let mut fragments = HashMap::new();
9331        fragments.insert(
9332            "index.html".to_string(),
9333            FragmentList {
9334                fragments: vec![
9335                    WebUIFragment::raw("<div>"),
9336                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9337                    WebUIFragment::raw("</div>"),
9338                ],
9339                contains_boundary: false,
9340            },
9341        );
9342        fragments.insert(
9343            "templateComponent".to_string(),
9344            FragmentList {
9345                fragments: vec![
9346                    WebUIFragment::raw("<component-tag>"),
9347                    WebUIFragment::component("my-component"),
9348                    WebUIFragment::raw("</component-tag>"),
9349                ],
9350                contains_boundary: false,
9351            },
9352        );
9353        fragments.insert(
9354            "my-component".to_string(),
9355            FragmentList {
9356                fragments: vec![
9357                    WebUIFragment::raw("<span>"),
9358                    WebUIFragment::signal("item.name", false),
9359                    WebUIFragment::raw("-"),
9360                    WebUIFragment::signal("globalSuffix", false),
9361                    WebUIFragment::raw("</span>"),
9362                ],
9363                contains_boundary: false,
9364            },
9365        );
9366        let protocol = WebUIProtocol::new(fragments);
9367        let state =
9368            test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
9369        let mut writer = TestWriter::new();
9370        handle(
9371            &protocol,
9372            &state,
9373            &RenderOptions::new("index.html", "/"),
9374            &mut writer,
9375        )
9376        .unwrap();
9377        assert_eq!(
9378            writer.get_content(),
9379            "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9380        );
9381    }
9382
9383    #[test]
9384    fn test_for_nonqualified_uses_global() {
9385        let mut fragments = HashMap::new();
9386        fragments.insert(
9387            "index.html".to_string(),
9388            FragmentList {
9389                fragments: vec![
9390                    WebUIFragment::raw("<div>"),
9391                    WebUIFragment::for_loop("item", "items", "template1"),
9392                    WebUIFragment::raw("</div>"),
9393                ],
9394                contains_boundary: false,
9395            },
9396        );
9397        fragments.insert(
9398            "template1".to_string(),
9399            FragmentList {
9400                fragments: vec![
9401                    WebUIFragment::raw("<span>"),
9402                    WebUIFragment::signal("name", false),
9403                    WebUIFragment::raw("</span>"),
9404                ],
9405                contains_boundary: false,
9406            },
9407        );
9408        let protocol = WebUIProtocol::new(fragments);
9409        let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
9410        let mut writer = TestWriter::new();
9411        handle(
9412            &protocol,
9413            &state,
9414            &RenderOptions::new("index.html", "/"),
9415            &mut writer,
9416        )
9417        .unwrap();
9418        assert_eq!(
9419            writer.get_content(),
9420            "<div><span>GlobalName</span><span>GlobalName</span></div>"
9421        );
9422    }
9423
9424    #[test]
9425    fn test_nested_for_if_interleaved() {
9426        let mut fragments = HashMap::new();
9427        fragments.insert(
9428            "index.html".to_string(),
9429            FragmentList {
9430                fragments: vec![
9431                    WebUIFragment::raw("<div>"),
9432                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9433                    WebUIFragment::raw("</div>"),
9434                ],
9435                contains_boundary: false,
9436            },
9437        );
9438        fragments.insert(
9439            "outerTemplate".to_string(),
9440            FragmentList {
9441                fragments: vec![
9442                    WebUIFragment::raw("<section>"),
9443                    WebUIFragment::signal("globalPrefix", false),
9444                    WebUIFragment::signal("outerItem.outerLabel", false),
9445                    WebUIFragment::if_cond(
9446                        ConditionExpr::identifier("outerItem.include"),
9447                        "ifTemplate",
9448                    ),
9449                    WebUIFragment::raw("</section>"),
9450                ],
9451                contains_boundary: false,
9452            },
9453        );
9454        fragments.insert(
9455            "ifTemplate".to_string(),
9456            FragmentList {
9457                fragments: vec![
9458                    WebUIFragment::raw("<div>"),
9459                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9460                    WebUIFragment::raw("</div>"),
9461                ],
9462                contains_boundary: false,
9463            },
9464        );
9465        fragments.insert(
9466            "innerTemplate".to_string(),
9467            FragmentList {
9468                fragments: vec![
9469                    WebUIFragment::raw("<p>"),
9470                    WebUIFragment::signal("globalSuffix", false),
9471                    WebUIFragment::raw(": "),
9472                    WebUIFragment::signal("innerItem.innerLabel", false),
9473                    WebUIFragment::raw("</p>"),
9474                ],
9475                contains_boundary: false,
9476            },
9477        );
9478        let protocol = WebUIProtocol::new(fragments);
9479        let state = test_json!({
9480            "globalPrefix": "Prefix: ",
9481            "globalSuffix": "Suffix",
9482            "outerItems": [
9483                {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9484                {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9485                {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9486            ]
9487        });
9488        let mut writer = TestWriter::new();
9489        handle(
9490            &protocol,
9491            &state,
9492            &RenderOptions::new("index.html", "/"),
9493            &mut writer,
9494        )
9495        .unwrap();
9496        assert_eq!(
9497            writer.get_content(),
9498            "<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>"
9499        );
9500    }
9501
9502    #[test]
9503    fn test_nested_for_if_outer_state() {
9504        let mut fragments = HashMap::new();
9505        fragments.insert(
9506            "index.html".to_string(),
9507            FragmentList {
9508                fragments: vec![
9509                    WebUIFragment::raw("<div>"),
9510                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9511                    WebUIFragment::raw("</div>"),
9512                ],
9513                contains_boundary: false,
9514            },
9515        );
9516        fragments.insert(
9517            "outerTemplate".to_string(),
9518            FragmentList {
9519                fragments: vec![
9520                    WebUIFragment::raw("<section>"),
9521                    WebUIFragment::signal("globalPrefix", false),
9522                    WebUIFragment::signal("outerItem.label", false),
9523                    WebUIFragment::for_loop(
9524                        "middleItem",
9525                        "outerItem.middleItems",
9526                        "middleTemplate",
9527                    ),
9528                    WebUIFragment::raw("</section>"),
9529                ],
9530                contains_boundary: false,
9531            },
9532        );
9533        fragments.insert(
9534            "middleTemplate".to_string(),
9535            FragmentList {
9536                fragments: vec![
9537                    WebUIFragment::raw("<div>"),
9538                    WebUIFragment::if_cond(
9539                        ConditionExpr::identifier("outerItem.active"),
9540                        "ifTemplate",
9541                    ),
9542                    WebUIFragment::raw("</div>"),
9543                ],
9544                contains_boundary: false,
9545            },
9546        );
9547        fragments.insert(
9548            "ifTemplate".to_string(),
9549            FragmentList {
9550                fragments: vec![
9551                    WebUIFragment::raw("<p>"),
9552                    WebUIFragment::signal("middleItem.value", false),
9553                    WebUIFragment::raw("</p>"),
9554                ],
9555                contains_boundary: false,
9556            },
9557        );
9558        let protocol = WebUIProtocol::new(fragments);
9559        let state = test_json!({
9560            "globalPrefix": "GP-",
9561            "outerItems": [
9562                {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9563                {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9564                {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9565            ]
9566        });
9567        let mut writer = TestWriter::new();
9568        handle(
9569            &protocol,
9570            &state,
9571            &RenderOptions::new("index.html", "/"),
9572            &mut writer,
9573        )
9574        .unwrap();
9575        assert_eq!(
9576            writer.get_content(),
9577            "<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>"
9578        );
9579    }
9580
9581    #[test]
9582    fn test_nested_for_if_inner_state() {
9583        let mut fragments = HashMap::new();
9584        fragments.insert(
9585            "index.html".to_string(),
9586            FragmentList {
9587                fragments: vec![
9588                    WebUIFragment::raw("<div>"),
9589                    WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9590                    WebUIFragment::raw("</div>"),
9591                ],
9592                contains_boundary: false,
9593            },
9594        );
9595        fragments.insert(
9596            "outerTemplate".to_string(),
9597            FragmentList {
9598                fragments: vec![
9599                    WebUIFragment::raw("<section>"),
9600                    WebUIFragment::signal("outerItem.label", false),
9601                    WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9602                    WebUIFragment::raw("</section>"),
9603                ],
9604                contains_boundary: false,
9605            },
9606        );
9607        fragments.insert(
9608            "innerTemplate".to_string(),
9609            FragmentList {
9610                fragments: vec![
9611                    WebUIFragment::raw("<article>"),
9612                    WebUIFragment::if_cond(
9613                        ConditionExpr::identifier("innerItem.show"),
9614                        "ifTemplate",
9615                    ),
9616                    WebUIFragment::raw("</article>"),
9617                ],
9618                contains_boundary: false,
9619            },
9620        );
9621        fragments.insert(
9622            "ifTemplate".to_string(),
9623            FragmentList {
9624                fragments: vec![
9625                    WebUIFragment::raw("<p>"),
9626                    WebUIFragment::signal("innerItem.detail", false),
9627                    WebUIFragment::raw("</p>"),
9628                ],
9629                contains_boundary: false,
9630            },
9631        );
9632        let protocol = WebUIProtocol::new(fragments);
9633        let state = test_json!({
9634            "outerItems": [
9635                {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9636                {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9637            ]
9638        });
9639        let mut writer = TestWriter::new();
9640        handle(
9641            &protocol,
9642            &state,
9643            &RenderOptions::new("index.html", "/"),
9644            &mut writer,
9645        )
9646        .unwrap();
9647        assert_eq!(
9648            writer.get_content(),
9649            "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9650        );
9651    }
9652
9653    #[test]
9654    fn test_for_merge_local_global_monikers() {
9655        let mut fragments = HashMap::new();
9656        fragments.insert(
9657            "index.html".to_string(),
9658            FragmentList {
9659                fragments: vec![
9660                    WebUIFragment::raw("<div>"),
9661                    WebUIFragment::for_loop("item", "items", "template1"),
9662                    WebUIFragment::raw("</div>"),
9663                ],
9664                contains_boundary: false,
9665            },
9666        );
9667        fragments.insert(
9668            "template1".to_string(),
9669            FragmentList {
9670                fragments: vec![
9671                    WebUIFragment::raw("<span>"),
9672                    WebUIFragment::signal("item.name", false),
9673                    WebUIFragment::raw("-"),
9674                    WebUIFragment::signal("item.globalValue", false),
9675                    WebUIFragment::raw("-"),
9676                    WebUIFragment::signal("item.localOnly", false),
9677                    WebUIFragment::raw("-"),
9678                    WebUIFragment::signal("item.otherVal", false),
9679                    WebUIFragment::raw("</span>"),
9680                ],
9681                contains_boundary: false,
9682            },
9683        );
9684        let protocol = WebUIProtocol::new(fragments);
9685        let state = test_json!({
9686            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9687            "items": [
9688                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9689                {"name": "Local2", "localOnly": "Only2"}
9690            ]
9691        });
9692        let mut writer = TestWriter::new();
9693        handle(
9694            &protocol,
9695            &state,
9696            &RenderOptions::new("index.html", "/"),
9697            &mut writer,
9698        )
9699        .unwrap();
9700        assert_eq!(
9701            writer.get_content(),
9702            "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9703        );
9704    }
9705
9706    #[test]
9707    fn test_component_in_for_global_moniker_shadow() {
9708        let mut fragments = HashMap::new();
9709        fragments.insert(
9710            "index.html".to_string(),
9711            FragmentList {
9712                fragments: vec![
9713                    WebUIFragment::raw("<div>"),
9714                    WebUIFragment::for_loop("item", "items", "templateComponent"),
9715                    WebUIFragment::raw("</div>"),
9716                ],
9717                contains_boundary: false,
9718            },
9719        );
9720        fragments.insert(
9721            "templateComponent".to_string(),
9722            FragmentList {
9723                fragments: vec![
9724                    WebUIFragment::raw("<component-tag>"),
9725                    WebUIFragment::component("my-component"),
9726                    WebUIFragment::raw("</component-tag>"),
9727                ],
9728                contains_boundary: false,
9729            },
9730        );
9731        fragments.insert(
9732            "my-component".to_string(),
9733            FragmentList {
9734                fragments: vec![
9735                    WebUIFragment::raw("<span>"),
9736                    WebUIFragment::signal("name", false),
9737                    WebUIFragment::raw("-"),
9738                    WebUIFragment::signal("item.globalValue", false),
9739                    WebUIFragment::raw("-"),
9740                    WebUIFragment::signal("localOnly", false),
9741                    WebUIFragment::raw("-"),
9742                    WebUIFragment::signal("item.otherVal", false),
9743                    WebUIFragment::raw("</span>"),
9744                ],
9745                contains_boundary: false,
9746            },
9747        );
9748        let protocol = WebUIProtocol::new(fragments);
9749        let state = test_json!({
9750            "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9751            "items": [
9752                {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9753                {"name": "Local2", "localOnly": "Only2"}
9754            ]
9755        });
9756        let mut writer = TestWriter::new();
9757        handle(
9758            &protocol,
9759            &state,
9760            &RenderOptions::new("index.html", "/"),
9761            &mut writer,
9762        )
9763        .unwrap();
9764        assert_eq!(
9765            writer.get_content(),
9766            "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9767        );
9768    }
9769
9770    #[test]
9771    fn test_if_in_nested_for_local_flag() {
9772        let mut fragments = HashMap::new();
9773        fragments.insert(
9774            "index.html".to_string(),
9775            FragmentList {
9776                fragments: vec![
9777                    WebUIFragment::raw("<div>"),
9778                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9779                    WebUIFragment::raw("</div>"),
9780                ],
9781                contains_boundary: false,
9782            },
9783        );
9784        fragments.insert(
9785            "outerTemplate".to_string(),
9786            FragmentList {
9787                fragments: vec![
9788                    WebUIFragment::raw("<section>"),
9789                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9790                    WebUIFragment::raw("</section>"),
9791                ],
9792                contains_boundary: false,
9793            },
9794        );
9795        fragments.insert(
9796            "innerTemplate".to_string(),
9797            FragmentList {
9798                fragments: vec![WebUIFragment::if_cond(
9799                    ConditionExpr::identifier("inner_item.flag"),
9800                    "ifInner",
9801                )],
9802                contains_boundary: false,
9803            },
9804        );
9805        fragments.insert(
9806            "ifInner".to_string(),
9807            FragmentList {
9808                fragments: vec![
9809                    WebUIFragment::raw("<p>"),
9810                    WebUIFragment::signal("inner_item.value", false),
9811                    WebUIFragment::raw("</p>"),
9812                ],
9813                contains_boundary: false,
9814            },
9815        );
9816        let protocol = WebUIProtocol::new(fragments);
9817        let state = test_json!({
9818            "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9819            "inner_item": {"flag": false}
9820        });
9821        let mut writer = TestWriter::new();
9822        handle(
9823            &protocol,
9824            &state,
9825            &RenderOptions::new("index.html", "/"),
9826            &mut writer,
9827        )
9828        .unwrap();
9829        assert_eq!(
9830            writer.get_content(),
9831            "<div><section><p>X</p></section></div>"
9832        );
9833    }
9834
9835    #[test]
9836    fn test_if_in_nested_for_global_fallback() {
9837        let mut fragments = HashMap::new();
9838        fragments.insert(
9839            "index.html".to_string(),
9840            FragmentList {
9841                fragments: vec![
9842                    WebUIFragment::raw("<div>"),
9843                    WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9844                    WebUIFragment::raw("</div>"),
9845                ],
9846                contains_boundary: false,
9847            },
9848        );
9849        fragments.insert(
9850            "outerTemplate".to_string(),
9851            FragmentList {
9852                fragments: vec![
9853                    WebUIFragment::raw("<section>"),
9854                    WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9855                    WebUIFragment::raw("</section>"),
9856                ],
9857                contains_boundary: false,
9858            },
9859        );
9860        fragments.insert(
9861            "innerTemplate".to_string(),
9862            FragmentList {
9863                fragments: vec![WebUIFragment::if_cond(
9864                    ConditionExpr::identifier("inner_item.flag"),
9865                    "ifInner",
9866                )],
9867                contains_boundary: false,
9868            },
9869        );
9870        fragments.insert(
9871            "ifInner".to_string(),
9872            FragmentList {
9873                fragments: vec![
9874                    WebUIFragment::raw("<p>"),
9875                    WebUIFragment::signal("inner_item.value", false),
9876                    WebUIFragment::raw("</p>"),
9877                ],
9878                contains_boundary: false,
9879            },
9880        );
9881        let protocol = WebUIProtocol::new(fragments);
9882        let state = test_json!({
9883            "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9884            "inner_item": {"flag": true}
9885        });
9886        let mut writer = TestWriter::new();
9887        handle(
9888            &protocol,
9889            &state,
9890            &RenderOptions::new("index.html", "/"),
9891            &mut writer,
9892        )
9893        .unwrap();
9894        assert_eq!(
9895            writer.get_content(),
9896            "<div><section><p>X</p><p>Y</p></section></div>"
9897        );
9898    }
9899
9900    #[test]
9901    fn test_if_mixed_for_monikers() {
9902        let mut fragments = HashMap::new();
9903        fragments.insert(
9904            "index.html".to_string(),
9905            FragmentList {
9906                fragments: vec![
9907                    WebUIFragment::raw("<div>"),
9908                    WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9909                    WebUIFragment::raw("</div>"),
9910                ],
9911                contains_boundary: false,
9912            },
9913        );
9914        fragments.insert(
9915            "outerTemplate".to_string(),
9916            FragmentList {
9917                fragments: vec![
9918                    WebUIFragment::raw("<section>"),
9919                    WebUIFragment::signal("outer.outerLabel", false),
9920                    WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9921                    WebUIFragment::raw("</section>"),
9922                ],
9923                contains_boundary: false,
9924            },
9925        );
9926        fragments.insert(
9927            "innerTemplate".to_string(),
9928            FragmentList {
9929                fragments: vec![WebUIFragment::if_cond(
9930                    ConditionExpr::compound(
9931                        ConditionExpr::identifier("outer.active"),
9932                        LogicalOperator::And,
9933                        ConditionExpr::predicate(
9934                            "inner.value",
9935                            ComparisonOperator::GreaterThan,
9936                            "globalLimit",
9937                        ),
9938                    ),
9939                    "ifInner",
9940                )],
9941                contains_boundary: false,
9942            },
9943        );
9944        fragments.insert(
9945            "ifInner".to_string(),
9946            FragmentList {
9947                fragments: vec![
9948                    WebUIFragment::raw("<p>"),
9949                    WebUIFragment::signal("inner.value", false),
9950                    WebUIFragment::raw("</p>"),
9951                ],
9952                contains_boundary: false,
9953            },
9954        );
9955        let protocol = WebUIProtocol::new(fragments);
9956        let state = test_json!({
9957            "globalLimit": 10,
9958            "list": {"outerItems": [
9959                {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9960                {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9961                {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9962            ]}
9963        });
9964        let mut writer = TestWriter::new();
9965        handle(
9966            &protocol,
9967            &state,
9968            &RenderOptions::new("index.html", "/"),
9969            &mut writer,
9970        )
9971        .unwrap();
9972        assert_eq!(
9973            writer.get_content(),
9974            "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9975        );
9976    }
9977
9978    // ── Route-aware rendering tests ─────────────────────────────────────
9979
9980    fn make_route_protocol() -> WebUIProtocol {
9981        use webui_protocol::WebUiFragmentRoute;
9982
9983        let mut fragments = HashMap::new();
9984
9985        // Entry page with two routes
9986        fragments.insert(
9987            "index.html".to_string(),
9988            FragmentList {
9989                fragments: vec![
9990                    WebUIFragment::raw("<h1>Shell</h1>"),
9991                    WebUIFragment::route_from(WebUiFragmentRoute {
9992                        path: "/".into(),
9993                        fragment_id: "dash-page".into(),
9994                        exact: true,
9995                        keep_alive: false,
9996                        ..Default::default()
9997                    }),
9998                    WebUIFragment::route_from(WebUiFragmentRoute {
9999                        path: "/contacts/:id".into(),
10000                        fragment_id: "detail-page".into(),
10001                        exact: true,
10002                        keep_alive: true,
10003                        ..Default::default()
10004                    }),
10005                ],
10006
10007                contains_boundary: false,
10008            },
10009        );
10010
10011        // Dashboard page component
10012        fragments.insert(
10013            "dash-page".to_string(),
10014            FragmentList {
10015                fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
10016                contains_boundary: false,
10017            },
10018        );
10019
10020        // Detail page component
10021        fragments.insert(
10022            "detail-page".to_string(),
10023            FragmentList {
10024                fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
10025                contains_boundary: false,
10026            },
10027        );
10028
10029        WebUIProtocol::new(fragments)
10030    }
10031
10032    fn make_nested_route_protocol() -> WebUIProtocol {
10033        use webui_protocol::WebUiFragmentRoute;
10034
10035        let mut fragments = HashMap::new();
10036
10037        fragments.insert(
10038            "index.html".to_string(),
10039            FragmentList {
10040                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
10041                    path: "/".into(),
10042                    fragment_id: "app-shell".into(),
10043                    exact: false,
10044                    children: vec![WebUiFragmentRoute {
10045                        path: "sections/:id".into(),
10046                        fragment_id: "section-comp".into(),
10047                        exact: false,
10048                        children: vec![WebUiFragmentRoute {
10049                            path: "topics/:topicId".into(),
10050                            fragment_id: "topic-comp".into(),
10051                            exact: true,
10052                            children: vec![],
10053                            keep_alive: false,
10054                            ..Default::default()
10055                        }],
10056                        keep_alive: false,
10057                        ..Default::default()
10058                    }],
10059                    keep_alive: false,
10060                    ..Default::default()
10061                })],
10062
10063                contains_boundary: false,
10064            },
10065        );
10066
10067        fragments.insert(
10068            "app-shell".to_string(),
10069            FragmentList {
10070                fragments: vec![
10071                    WebUIFragment::raw("<h1>Shell</h1>"),
10072                    WebUIFragment::outlet(),
10073                ],
10074                contains_boundary: false,
10075            },
10076        );
10077
10078        fragments.insert(
10079            "section-comp".to_string(),
10080            FragmentList {
10081                fragments: vec![
10082                    WebUIFragment::raw("<h2>Section</h2>"),
10083                    WebUIFragment::outlet(),
10084                ],
10085                contains_boundary: false,
10086            },
10087        );
10088
10089        fragments.insert(
10090            "topic-comp".to_string(),
10091            FragmentList {
10092                fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
10093                contains_boundary: false,
10094            },
10095        );
10096
10097        WebUIProtocol::new(fragments)
10098    }
10099
10100    #[test]
10101    fn test_route_renders_shell_always() {
10102        let protocol = make_route_protocol();
10103        let state = test_json!({});
10104        let mut writer = TestWriter::new();
10105        handle(
10106            &protocol,
10107            &state,
10108            &RenderOptions::new("index.html", "/"),
10109            &mut writer,
10110        )
10111        .unwrap();
10112        let html = writer.get_content();
10113
10114        // Shell content always renders regardless of route matching
10115        assert!(html.contains("<h1>Shell</h1>"), "shell should render");
10116        // Dashboard matches "/" so it should be active
10117        assert!(html.contains(" active>"), "matched route should be active");
10118        // Detail should be hidden and empty
10119        assert!(
10120            html.contains("style=\"display:none\""),
10121            "non-matched routes should be hidden"
10122        );
10123    }
10124
10125    #[test]
10126    fn test_route_matched_renders_visible() {
10127        let protocol = make_route_protocol();
10128        let state = test_json!({});
10129        let mut writer = TestWriter::new();
10130        handle(
10131            &protocol,
10132            &state,
10133            &RenderOptions::new("index.html", "/"),
10134            &mut writer,
10135        )
10136        .unwrap();
10137        let html = writer.get_content();
10138
10139        // Dashboard route should be visible (active, no display:none)
10140        assert!(
10141            html.contains("<webui-route path=\"/\""),
10142            "dashboard route should exist"
10143        );
10144        assert!(
10145            html.contains("active>") && html.contains("<dash-page"),
10146            "matched route should be active with component tag: {html}"
10147        );
10148        assert!(
10149            html.contains("<p>Dashboard</p>"),
10150            "matched route should have content"
10151        );
10152    }
10153
10154    #[test]
10155    fn test_route_non_matched_renders_hidden_empty() {
10156        let protocol = make_route_protocol();
10157        let state = test_json!({});
10158        let mut writer = TestWriter::new();
10159        handle(
10160            &protocol,
10161            &state,
10162            &RenderOptions::new("index.html", "/"),
10163            &mut writer,
10164        )
10165        .unwrap();
10166        let html = writer.get_content();
10167
10168        // Detail route should be hidden and empty (no content rendered)
10169        assert!(
10170            html.contains("<webui-route path=\"/contacts/:id\""),
10171            "detail route element should exist"
10172        );
10173        // The non-matched route should have display:none and no inner content
10174        let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
10175        let after_detail = &html[detail_start..];
10176        assert!(
10177            after_detail.contains("style=\"display:none\">")
10178                && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
10179            "non-matched route should be hidden: {after_detail}"
10180        );
10181        // Should NOT contain the component's rendered content
10182        let detail_end = after_detail.find("</webui-route>").expect("closing tag");
10183        let detail_body = &after_detail[..detail_end];
10184        assert!(
10185            !detail_body.contains("<detail-page"),
10186            "non-matched route should not render component content: {detail_body}"
10187        );
10188    }
10189
10190    #[test]
10191    fn test_route_parameterized_match() {
10192        let protocol = make_route_protocol();
10193        let state = test_json!({});
10194        let mut writer = TestWriter::new();
10195        handle(
10196            &protocol,
10197            &state,
10198            &RenderOptions::new("index.html", "/contacts/42"),
10199            &mut writer,
10200        )
10201        .unwrap();
10202        let html = writer.get_content();
10203
10204        // Detail route matches /contacts/42
10205        assert!(
10206            html.contains("active>") && html.contains("<detail-page"),
10207            "detail route should be active: {html}"
10208        );
10209        assert!(html.contains("<p>Detail</p>"), "detail should have content");
10210        // Dashboard should be hidden + empty
10211        let dash_start = html
10212            .find("component=\"dash-page\"")
10213            .expect("dashboard route");
10214        let after_dash = &html[dash_start..];
10215        assert!(
10216            after_dash.contains("style=\"display:none\">"),
10217            "dashboard should be hidden when detail matches: {after_dash}"
10218        );
10219        let dash_end = after_dash.find("</webui-route>").expect("closing tag");
10220        let dash_body = &after_dash[..dash_end];
10221        assert!(
10222            !dash_body.contains("<dash-page"),
10223            "dashboard should not render component content: {dash_body}"
10224        );
10225    }
10226
10227    #[test]
10228    fn test_route_no_match_all_hidden_empty() {
10229        let protocol = make_route_protocol();
10230        let state = test_json!({});
10231        let mut writer = TestWriter::new();
10232        handle(
10233            &protocol,
10234            &state,
10235            &RenderOptions::new("index.html", "/nonexistent"),
10236            &mut writer,
10237        )
10238        .unwrap();
10239        let html = writer.get_content();
10240
10241        // Shell content should still render
10242        assert!(html.contains("<h1>Shell</h1>"));
10243        // All routes should be hidden + empty (nothing matched)
10244        assert!(
10245            !html.contains("<p>Dashboard</p>"),
10246            "no route content when nothing matches"
10247        );
10248        assert!(
10249            !html.contains("<p>Detail</p>"),
10250            "no route content when nothing matches"
10251        );
10252    }
10253
10254    #[test]
10255    fn test_route_component_attr_emitted() {
10256        let protocol = make_route_protocol();
10257        let state = test_json!({});
10258        let mut writer = TestWriter::new();
10259        handle(
10260            &protocol,
10261            &state,
10262            &RenderOptions::new("index.html", "/"),
10263            &mut writer,
10264        )
10265        .unwrap();
10266        let html = writer.get_content();
10267        // component attribute should be emitted on webui-route
10268        assert!(
10269            html.contains("component=\"dash-page\""),
10270            "component attr should be on webui-route: {html}"
10271        );
10272        assert!(
10273            html.contains("component=\"detail-page\""),
10274            "component attr should be on webui-route: {html}"
10275        );
10276        assert!(
10277            html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
10278            "keep-alive should be emitted on an unmatched destination placeholder: {html}"
10279        );
10280    }
10281
10282    #[test]
10283    fn test_no_plugin_no_state_attributes() {
10284        let protocol = make_route_protocol();
10285        let state = test_json!({
10286            "title": "Fish & Chips",
10287            "cartOpen": true,
10288            "items": [{"name": "A&B"}]
10289        });
10290        let mut writer = TestWriter::new();
10291
10292        handle(
10293            &protocol,
10294            &state,
10295            &RenderOptions::new("index.html", "/"),
10296            &mut writer,
10297        )
10298        .unwrap();
10299
10300        let html = writer.get_content();
10301        // Without a plugin, no state attributes at all
10302        assert!(
10303            !html.contains("data-state"),
10304            "no data-state without plugin: {html}"
10305        );
10306        assert!(
10307            !html.contains(r#"title="Fish"#),
10308            "no scalar attrs without plugin: {html}"
10309        );
10310    }
10311
10312    #[test]
10313    fn test_nested_routes_render_webui_route_as_light_dom() {
10314        let protocol = make_nested_route_protocol();
10315        let state = test_json!({"title": "Test"});
10316        let handler = WebUIHandler::new();
10317        let mut writer = TestWriter::new();
10318
10319        handler
10320            .handle(
10321                &protocol,
10322                &state,
10323                &RenderOptions::new("index.html", "/sections/frontend"),
10324                &mut writer,
10325            )
10326            .expect("render failed");
10327
10328        let html = writer.get_content();
10329
10330        assert!(
10331            html.contains("component=\"app-shell\"") && html.contains("active>"),
10332            "root route should be active: {html}"
10333        );
10334        // webui-route should NOT have shadow DOM — it's a light DOM structural element
10335        assert!(
10336            !html.contains("<template shadowrootmode"),
10337            "webui-route should be light DOM (no shadow template): {html}"
10338        );
10339    }
10340
10341    #[test]
10342    fn test_nested_routes_render_outlet_as_light_dom() {
10343        let protocol = make_nested_route_protocol();
10344        let state = test_json!({"title": "Test"});
10345        let handler = WebUIHandler::new();
10346        let mut writer = TestWriter::new();
10347
10348        handler
10349            .handle(
10350                &protocol,
10351                &state,
10352                &RenderOptions::new("index.html", "/sections/frontend"),
10353                &mut writer,
10354            )
10355            .expect("render failed");
10356
10357        let html = writer.get_content();
10358
10359        // No <webui-outlet> wrapper — routes render directly at outlet position
10360        assert!(
10361            !html.contains("<webui-outlet>"),
10362            "should not contain webui-outlet wrapper: {html}"
10363        );
10364        // Route elements should be in the output directly
10365        assert!(
10366            html.contains("<webui-route"),
10367            "should contain webui-route elements: {html}"
10368        );
10369    }
10370
10371    #[test]
10372    fn test_nested_routes_match_child_at_outlet() {
10373        let protocol = make_nested_route_protocol();
10374        let state = test_json!({"title": "Test"});
10375        let handler = WebUIHandler::new();
10376        let mut writer = TestWriter::new();
10377
10378        handler
10379            .handle(
10380                &protocol,
10381                &state,
10382                &RenderOptions::new("index.html", "/sections/frontend"),
10383                &mut writer,
10384            )
10385            .expect("render failed");
10386
10387        let html = writer.get_content();
10388
10389        assert!(
10390            html.contains("component=\"section-comp\"") && html.contains("active>"),
10391            "section route should be active: {html}"
10392        );
10393        assert!(
10394            html.contains("<h2>Section</h2>"),
10395            "section content should be present: {html}"
10396        );
10397    }
10398
10399    #[test]
10400    fn test_nested_routes_three_levels_deep() {
10401        let protocol = make_nested_route_protocol();
10402        let state = test_json!({"title": "Test"});
10403        let handler = WebUIHandler::new();
10404        let mut writer = TestWriter::new();
10405
10406        handler
10407            .handle(
10408                &protocol,
10409                &state,
10410                &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
10411                &mut writer,
10412            )
10413            .expect("render failed");
10414
10415        let html = writer.get_content();
10416
10417        assert!(
10418            html.contains("component=\"app-shell\"") && html.contains("active>"),
10419            "root active: {html}"
10420        );
10421        assert!(
10422            html.contains("component=\"section-comp\"") && html.contains("active>"),
10423            "section active: {html}"
10424        );
10425        assert!(
10426            html.contains("component=\"topic-comp\"")
10427                && html.contains("exact")
10428                && html.contains("active>"),
10429            "topic active: {html}"
10430        );
10431        assert!(
10432            html.contains("<p>Topic content</p>"),
10433            "leaf content present: {html}"
10434        );
10435    }
10436
10437    #[test]
10438    fn test_nested_routes_nonmatched_siblings_hidden() {
10439        let protocol = make_nested_route_protocol();
10440        let state = test_json!({"title": "Test"});
10441        let handler = WebUIHandler::new();
10442        let mut writer = TestWriter::new();
10443
10444        handler
10445            .handle(
10446                &protocol,
10447                &state,
10448                &RenderOptions::new("index.html", "/sections/frontend"),
10449                &mut writer,
10450            )
10451            .expect("render failed");
10452
10453        let html = writer.get_content();
10454
10455        assert!(
10456            html.contains(r#"component="topic-comp" exact style="display:none">"#),
10457            "topic should be hidden: {html}"
10458        );
10459    }
10460
10461    #[test]
10462    fn test_nested_routes_preserve_sibling_declaration_order() {
10463        let mut protocol = make_nested_route_protocol();
10464        let Some(Fragment::Route(root)) = protocol
10465            .fragments
10466            .get_mut("index.html")
10467            .and_then(|entry| entry.fragments.first_mut())
10468            .and_then(|fragment| fragment.fragment.as_mut())
10469        else {
10470            panic!("nested route fixture must have a root route");
10471        };
10472        root.children.insert(
10473            0,
10474            webui_protocol::WebUiFragmentRoute {
10475                path: String::new(),
10476                fragment_id: "topic-comp".into(),
10477                exact: true,
10478                ..Default::default()
10479            },
10480        );
10481        root.children.push(webui_protocol::WebUiFragmentRoute {
10482            path: "*rest".into(),
10483            fragment_id: "topic-comp".into(),
10484            exact: true,
10485            ..Default::default()
10486        });
10487
10488        for path in ["/", "/sections/frontend", "/missing"] {
10489            let mut writer = TestWriter::new();
10490            WebUIHandler::new()
10491                .handle(
10492                    &protocol,
10493                    &test_json!({}),
10494                    &RenderOptions::new("index.html", path),
10495                    &mut writer,
10496                )
10497                .expect("render failed");
10498            let html = writer.get_content();
10499            let first = html.find(r#"<webui-route path="""#).expect("empty route");
10500            let middle = html
10501                .find(r#"<webui-route path="sections/:id""#)
10502                .expect("section route");
10503            let last = html
10504                .find(r#"<webui-route path="*rest""#)
10505                .expect("catch-all route");
10506            assert!(first < middle && middle < last, "{path}: {html}");
10507        }
10508    }
10509
10510    #[test]
10511    fn test_nested_routes_root_only() {
10512        let protocol = make_nested_route_protocol();
10513        let state = test_json!({"title": "Test"});
10514        let handler = WebUIHandler::new();
10515        let mut writer = TestWriter::new();
10516
10517        handler
10518            .handle(
10519                &protocol,
10520                &state,
10521                &RenderOptions::new("index.html", "/"),
10522                &mut writer,
10523            )
10524            .expect("render failed");
10525
10526        let html = writer.get_content();
10527
10528        assert!(
10529            html.contains("component=\"app-shell\"") && html.contains("active>"),
10530            "root active at /: {html}"
10531        );
10532        assert!(
10533            html.contains("<h1>Shell</h1>"),
10534            "shell renders at /: {html}"
10535        );
10536        assert!(
10537            html.contains(r#"component="section-comp" style="display:none">"#),
10538            "section hidden at /: {html}"
10539        );
10540    }
10541
10542    // ── CSS Module dedup tests ───────────────────────────────────────
10543
10544    #[test]
10545    fn test_css_module_emitted_once_inline_in_component() {
10546        // CSS module definition emitted once in the component's light DOM
10547        // on first render, not in <head> and not on second instance.
10548        let template = r#"<p><slot></slot></p>"#;
10549
10550        let mut fragments = HashMap::new();
10551        fragments.insert(
10552            "index.html".to_string(),
10553            FragmentList {
10554                fragments: vec![
10555                    WebUIFragment::raw("<html><head>".to_string()),
10556                    structural_fragment("head_end"),
10557                    WebUIFragment::raw("</head><body><div>".to_string()),
10558                    WebUIFragment::component("my-card"),
10559                    WebUIFragment::raw("A".to_string()),
10560                    WebUIFragment::component("my-card"),
10561                    WebUIFragment::raw("B</div>".to_string()),
10562                    structural_fragment("body_end"),
10563                    WebUIFragment::raw("</body></html>".to_string()),
10564                ],
10565                contains_boundary: false,
10566            },
10567        );
10568        fragments.insert(
10569            "my-card".to_string(),
10570            FragmentList {
10571                fragments: vec![WebUIFragment::raw(template.to_string())],
10572                contains_boundary: false,
10573            },
10574        );
10575
10576        let mut protocol = WebUIProtocol::new(fragments);
10577        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10578        protocol
10579            .components
10580            .entry("my-card".to_string())
10581            .or_default()
10582            .css = "p{color:red}".to_string();
10583        protocol.populate_style_closures(&["index.html"]);
10584        let state = test_json!({});
10585        let mut writer = TestWriter::new();
10586
10587        handle(
10588            &protocol,
10589            &state,
10590            &RenderOptions::new("index.html", "/"),
10591            &mut writer,
10592        )
10593        .unwrap();
10594
10595        let html = writer.get_content();
10596
10597        // CSS module importmap should appear exactly once
10598        let count = html.matches(r#"<script type="importmap""#).count();
10599        assert_eq!(
10600            count, 1,
10601            "CSS module importmap should be emitted once, got {count} in: {html}"
10602        );
10603        assert!(
10604            html.contains(r#""my-card":"data:text/css,"#),
10605            "Importmap must register my-card under a data: URI: {html}"
10606        );
10607
10608        // Template content should appear twice (once per component instance)
10609        let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10610        assert_eq!(
10611            tmpl_count, 2,
10612            "Template should render twice, got {tmpl_count} in: {html}"
10613        );
10614
10615        // CSS module should be in <body> (inline), not in <head>
10616        let css_pos = html
10617            .find(r#"<script type="importmap""#)
10618            .expect("CSS module importmap missing");
10619        let body_pos = html.find("<body>").expect("<body> missing");
10620        assert!(
10621            css_pos > body_pos,
10622            "CSS module should be inline in component, not in <head>: {html}"
10623        );
10624    }
10625
10626    #[test]
10627    fn test_component_without_css_renders_normally() {
10628        // Components without CSS module prefix pass through unchanged
10629        let mut fragments = HashMap::new();
10630        fragments.insert(
10631            "index.html".to_string(),
10632            FragmentList {
10633                fragments: vec![WebUIFragment::component("my-card")],
10634                contains_boundary: false,
10635            },
10636        );
10637        fragments.insert(
10638            "my-card".to_string(),
10639            FragmentList {
10640                fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10641                contains_boundary: false,
10642            },
10643        );
10644
10645        let protocol = WebUIProtocol::new(fragments);
10646        let state = test_json!({});
10647        let mut writer = TestWriter::new();
10648
10649        handle(
10650            &protocol,
10651            &state,
10652            &RenderOptions::new("index.html", "/"),
10653            &mut writer,
10654        )
10655        .unwrap();
10656
10657        let html = writer.get_content();
10658        assert!(
10659            html.contains("<p>hello</p>"),
10660            "Non-module component should render normally: {html}"
10661        );
10662    }
10663
10664    #[test]
10665    fn test_non_module_strategy_no_css_in_head() {
10666        // When component_css is empty (Link/Style strategies), no
10667        // CSS module importmap tags should appear in <head>.
10668        let template = r#"<p>hello</p>"#;
10669
10670        let mut fragments = HashMap::new();
10671        fragments.insert(
10672            "index.html".to_string(),
10673            FragmentList {
10674                fragments: vec![
10675                    WebUIFragment::raw("<html><head>".to_string()),
10676                    structural_fragment("head_end"),
10677                    WebUIFragment::raw("</head><body>".to_string()),
10678                    WebUIFragment::component("my-card"),
10679                    WebUIFragment::raw("</body></html>".to_string()),
10680                ],
10681                contains_boundary: false,
10682            },
10683        );
10684        fragments.insert(
10685            "my-card".to_string(),
10686            FragmentList {
10687                fragments: vec![WebUIFragment::raw(template.to_string())],
10688                contains_boundary: false,
10689            },
10690        );
10691
10692        // No component css populated — simulates Link/Style strategy
10693        let protocol = WebUIProtocol::new(fragments);
10694        let state = test_json!({});
10695        let mut writer = TestWriter::new();
10696
10697        handle(
10698            &protocol,
10699            &state,
10700            &RenderOptions::new("index.html", "/"),
10701            &mut writer,
10702        )
10703        .unwrap();
10704
10705        let html = writer.get_content();
10706
10707        assert!(
10708            !html.contains(r#"<style type="module""#),
10709            "Non-module strategy should not emit CSS module tags in <head>: {html}"
10710        );
10711        assert!(
10712            !html.contains(r#"<script type="importmap""#),
10713            "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10714        );
10715        assert!(
10716            html.contains("<p>hello</p>"),
10717            "Component should still render: {html}"
10718        );
10719    }
10720
10721    #[test]
10722    fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10723        let mut fragments = HashMap::new();
10724        fragments.insert(
10725            "index.html".to_string(),
10726            FragmentList {
10727                fragments: vec![
10728                    WebUIFragment::raw("<html><head>".to_string()),
10729                    structural_fragment("head_end"),
10730                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10731                    WebUIFragment::component("my-card"),
10732                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10733                ],
10734                contains_boundary: false,
10735            },
10736        );
10737        fragments.insert(
10738            "my-card".to_string(),
10739            FragmentList {
10740                fragments: vec![WebUIFragment::raw(
10741                    "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10742                        .to_string(),
10743                )],
10744                        contains_boundary: false,
10745},
10746        );
10747
10748        let protocol = WebUIProtocol::new(fragments);
10749        let state = test_json!({});
10750        let mut writer = TestWriter::new();
10751
10752        handle(
10753            &protocol,
10754            &state,
10755            &RenderOptions::new("index.html", "/"),
10756            &mut writer,
10757        )
10758        .unwrap();
10759
10760        let html = writer.get_content();
10761
10762        assert!(
10763            html.contains("<style>.card{color:red}</style>"),
10764            "Style strategy should embed inline CSS in shadow template: {html}"
10765        );
10766        assert!(
10767            !html.contains(r#"<style type="module""#),
10768            "Style strategy should not emit legacy module CSS in <head>: {html}"
10769        );
10770        assert!(
10771            !html.contains(r#"<script type="importmap""#),
10772            "Style strategy should not emit CSS module importmaps in <head>: {html}"
10773        );
10774    }
10775
10776    #[test]
10777    fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10778        // Light DOM + Link strategy: handler emits <link rel="stylesheet">
10779        // in <head>. No preload tag — the stylesheet itself fetches.
10780        let mut fragments = HashMap::new();
10781        fragments.insert(
10782            "index.html".to_string(),
10783            FragmentList {
10784                fragments: vec![
10785                    WebUIFragment::raw("<html><head>".to_string()),
10786                    structural_fragment("head_end"),
10787                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10788                    WebUIFragment::component("my-card"),
10789                    WebUIFragment::raw("</my-card>".to_string()),
10790                    structural_fragment("body_end"),
10791                    WebUIFragment::raw("</body></html>".to_string()),
10792                ],
10793                contains_boundary: false,
10794            },
10795        );
10796        fragments.insert(
10797            "my-card".to_string(),
10798            FragmentList {
10799                fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10800                contains_boundary: false,
10801            },
10802        );
10803
10804        let mut protocol = WebUIProtocol::new(fragments);
10805        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10806
10807        let comp = protocol
10808            .components
10809            .entry("my-card".to_string())
10810            .or_default();
10811        comp.css_href = "my-card.css".to_string();
10812        comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10813        protocol.populate_style_closures(&["index.html"]);
10814
10815        let state = test_json!({});
10816        let mut writer = TestWriter::new();
10817
10818        handle(
10819            &protocol,
10820            &state,
10821            &RenderOptions::new("index.html", "/"),
10822            &mut writer,
10823        )
10824        .unwrap();
10825
10826        let html = writer.get_content();
10827
10828        let head_end = html.find("</head>").expect("</head> missing");
10829        let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10830        assert!(
10831            link_pos.is_some_and(|p| p < head_end),
10832            "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10833        );
10834        assert!(
10835            !html.contains(r#"<link rel="preload""#),
10836            "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10837        );
10838        assert!(
10839            !html.contains(r#"<style type="module""#),
10840            "Link strategy should not emit legacy module CSS: {html}"
10841        );
10842        assert!(
10843            !html.contains(r#"<script type="importmap""#),
10844            "Link strategy should not emit CSS module importmaps: {html}"
10845        );
10846    }
10847
10848    #[test]
10849    fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10850        // Regression: `css_hrefs`/`style_specs` must follow the traversal-ordered
10851        // `reachable` list (a `Vec<String>`), never a `HashSet<String>` — whose
10852        // iteration order depends on the process's randomized hash seed and can
10853        // silently reorder `<link>` tags between renders/process restarts, which
10854        // in turn can flip cascade-order-sensitive CSS (e.g. same-specificity
10855        // `order`/`position` rules) and move on-page elements around.
10856        let mut fragments = HashMap::new();
10857        fragments.insert(
10858            "index.html".to_string(),
10859            FragmentList {
10860                fragments: vec![
10861                    WebUIFragment::raw("<html><head>".to_string()),
10862                    structural_fragment("head_end"),
10863                    WebUIFragment::raw(
10864                        "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10865                            .to_string(),
10866                    ),
10867                    WebUIFragment::component("comp-a"),
10868                    WebUIFragment::component("comp-b"),
10869                    WebUIFragment::component("comp-c"),
10870                    structural_fragment("body_end"),
10871                    WebUIFragment::raw("</body></html>".to_string()),
10872                ],
10873                contains_boundary: false,
10874            },
10875        );
10876        for tag in ["comp-a", "comp-b", "comp-c"] {
10877            fragments.insert(
10878                tag.to_string(),
10879                FragmentList {
10880                    fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10881                    contains_boundary: false,
10882                },
10883            );
10884        }
10885
10886        let mut protocol = WebUIProtocol::new(fragments);
10887        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10888        for tag in ["comp-a", "comp-b", "comp-c"] {
10889            let comp = protocol.components.entry(tag.to_string()).or_default();
10890            comp.css_href = format!("{tag}.css");
10891            comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10892        }
10893        protocol.populate_style_closures(&["index.html"]);
10894
10895        let state = test_json!({});
10896        let mut writer = TestWriter::new();
10897        handle(
10898            &protocol,
10899            &state,
10900            &RenderOptions::new("index.html", "/"),
10901            &mut writer,
10902        )
10903        .unwrap();
10904        let html = writer.get_content();
10905
10906        let pos_a = html
10907            .find(r#"href="comp-a.css""#)
10908            .expect("comp-a.css link missing");
10909        let pos_b = html
10910            .find(r#"href="comp-b.css""#)
10911            .expect("comp-b.css link missing");
10912        let pos_c = html
10913            .find(r#"href="comp-c.css""#)
10914            .expect("comp-c.css link missing");
10915        assert!(
10916            pos_a < pos_b && pos_b < pos_c,
10917            "stylesheet <link> tags must follow document/traversal order \
10918             (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10919        );
10920    }
10921
10922    #[test]
10923    fn link_strategy_preloads_static_shadow_css_in_head() {
10924        let mut fragments = HashMap::new();
10925        fragments.insert(
10926            "index.html".to_string(),
10927            FragmentList {
10928                fragments: vec![
10929                    WebUIFragment::raw("<html><head>".to_string()),
10930                    structural_fragment("head_end"),
10931                    WebUIFragment::raw("</head><body><my-card>".to_string()),
10932                    WebUIFragment::component("my-card"),
10933                    WebUIFragment::raw("</my-card></body></html>".to_string()),
10934                ],
10935                contains_boundary: false,
10936            },
10937        );
10938        fragments.insert(
10939            "o-loading-state".to_string(),
10940            FragmentList {
10941                fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10942                contains_boundary: false,
10943            },
10944        );
10945        fragments.insert(
10946            "my-card".to_string(),
10947            FragmentList {
10948                fragments: vec![
10949                    WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10950                    structural_fragment("shadow_styles:my-card"),
10951                    WebUIFragment::raw("<div>card</div></template>".to_string()),
10952                ],
10953                contains_boundary: false,
10954            },
10955        );
10956
10957        let mut protocol = WebUIProtocol::new(fragments);
10958        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10959        let component = protocol
10960            .components
10961            .entry("my-card".to_string())
10962            .or_default();
10963        component.css_href = "my-card.css".to_string();
10964        component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10965        component.uses_shadow_dom = true;
10966        protocol.populate_style_closures(&["index.html"]);
10967
10968        let mut writer = TestWriter::new();
10969        handle(
10970            &protocol,
10971            &test_json!({}),
10972            &RenderOptions::new("index.html", "/"),
10973            &mut writer,
10974        )
10975        .unwrap();
10976
10977        let html = writer.get_content();
10978        let head_end = html.find("</head>").expect("</head> missing");
10979        let preload = html
10980            .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10981            .expect("Shadow preload missing");
10982        let stylesheet = html
10983            .find(r#"<link rel="stylesheet" href="my-card.css""#)
10984            .expect("Shadow stylesheet missing");
10985        assert!(preload < head_end);
10986        assert!(stylesheet > head_end);
10987        assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10988    }
10989
10990    #[test]
10991    fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10992        let fragments = HashMap::from([
10993            (
10994                "index.html".to_string(),
10995                FragmentList {
10996                    fragments: vec![
10997                        WebUIFragment::raw("<html><head>".to_string()),
10998                        structural_fragment("head_end"),
10999                        WebUIFragment::raw("</head><body><outer-box>".to_string()),
11000                        WebUIFragment::component("outer-box"),
11001                        WebUIFragment::raw("</outer-box></body></html>".to_string()),
11002                    ],
11003                    contains_boundary: false,
11004                },
11005            ),
11006            (
11007                "outer-box".to_string(),
11008                FragmentList {
11009                    fragments: vec![
11010                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11011                        structural_fragment("shadow_styles:outer-box"),
11012                        WebUIFragment::raw("<light-card>".to_string()),
11013                        WebUIFragment::component("light-card"),
11014                        WebUIFragment::raw("</light-card><light-card>".to_string()),
11015                        WebUIFragment::component("light-card"),
11016                        WebUIFragment::raw("</light-card><inner-box>".to_string()),
11017                        WebUIFragment::component("inner-box"),
11018                        WebUIFragment::raw("</inner-box><light-card>".to_string()),
11019                        WebUIFragment::component("light-card"),
11020                        WebUIFragment::raw("</light-card></template>".to_string()),
11021                    ],
11022                    contains_boundary: false,
11023                },
11024            ),
11025            (
11026                "inner-box".to_string(),
11027                FragmentList {
11028                    fragments: vec![
11029                        WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11030                        structural_fragment("shadow_styles:inner-box"),
11031                        WebUIFragment::raw("<light-card>".to_string()),
11032                        WebUIFragment::component("light-card"),
11033                        WebUIFragment::raw("</light-card></template>".to_string()),
11034                    ],
11035                    contains_boundary: false,
11036                },
11037            ),
11038            (
11039                "light-card".to_string(),
11040                FragmentList {
11041                    fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
11042                    contains_boundary: false,
11043                },
11044            ),
11045        ]);
11046        let mut protocol = WebUIProtocol::new(fragments);
11047        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11048        for (tag, css, uses_shadow_dom) in [
11049            ("outer-box", ".outer{}", true),
11050            ("inner-box", ".inner{}", true),
11051            ("light-card", ".card{}", false),
11052        ] {
11053            let component = protocol.components.entry(tag.to_string()).or_default();
11054            component.css = css.to_string();
11055            component.uses_shadow_dom = uses_shadow_dom;
11056        }
11057        protocol.populate_style_closures(&["index.html"]);
11058
11059        let mut writer = TestWriter::new();
11060        handle(
11061            &protocol,
11062            &test_json!({}),
11063            &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11064            &mut writer,
11065        )
11066        .unwrap();
11067        let html = writer.get_content();
11068
11069        assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
11070        assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
11071        assert_eq!(
11072            html.matches("data-webui-resource=\"light-card\"").count(),
11073            2,
11074            "the Light resource installs once in each Shadow tree: {html}"
11075        );
11076        assert_eq!(
11077            html.matches("data-webui-resource=").count(),
11078            4,
11079            "outer-box + light-card and inner-box + light-card are the four tree-local installs"
11080        );
11081        assert_eq!(
11082            html.matches("nonce=\"css-nonce\"").count(),
11083            4,
11084            "each inline style resource carries the nonce"
11085        );
11086        assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
11087        let inner_end = html.find("</inner-box>").expect("inner host close");
11088        let caller_continuation = &html[inner_end..];
11089        let trailing_card = caller_continuation
11090            .find("<p>card</p>")
11091            .expect("caller continues after nested Shadow root");
11092        assert!(trailing_card > 0);
11093        assert!(
11094            !caller_continuation.contains("data-webui-resource=\"light-card\""),
11095            "returning from the nested Shadow root must restore the outer frame's delivered set"
11096        );
11097    }
11098
11099    #[test]
11100    fn routed_document_styles_are_hoisted_for_the_active_chain() {
11101        let route = WebUiFragmentRoute {
11102            path: "/".to_string(),
11103            fragment_id: "app-shell".to_string(),
11104            children: vec![
11105                WebUiFragmentRoute {
11106                    path: String::new(),
11107                    fragment_id: "dashboard-page".to_string(),
11108                    exact: true,
11109                    ..Default::default()
11110                },
11111                WebUiFragmentRoute {
11112                    path: "details".to_string(),
11113                    fragment_id: "detail-page".to_string(),
11114                    exact: true,
11115                    ..Default::default()
11116                },
11117            ],
11118            ..Default::default()
11119        };
11120        let fragments = HashMap::from([
11121            (
11122                "index.html".to_string(),
11123                FragmentList {
11124                    fragments: vec![
11125                        WebUIFragment::raw("<html><head>"),
11126                        structural_fragment("head_start"),
11127                        structural_fragment("head_end"),
11128                        WebUIFragment::raw("</head><body>"),
11129                        structural_fragment("body_start"),
11130                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11131                        WebUIFragment::route_from(route),
11132                        WebUIFragment::boundary_end(0),
11133                        structural_fragment("body_end"),
11134                        WebUIFragment::raw("</body></html>"),
11135                    ],
11136                    contains_boundary: true,
11137                },
11138            ),
11139            (
11140                "app-shell".to_string(),
11141                FragmentList {
11142                    fragments: vec![
11143                        WebUIFragment::raw("<header>App</header>"),
11144                        WebUIFragment::outlet(),
11145                    ],
11146                    contains_boundary: false,
11147                },
11148            ),
11149            (
11150                "dashboard-page".to_string(),
11151                FragmentList {
11152                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11153                    contains_boundary: false,
11154                },
11155            ),
11156            (
11157                "detail-page".to_string(),
11158                FragmentList {
11159                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11160                    contains_boundary: false,
11161                },
11162            ),
11163        ]);
11164        let mut protocol = WebUIProtocol::new(fragments);
11165        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11166        for tag in ["app-shell", "dashboard-page", "detail-page"] {
11167            let component = protocol.components.entry(tag.to_string()).or_default();
11168            component.css_href = format!("/{tag}.css");
11169            component.template_json = r#"{"h":""}"#.to_string();
11170        }
11171        protocol.populate_style_closures(&["index.html"]);
11172
11173        let mut writer = TestWriter::new();
11174        handle(
11175            &protocol,
11176            &test_json!({}),
11177            &RenderOptions::new("index.html", "/"),
11178            &mut writer,
11179        )
11180        .unwrap();
11181        let html = writer.get_content();
11182
11183        assert_eq!(
11184            html.matches(r#"data-webui-resource="app-shell""#).count(),
11185            1,
11186            "the matched route shell must install once: {html}"
11187        );
11188        assert_eq!(
11189            html.matches(r#"data-webui-resource="dashboard-page""#)
11190                .count(),
11191            1,
11192            "the matched child route must install once: {html}"
11193        );
11194        assert!(
11195            !html.contains(r#"data-webui-resource="detail-page""#),
11196            "inactive route CSS must not be delivered: {html}"
11197        );
11198        let head_end = html.find("</head>").expect("head close");
11199        let head = &html[..head_end];
11200        for (resource, href) in [
11201            ("app-shell", "/app-shell.css"),
11202            ("dashboard-page", "/dashboard-page.css"),
11203        ] {
11204            let encoded_href = crate::html_encode::encode_safe(href);
11205            assert!(
11206                head.contains(&format!(
11207                    r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
11208                )),
11209                "Document-targeted route CSS must be render-blocking in head: {html}"
11210            );
11211            assert!(
11212                !head.contains(&format!(
11213                    r#"<link rel="preload" as="style" href="{encoded_href}">"#
11214                )),
11215                "a hoisted stylesheet must not also be preloaded: {html}"
11216            );
11217        }
11218        assert!(
11219            !head.contains("detail-page.css"),
11220            "inactive route CSS must not be preloaded: {html}"
11221        );
11222        let app_style = html
11223            .find(r#"data-webui-resource="app-shell""#)
11224            .expect("app style");
11225        let dashboard_style = html
11226            .find(r#"data-webui-resource="dashboard-page""#)
11227            .expect("dashboard style");
11228        let app_host = html.find("<app-shell").expect("app route host");
11229        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11230        assert!(
11231            app_style < dashboard_style
11232                && dashboard_style < head_end
11233                && head_end < app_host
11234                && app_host < dashboard_host,
11235            "matched Document route closures must be hoisted in chain order: {html}"
11236        );
11237
11238        let protocol = Protocol::new(protocol);
11239        let mut streamed = FlushTestWriter::default();
11240        WebUIHandler::new()
11241            .render_streaming(
11242                &protocol,
11243                &test_json!({}),
11244                &RenderOptions::new("index.html", "/"),
11245                &mut streamed,
11246            )
11247            .unwrap();
11248        let streamed_head_end = streamed
11249            .output
11250            .find("</head>")
11251            .expect("streamed head close");
11252        for resource in ["app-shell", "dashboard-page"] {
11253            let marker = format!(r#"data-webui-resource="{resource}""#);
11254            let position = streamed.output.find(&marker).expect("streamed route style");
11255            assert!(
11256                position < streamed_head_end,
11257                "streaming must hoist {resource} into head: {}",
11258                streamed.output
11259            );
11260            assert_eq!(
11261                streamed.output.matches(&marker).count(),
11262                1,
11263                "streaming must not emit {resource} twice: {}",
11264                streamed.output
11265            );
11266        }
11267    }
11268
11269    #[test]
11270    fn bundled_routed_document_stylesheet_is_hoisted_once() {
11271        let route = WebUiFragmentRoute {
11272            path: "/".to_string(),
11273            fragment_id: "dashboard-page".to_string(),
11274            exact: true,
11275            ..Default::default()
11276        };
11277        let mut protocol = WebUIProtocol::new(HashMap::from([
11278            (
11279                "index.html".to_string(),
11280                FragmentList {
11281                    fragments: vec![
11282                        WebUIFragment::raw("<html><head>"),
11283                        structural_fragment("head_start"),
11284                        structural_fragment("head_end"),
11285                        WebUIFragment::raw("</head><body>"),
11286                        WebUIFragment::route_from(route),
11287                        structural_fragment("body_end"),
11288                        WebUIFragment::raw("</body></html>"),
11289                    ],
11290                    contains_boundary: false,
11291                },
11292            ),
11293            (
11294                "dashboard-page".to_string(),
11295                FragmentList {
11296                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11297                    contains_boundary: false,
11298                },
11299            ),
11300        ]));
11301        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11302        for tag in ["dashboard-page", "summary-card", "activity-list"] {
11303            protocol
11304                .components
11305                .entry(tag.to_string())
11306                .or_default()
11307                .css_href = format!("/{tag}.css");
11308        }
11309        protocol.style_closures.insert(
11310            "index.html".to_string(),
11311            webui_protocol::ComponentStyleClosure::default(),
11312        );
11313        protocol.style_closures.insert(
11314            "dashboard-page".to_string(),
11315            webui_protocol::ComponentStyleClosure {
11316                component_tags: vec![
11317                    "dashboard-page".to_string(),
11318                    "summary-card".to_string(),
11319                    "activity-list".to_string(),
11320                ],
11321                style_chunks: vec![0],
11322            },
11323        );
11324        protocol.style_chunks.push(webui_protocol::StyleChunk {
11325            name: "_chunk-dashboard-page-3".to_string(),
11326            css: String::new(),
11327            css_href: "/_chunk-dashboard-page-3.css".to_string(),
11328            component_tags: vec![
11329                "dashboard-page".to_string(),
11330                "summary-card".to_string(),
11331                "activity-list".to_string(),
11332            ],
11333        });
11334
11335        let mut writer = TestWriter::new();
11336        handle(
11337            &protocol,
11338            &test_json!({}),
11339            &RenderOptions::new("index.html", "/"),
11340            &mut writer,
11341        )
11342        .unwrap();
11343        let html = writer.get_content();
11344        let head_end = html.find("</head>").expect("head close");
11345        let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
11346        let style = html.find(marker).expect("bundled route stylesheet");
11347
11348        assert!(
11349            style < head_end,
11350            "bundled route CSS must be in head: {html}"
11351        );
11352        assert_eq!(
11353            html.matches(marker).count(),
11354            1,
11355            "the route host must not emit the hoisted chunk again: {html}"
11356        );
11357        let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
11358        assert!(
11359            !html.contains(&format!(
11360                r#"<link rel="preload" as="style" href="{bundled_href}">"#
11361            )),
11362            "the applied bundled stylesheet must not also be preloaded: {html}"
11363        );
11364    }
11365
11366    #[test]
11367    fn routed_document_inline_styles_are_hoisted() {
11368        for strategy in [
11369            webui_protocol::CssStrategy::Style,
11370            webui_protocol::CssStrategy::Module,
11371        ] {
11372            let route = WebUiFragmentRoute {
11373                path: "/".to_string(),
11374                fragment_id: "dashboard-page".to_string(),
11375                exact: true,
11376                ..Default::default()
11377            };
11378            let mut protocol = WebUIProtocol::new(HashMap::from([
11379                (
11380                    "index.html".to_string(),
11381                    FragmentList {
11382                        fragments: vec![
11383                            WebUIFragment::raw("<html><head>"),
11384                            structural_fragment("head_start"),
11385                            structural_fragment("head_end"),
11386                            WebUIFragment::raw("</head><body>"),
11387                            WebUIFragment::route_from(route),
11388                            structural_fragment("body_end"),
11389                            WebUIFragment::raw("</body></html>"),
11390                        ],
11391                        contains_boundary: false,
11392                    },
11393                ),
11394                (
11395                    "dashboard-page".to_string(),
11396                    FragmentList {
11397                        fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11398                        contains_boundary: false,
11399                    },
11400                ),
11401            ]));
11402            protocol.set_css_strategy(strategy);
11403            protocol
11404                .components
11405                .entry("dashboard-page".to_string())
11406                .or_default()
11407                .css = ".dashboard{display:grid}".to_string();
11408            protocol.populate_style_closures(&["index.html"]);
11409
11410            let mut writer = TestWriter::new();
11411            handle(
11412                &protocol,
11413                &test_json!({}),
11414                &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11415                &mut writer,
11416            )
11417            .unwrap();
11418            let html = writer.get_content();
11419            let head_end = html.find("</head>").expect("head close");
11420            let marker = r#"data-webui-resource="dashboard-page""#;
11421            let style = html.find(marker).expect("route style fallback");
11422            let host = html.find("<dashboard-page").expect("dashboard route host");
11423            let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
11424                "module"
11425            } else {
11426                "style"
11427            };
11428            let style_marker = format!(
11429                r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11430            );
11431
11432            assert!(
11433                style < head_end && head_end < host,
11434                "{strategy:?} route CSS must be applied in head before its host: {html}"
11435            );
11436            assert_eq!(
11437                html.matches(&style_marker).count(),
11438                1,
11439                "{strategy:?} route CSS must not be emitted twice: {html}"
11440            );
11441        }
11442    }
11443
11444    #[test]
11445    fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11446        let route = WebUiFragmentRoute {
11447            path: "/".to_string(),
11448            fragment_id: "dashboard-page".to_string(),
11449            exact: true,
11450            ..Default::default()
11451        };
11452        let mut protocol = WebUIProtocol::new(HashMap::from([
11453            (
11454                "index.html".to_string(),
11455                FragmentList {
11456                    fragments: vec![
11457                        WebUIFragment::raw("<html><head>"),
11458                        structural_fragment("head_start"),
11459                        structural_fragment("head_end"),
11460                        WebUIFragment::raw("</head><body><outer-box>"),
11461                        WebUIFragment::component("outer-box"),
11462                        WebUIFragment::raw("</outer-box></body></html>"),
11463                    ],
11464                    contains_boundary: false,
11465                },
11466            ),
11467            (
11468                "outer-box".to_string(),
11469                FragmentList {
11470                    fragments: vec![
11471                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11472                        structural_fragment("shadow_styles:outer-box"),
11473                        WebUIFragment::route_from(route),
11474                        WebUIFragment::raw("</template>"),
11475                    ],
11476                    contains_boundary: false,
11477                },
11478            ),
11479            (
11480                "dashboard-page".to_string(),
11481                FragmentList {
11482                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11483                    contains_boundary: false,
11484                },
11485            ),
11486        ]));
11487        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11488        for tag in ["outer-box", "dashboard-page"] {
11489            protocol
11490                .components
11491                .entry(tag.to_string())
11492                .or_default()
11493                .css_href = format!("/{tag}.css");
11494        }
11495        protocol
11496            .components
11497            .get_mut("outer-box")
11498            .expect("outer-box component")
11499            .uses_shadow_dom = true;
11500        protocol.populate_style_closures(&["index.html"]);
11501
11502        let mut writer = TestWriter::new();
11503        handle(
11504            &protocol,
11505            &test_json!({}),
11506            &RenderOptions::new("index.html", "/"),
11507            &mut writer,
11508        )
11509        .unwrap();
11510        let html = writer.get_content();
11511        let head_end = html.find("</head>").expect("head close");
11512        let head = &html[..head_end];
11513        let dashboard_style = html
11514            .find(r#"data-webui-resource="dashboard-page""#)
11515            .expect("tree-local dashboard stylesheet");
11516        let shadow_start = html
11517            .find("<template shadowrootmode=\"open\">")
11518            .expect("Shadow template");
11519        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11520        let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11521
11522        assert!(
11523            head.contains(&format!(
11524                r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11525            )),
11526            "Shadow-targeted route CSS must start loading from head: {html}"
11527        );
11528        assert!(
11529            !head.contains(r#"data-webui-resource="dashboard-page""#),
11530            "Shadow-targeted route CSS must not be applied to Document: {html}"
11531        );
11532        assert!(
11533            shadow_start < dashboard_style && dashboard_style < dashboard_host,
11534            "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11535        );
11536    }
11537
11538    #[test]
11539    fn isolated_shadow_entry_does_not_emit_document_preloads() {
11540        let route = WebUiFragmentRoute {
11541            path: "/".to_string(),
11542            fragment_id: "dashboard-page".to_string(),
11543            exact: true,
11544            ..Default::default()
11545        };
11546        let mut protocol = WebUIProtocol::new(HashMap::from([
11547            (
11548                "outer-box".to_string(),
11549                FragmentList {
11550                    fragments: vec![
11551                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11552                        structural_fragment("shadow_styles:outer-box"),
11553                        WebUIFragment::route_from(route),
11554                        WebUIFragment::raw("</template>"),
11555                    ],
11556                    contains_boundary: false,
11557                },
11558            ),
11559            (
11560                "dashboard-page".to_string(),
11561                FragmentList {
11562                    fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11563                    contains_boundary: false,
11564                },
11565            ),
11566        ]));
11567        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11568        for tag in ["outer-box", "dashboard-page"] {
11569            protocol
11570                .components
11571                .entry(tag.to_string())
11572                .or_default()
11573                .css_href = format!("/{tag}.css");
11574        }
11575        protocol
11576            .components
11577            .get_mut("outer-box")
11578            .expect("outer-box component")
11579            .uses_shadow_dom = true;
11580        protocol.populate_style_closures(&["outer-box"]);
11581
11582        let mut writer = TestWriter::new();
11583        handle(
11584            &protocol,
11585            &test_json!({}),
11586            &RenderOptions::new("outer-box", "/"),
11587            &mut writer,
11588        )
11589        .unwrap();
11590        let html = writer.get_content();
11591
11592        assert!(
11593            html.starts_with("<template shadowrootmode=\"open\">"),
11594            "isolated component output must not be prefixed with document metadata: {html}"
11595        );
11596        assert!(
11597            !html.contains(r#"<link rel="preload""#),
11598            "an isolated Shadow entry has no Document head for preloads: {html}"
11599        );
11600        assert!(
11601            html.contains(r#"data-webui-resource="dashboard-page""#),
11602            "the route stylesheet must still install inside the ShadowRoot: {html}"
11603        );
11604    }
11605
11606    #[test]
11607    fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11608        let route = WebUiFragmentRoute {
11609            path: "/".to_string(),
11610            fragment_id: "app-shell".to_string(),
11611            children: vec![
11612                WebUiFragmentRoute {
11613                    path: String::new(),
11614                    fragment_id: "dashboard-page".to_string(),
11615                    exact: true,
11616                    ..Default::default()
11617                },
11618                WebUiFragmentRoute {
11619                    path: "details".to_string(),
11620                    fragment_id: "detail-page".to_string(),
11621                    exact: true,
11622                    ..Default::default()
11623                },
11624            ],
11625            ..Default::default()
11626        };
11627        let fragments = HashMap::from([
11628            (
11629                "index.html".to_string(),
11630                FragmentList {
11631                    fragments: vec![
11632                        WebUIFragment::raw("<html><head>"),
11633                        structural_fragment("head_start"),
11634                        structural_fragment("head_end"),
11635                        WebUIFragment::raw("</head><body>"),
11636                        structural_fragment("body_start"),
11637                        WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11638                        WebUIFragment::route_from(route),
11639                        WebUIFragment::boundary_end(0),
11640                        structural_fragment("body_end"),
11641                        WebUIFragment::raw("</body></html>"),
11642                    ],
11643                    contains_boundary: true,
11644                },
11645            ),
11646            (
11647                "app-shell".to_string(),
11648                FragmentList {
11649                    fragments: vec![
11650                        WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11651                        structural_fragment("shadow_styles:app-shell"),
11652                        WebUIFragment::raw("<shared-card"),
11653                        structural_fragment("streaming_root:shared-card"),
11654                        WebUIFragment::raw(">"),
11655                        WebUIFragment::component("shared-card"),
11656                        WebUIFragment::raw("</shared-card>"),
11657                        WebUIFragment::outlet(),
11658                        WebUIFragment::raw("</template>"),
11659                    ],
11660                    contains_boundary: false,
11661                },
11662            ),
11663            (
11664                "dashboard-page".to_string(),
11665                FragmentList {
11666                    fragments: vec![
11667                        WebUIFragment::raw("<shared-card"),
11668                        structural_fragment("streaming_root:shared-card"),
11669                        WebUIFragment::raw(">"),
11670                        WebUIFragment::component("shared-card"),
11671                        WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11672                    ],
11673                    contains_boundary: false,
11674                },
11675            ),
11676            (
11677                "detail-page".to_string(),
11678                FragmentList {
11679                    fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11680                    contains_boundary: false,
11681                },
11682            ),
11683            (
11684                "shared-card".to_string(),
11685                FragmentList {
11686                    fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11687                    contains_boundary: false,
11688                },
11689            ),
11690        ]);
11691        let mut protocol = WebUIProtocol::new(fragments);
11692        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11693        for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11694            let component = protocol.components.entry(tag.to_string()).or_default();
11695            component.css = format!(".{tag}{{display:block}}");
11696            component.template_json = r#"{"h":""}"#.to_string();
11697        }
11698        protocol
11699            .components
11700            .get_mut("app-shell")
11701            .expect("app shell component")
11702            .uses_shadow_dom = true;
11703        protocol.populate_style_closures(&["index.html"]);
11704
11705        let mut writer = TestWriter::new();
11706        handle(
11707            &protocol,
11708            &test_json!({}),
11709            &RenderOptions::new("index.html", "/"),
11710            &mut writer,
11711        )
11712        .unwrap();
11713        let html = writer.get_content();
11714
11715        for resource in ["app-shell", "shared-card", "dashboard-page"] {
11716            assert_eq!(
11717                html.matches(&format!(r#"data-webui-resource="{resource}""#))
11718                    .count(),
11719                1,
11720                "{resource} must install once in the active ShadowRoot: {html}"
11721            );
11722        }
11723        assert!(
11724            !html.contains(r#"data-webui-resource="detail-page""#),
11725            "inactive route CSS must not enter the ShadowRoot: {html}"
11726        );
11727        let shadow_start = html
11728            .find("<template shadowrootmode=\"open\">")
11729            .expect("Shadow template");
11730        let dashboard_style = html
11731            .find(r#"data-webui-resource="dashboard-page""#)
11732            .expect("dashboard style");
11733        let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11734        let shadow_end = html.find("</template>").expect("Shadow template close");
11735        assert!(
11736            shadow_start < dashboard_style
11737                && dashboard_style < dashboard_host
11738                && dashboard_host < shadow_end,
11739            "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11740        );
11741
11742        let protocol = Protocol::new(protocol);
11743        let mut streamed = FlushTestWriter::default();
11744        WebUIHandler::new()
11745            .render_streaming(
11746                &protocol,
11747                &test_json!({}),
11748                &RenderOptions::new("index.html", "/"),
11749                &mut streamed,
11750            )
11751            .unwrap();
11752        for resource in ["app-shell", "shared-card"] {
11753            assert_eq!(
11754                streamed
11755                    .output
11756                    .matches(&format!(r#"data-webui-resource="{resource}""#))
11757                    .count(),
11758                1,
11759                "streaming must preserve tree-local delivery for {resource}: {}",
11760                streamed.output
11761            );
11762        }
11763        assert!(
11764            !streamed
11765                .output
11766                .contains(r#"data-webui-resource="dashboard-page""#),
11767            "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11768            streamed.output
11769        );
11770        assert!(
11771            streamed
11772                .output
11773                .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11774                && streamed
11775                    .output
11776                    .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11777            "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11778            streamed.output
11779        );
11780        assert!(
11781            !streamed
11782                .output
11783                .contains(r#"data-webui-resource="detail-page""#),
11784            "streaming must omit inactive route CSS: {}",
11785            streamed.output
11786        );
11787    }
11788
11789    #[test]
11790    fn document_styles_follow_doctype_before_headless_document_content() {
11791        for entry_fragments in [
11792            vec![
11793                WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11794                structural_fragment("body_start"),
11795                WebUIFragment::raw("<my-card>"),
11796                WebUIFragment::component("my-card"),
11797                WebUIFragment::raw("</my-card></body></html>"),
11798            ],
11799            vec![
11800                WebUIFragment::raw("<!doctype html><html><my-card>"),
11801                WebUIFragment::component("my-card"),
11802                WebUIFragment::raw("</my-card></html>"),
11803            ],
11804        ] {
11805            let mut protocol = WebUIProtocol::new(HashMap::from([
11806                (
11807                    "index.html".to_string(),
11808                    FragmentList {
11809                        fragments: entry_fragments,
11810                        contains_boundary: false,
11811                    },
11812                ),
11813                (
11814                    "my-card".to_string(),
11815                    FragmentList {
11816                        fragments: vec![WebUIFragment::raw("<p>card</p>")],
11817                        contains_boundary: false,
11818                    },
11819                ),
11820            ]));
11821            protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11822            let component = protocol
11823                .components
11824                .entry("my-card".to_string())
11825                .or_default();
11826            component.css = ".card{color:red}".to_string();
11827            component.uses_shadow_dom = false;
11828            protocol.populate_style_closures(&["index.html"]);
11829
11830            let mut writer = TestWriter::new();
11831            handle(
11832                &protocol,
11833                &test_json!({}),
11834                &RenderOptions::new("index.html", "/"),
11835                &mut writer,
11836            )
11837            .expect("render");
11838            let html = writer.get_content();
11839
11840            assert!(
11841                html.to_ascii_lowercase().starts_with("<!doctype html>"),
11842                "the doctype must remain the first document token: {html}"
11843            );
11844            let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11845            let document = html.find("<html").expect("document root");
11846            let component = html.find("<my-card>").expect("component host");
11847            assert!(
11848                style < document && style < component,
11849                "headless document styles must precede document content: {html}"
11850            );
11851        }
11852    }
11853
11854    #[test]
11855    fn authored_shadow_component_can_render_as_entry_fragment() {
11856        let mut protocol = WebUIProtocol::new(HashMap::from([(
11857            "my-card".to_string(),
11858            FragmentList {
11859                fragments: vec![
11860                    WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11861                    structural_fragment("shadow_styles:my-card"),
11862                    WebUIFragment::raw("<p>card</p></template>"),
11863                ],
11864                contains_boundary: false,
11865            },
11866        )]));
11867        protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11868        let component = protocol
11869            .components
11870            .entry("my-card".to_string())
11871            .or_default();
11872        component.css = ".card{color:red}".to_string();
11873        component.uses_shadow_dom = true;
11874        protocol.populate_style_closures(&["my-card"]);
11875
11876        let mut writer = TestWriter::new();
11877        handle(
11878            &protocol,
11879            &test_json!({}),
11880            &RenderOptions::new("my-card", "/"),
11881            &mut writer,
11882        )
11883        .expect("render");
11884        let html = writer.get_content();
11885
11886        assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11887        assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11888        assert!(html.ends_with("<p>card</p></template>"));
11889    }
11890
11891    #[test]
11892    fn component_style_closures_escape_mixed_case_style_end_tags() {
11893        for strategy in [
11894            webui_protocol::CssStrategy::Style,
11895            webui_protocol::CssStrategy::Module,
11896        ] {
11897            let mut protocol = WebUIProtocol::new(HashMap::from([
11898                (
11899                    "index.html".to_string(),
11900                    FragmentList {
11901                        fragments: vec![
11902                            WebUIFragment::raw("<html><head>"),
11903                            structural_fragment("head_end"),
11904                            WebUIFragment::raw("</head><body><safe-card>"),
11905                            WebUIFragment::component("safe-card"),
11906                            WebUIFragment::raw("</safe-card></body></html>"),
11907                        ],
11908                        contains_boundary: false,
11909                    },
11910                ),
11911                (
11912                    "safe-card".to_string(),
11913                    FragmentList {
11914                        fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11915                        contains_boundary: false,
11916                    },
11917                ),
11918            ]));
11919            protocol.set_css_strategy(strategy);
11920            protocol.components.insert(
11921                "safe-card".to_string(),
11922                webui_protocol::ComponentData {
11923                    css: ".safe{content:'</StYlE>'}".to_string(),
11924                    ..Default::default()
11925                },
11926            );
11927            protocol.populate_style_closures(&["index.html"]);
11928            let mut writer = TestWriter::new();
11929
11930            handle(
11931                &protocol,
11932                &test_json!({}),
11933                &RenderOptions::new("index.html", "/"),
11934                &mut writer,
11935            )
11936            .unwrap();
11937
11938            let html = writer.get_content();
11939            assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11940            assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11941        }
11942    }
11943
11944    #[test]
11945    fn shadow_closure_supports_link_and_module_ssr_paths() {
11946        for strategy in [
11947            webui_protocol::CssStrategy::Link,
11948            webui_protocol::CssStrategy::Module,
11949        ] {
11950            let mut protocol = WebUIProtocol::new(HashMap::from([
11951                (
11952                    "index.html".to_string(),
11953                    FragmentList {
11954                        fragments: vec![
11955                            WebUIFragment::raw("<html><head>".to_string()),
11956                            structural_fragment("head_end"),
11957                            WebUIFragment::raw("</head><body><my-card>".to_string()),
11958                            WebUIFragment::component("my-card"),
11959                            WebUIFragment::raw("</my-card></body></html>".to_string()),
11960                        ],
11961                        contains_boundary: false,
11962                    },
11963                ),
11964                (
11965                    "my-card".to_string(),
11966                    FragmentList {
11967                        fragments: vec![
11968                            WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11969                            structural_fragment("shadow_styles:my-card"),
11970                            WebUIFragment::raw("<p>card</p></template>".to_string()),
11971                        ],
11972                        contains_boundary: false,
11973                    },
11974                ),
11975            ]));
11976            protocol.set_css_strategy(strategy);
11977            let component = protocol
11978                .components
11979                .entry("my-card".to_string())
11980                .or_default();
11981            component.css = ".card{color:red}".to_string();
11982            component.css_href = "/my-card.css".to_string();
11983            component.uses_shadow_dom = true;
11984            protocol.populate_style_closures(&["index.html"]);
11985
11986            let mut writer = TestWriter::new();
11987            handle(
11988                &protocol,
11989                &test_json!({}),
11990                &RenderOptions::new("index.html", "/"),
11991                &mut writer,
11992            )
11993            .unwrap();
11994            let html = writer.get_content();
11995            match strategy {
11996                webui_protocol::CssStrategy::Link => {
11997                    let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11998                    assert!(html.contains(&format!(
11999                        "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
12000                    )));
12001                }
12002                webui_protocol::CssStrategy::Module => {
12003                    assert!(html.contains(
12004                        "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
12005                    ));
12006                    assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
12007                }
12008                webui_protocol::CssStrategy::Style => unreachable!(),
12009            }
12010        }
12011    }
12012
12013    #[test]
12014    fn test_module_preloads_emit_in_head_in_compiler_order() {
12015        // The whole value of these hints is ordering: preloads are issued in
12016        // document order over one connection, so the largest chunk must go
12017        // first. The compiler sorts; the handler must not reorder or dedupe.
12018        let mut fragments = HashMap::new();
12019        fragments.insert(
12020            "index.html".to_string(),
12021            FragmentList {
12022                fragments: vec![
12023                    WebUIFragment::raw(
12024                        r#"<html><head><script type="module" async src="/index.js"></script>"#
12025                            .to_string(),
12026                    ),
12027                    structural_fragment("head_end"),
12028                    WebUIFragment::raw("</head><body>".to_string()),
12029                    structural_fragment("body_end"),
12030                    WebUIFragment::raw("</body></html>".to_string()),
12031                ],
12032                contains_boundary: false,
12033            },
12034        );
12035
12036        let mut protocol = WebUIProtocol::new(fragments);
12037        protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
12038
12039        let state = test_json!({});
12040        let mut writer = TestWriter::new();
12041        handle(
12042            &protocol,
12043            &state,
12044            &RenderOptions::new("index.html", "/"),
12045            &mut writer,
12046        )
12047        .unwrap();
12048
12049        let html = writer.get_content();
12050        let head_end = html.find("</head>").expect("</head> missing");
12051        let head = &html[..head_end];
12052        assert!(
12053            head.contains(
12054                r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
12055            ),
12056            "hints must appear in <head> in the compiler's order: {html}"
12057        );
12058    }
12059
12060    #[test]
12061    fn test_no_module_preloads_emits_nothing() {
12062        let mut fragments = HashMap::new();
12063        fragments.insert(
12064            "index.html".to_string(),
12065            FragmentList {
12066                fragments: vec![
12067                    WebUIFragment::raw("<html><head>".to_string()),
12068                    structural_fragment("head_end"),
12069                    WebUIFragment::raw("</head><body>".to_string()),
12070                    structural_fragment("body_end"),
12071                    WebUIFragment::raw("</body></html>".to_string()),
12072                ],
12073                contains_boundary: false,
12074            },
12075        );
12076        let protocol = WebUIProtocol::new(fragments);
12077
12078        let state = test_json!({});
12079        let mut writer = TestWriter::new();
12080        handle(
12081            &protocol,
12082            &state,
12083            &RenderOptions::new("index.html", "/"),
12084            &mut writer,
12085        )
12086        .unwrap();
12087
12088        assert!(
12089            !writer.get_content().contains("modulepreload"),
12090            "a build without hints must be byte-identical to before"
12091        );
12092    }
12093
12094    #[test]
12095    fn test_link_strategy_head_links_follow_document_order() {
12096        // Regression for #381: Link-strategy <head> CSS <link> tags must be
12097        // emitted in document/traversal order, not alphabetical tag order.
12098        // Document order here is <z-widget> then <a-widget>; an alphabetical
12099        // sort would (incorrectly) place a-widget first.
12100        let mut fragments = HashMap::new();
12101        fragments.insert(
12102            "index.html".to_string(),
12103            FragmentList {
12104                fragments: vec![
12105                    WebUIFragment::raw("<html><head>".to_string()),
12106                    structural_fragment("head_end"),
12107                    WebUIFragment::raw("</head><body><z-widget>".to_string()),
12108                    WebUIFragment::component("z-widget"),
12109                    WebUIFragment::raw("</z-widget><a-widget>".to_string()),
12110                    WebUIFragment::component("a-widget"),
12111                    WebUIFragment::raw("</a-widget>".to_string()),
12112                    structural_fragment("body_end"),
12113                    WebUIFragment::raw("</body></html>".to_string()),
12114                ],
12115                contains_boundary: false,
12116            },
12117        );
12118        fragments.insert(
12119            "z-widget".to_string(),
12120            FragmentList {
12121                fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
12122                contains_boundary: false,
12123            },
12124        );
12125        fragments.insert(
12126            "a-widget".to_string(),
12127            FragmentList {
12128                fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
12129                contains_boundary: false,
12130            },
12131        );
12132
12133        let mut protocol = WebUIProtocol::new(fragments);
12134        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12135
12136        let z = protocol
12137            .components
12138            .entry("z-widget".to_string())
12139            .or_default();
12140        z.css_href = "z-widget.css".to_string();
12141        z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
12142
12143        let a = protocol
12144            .components
12145            .entry("a-widget".to_string())
12146            .or_default();
12147        a.css_href = "a-widget.css".to_string();
12148        a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
12149        protocol.populate_style_closures(&["index.html"]);
12150
12151        let state = test_json!({});
12152        let mut writer = TestWriter::new();
12153
12154        handle(
12155            &protocol,
12156            &state,
12157            &RenderOptions::new("index.html", "/"),
12158            &mut writer,
12159        )
12160        .unwrap();
12161
12162        let html = writer.get_content();
12163        let head_end = html.find("</head>").expect("</head> missing");
12164        let head_section = &html[..head_end];
12165
12166        let z_pos = head_section
12167            .find(r#"<link rel="stylesheet" href="z-widget.css""#)
12168            .expect("z-widget stylesheet link missing from <head>");
12169        let a_pos = head_section
12170            .find(r#"<link rel="stylesheet" href="a-widget.css""#)
12171            .expect("a-widget stylesheet link missing from <head>");
12172
12173        assert!(
12174            z_pos < a_pos,
12175            "CSS <link> tags must follow document order (z-widget before \
12176             a-widget), not alphabetical order: {html}"
12177        );
12178    }
12179
12180    #[test]
12181    fn test_css_module_emitted_in_component_light_dom() {
12182        // CSS module <style> tags are emitted inline in the component's light DOM,
12183        // not in <head>. This keeps SSR output lean — only rendered components
12184        // get their style definitions.
12185        let mut fragments = HashMap::new();
12186        fragments.insert(
12187            "index.html".to_string(),
12188            FragmentList {
12189                fragments: vec![
12190                    WebUIFragment::raw("<html><head>".to_string()),
12191                    structural_fragment("head_end"),
12192                    WebUIFragment::raw("</head><body><my-card>".to_string()),
12193                    WebUIFragment::component("my-card"),
12194                    WebUIFragment::raw("</my-card>".to_string()),
12195                    WebUIFragment::raw("</body></html>".to_string()),
12196                ],
12197                contains_boundary: false,
12198            },
12199        );
12200        fragments.insert(
12201            "my-card".to_string(),
12202            FragmentList {
12203                fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
12204                contains_boundary: false,
12205            },
12206        );
12207
12208        let mut protocol = WebUIProtocol::new(fragments);
12209        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12210        protocol
12211            .components
12212            .entry("my-card".to_string())
12213            .or_default()
12214            .css = "p{color:red}".to_string();
12215        protocol.populate_style_closures(&["index.html"]);
12216        let state = test_json!({});
12217        let mut writer = TestWriter::new();
12218
12219        handle(
12220            &protocol,
12221            &state,
12222            &RenderOptions::new("index.html", "/"),
12223            &mut writer,
12224        )
12225        .unwrap();
12226
12227        let html = writer.get_content();
12228
12229        // CSS module importmap must be INSIDE the component tag (light DOM)
12230        let tag_open = html.find("<my-card>").expect("<my-card> missing");
12231        let css_pos = html
12232            .find(r#"<script type="importmap""#)
12233            .expect("CSS module importmap missing");
12234        let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
12235        assert!(
12236            css_pos > tag_open && css_pos < tag_close,
12237            "CSS module should be inside component light DOM: {html}"
12238        );
12239
12240        // <head> should NOT contain module styles
12241        let head_end = html.find("</head>").expect("</head> missing");
12242        assert!(
12243            css_pos > head_end,
12244            "CSS module should not be in <head>: {html}"
12245        );
12246    }
12247
12248    #[test]
12249    fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
12250        let mut protocol = WebUIProtocol::default();
12251        protocol.fragments = HashMap::from([
12252            (
12253                "index.html".to_string(),
12254                FragmentList {
12255                    fragments: vec![
12256                        WebUIFragment::raw("<legacy-card>"),
12257                        WebUIFragment::component("legacy-card"),
12258                        WebUIFragment::raw("</legacy-card>"),
12259                    ],
12260                    contains_boundary: false,
12261                },
12262            ),
12263            (
12264                "legacy-card".to_string(),
12265                FragmentList {
12266                    fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
12267                    contains_boundary: false,
12268                },
12269            ),
12270        ]);
12271        protocol.components.insert(
12272            "legacy-card".to_string(),
12273            webui_protocol::ComponentData {
12274                css: ".legacy{color:red}".to_string(),
12275                ..Default::default()
12276            },
12277        );
12278        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12279        let mut writer = TestWriter::new();
12280
12281        let error = handle(
12282            &protocol,
12283            &test_json!({}),
12284            &RenderOptions::new("index.html", "/"),
12285            &mut writer,
12286        )
12287        .expect_err("styled protocols require closure metadata");
12288        assert!(error
12289            .to_string()
12290            .contains("component style closure metadata is required"));
12291    }
12292
12293    #[test]
12294    fn test_css_module_emitted_for_route_components() {
12295        // Route components get CSS modules emitted inline in their light DOM.
12296        let template = r#"<h1>Dashboard</h1>"#;
12297
12298        let mut fragments = HashMap::new();
12299        fragments.insert(
12300            "index.html".to_string(),
12301            FragmentList {
12302                fragments: vec![
12303                    WebUIFragment::raw("<html><head>".to_string()),
12304                    structural_fragment("head_end"),
12305                    WebUIFragment::raw("</head><body>".to_string()),
12306                    WebUIFragment::route("/", "dash-page"),
12307                    structural_fragment("body_end"),
12308                    WebUIFragment::raw("</body></html>".to_string()),
12309                ],
12310                contains_boundary: false,
12311            },
12312        );
12313        fragments.insert(
12314            "dash-page".to_string(),
12315            FragmentList {
12316                fragments: vec![WebUIFragment::raw(template.to_string())],
12317                contains_boundary: false,
12318            },
12319        );
12320
12321        let mut protocol = WebUIProtocol::new(fragments);
12322        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12323        let comp = protocol
12324            .components
12325            .entry("dash-page".to_string())
12326            .or_default();
12327        comp.css = "h1{font-size:2rem}".to_string();
12328        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12329        protocol.populate_style_closures(&["index.html"]);
12330        let state = test_json!({});
12331        let mut writer = TestWriter::new();
12332
12333        handle(
12334            &protocol,
12335            &state,
12336            &RenderOptions::new("index.html", "/"),
12337            &mut writer,
12338        )
12339        .unwrap();
12340
12341        let html = writer.get_content();
12342
12343        assert!(
12344            html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
12345            "Route component should have CSS module importmap with data: URI: {html}"
12346        );
12347        assert!(
12348            html.contains("<h1>Dashboard</h1>"),
12349            "Route component should render content: {html}"
12350        );
12351        let fallback = html
12352            .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
12353            .expect("route Module fallback");
12354        let host_open = html.find("<dash-page>").expect("route host");
12355        let content = html.find("<h1>Dashboard</h1>").expect("route content");
12356        assert!(
12357            fallback < host_open && host_open < content,
12358            "the Module fallback must precede the generated Light route host: {html}"
12359        );
12360        // The importmap belongs to the component's own Light DOM, never to the
12361        // route element. The router only treats `<link>`/`<style>` markers as
12362        // route-owned styles, so an importmap emitted as a direct route child
12363        // would be cleared on navigation and mistaken for the mounted
12364        // component. Keep it inside the host so that can never happen.
12365        let importmap = html
12366            .find(r#"<script type="importmap""#)
12367            .expect("route CSS module importmap");
12368        assert!(
12369            host_open < importmap,
12370            "the CSS module importmap must live inside the route host, not as a route child: {html}"
12371        );
12372    }
12373
12374    #[test]
12375    fn test_head_css_link_skipped_for_components_without_css() {
12376        // Regression: components without CSS files must not get <link> tags
12377        // in <head>, otherwise the browser requests a 404.
12378        let mut fragments = HashMap::new();
12379        fragments.insert(
12380            "index.html".to_string(),
12381            FragmentList {
12382                fragments: vec![
12383                    WebUIFragment::raw("<html><head>".to_string()),
12384                    structural_fragment("head_end"),
12385                    WebUIFragment::raw("</head><body>".to_string()),
12386                    WebUIFragment::component("has-css"),
12387                    WebUIFragment::component("no-css"),
12388                    structural_fragment("body_end"),
12389                    WebUIFragment::raw("</body></html>".to_string()),
12390                ],
12391                contains_boundary: false,
12392            },
12393        );
12394        fragments.insert(
12395            "has-css".to_string(),
12396            FragmentList {
12397                fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
12398                contains_boundary: false,
12399            },
12400        );
12401        fragments.insert(
12402            "no-css".to_string(),
12403            FragmentList {
12404                fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
12405                contains_boundary: false,
12406            },
12407        );
12408
12409        let mut protocol = WebUIProtocol::new(fragments);
12410        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12411
12412        // Only has-css has an external stylesheet (Link strategy)
12413        protocol
12414            .components
12415            .entry("has-css".to_string())
12416            .or_default()
12417            .css_href = "has-css.css".to_string();
12418        protocol
12419            .components
12420            .entry("no-css".to_string())
12421            .or_default()
12422            .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
12423        protocol.populate_style_closures(&["index.html"]);
12424
12425        let state = test_json!({});
12426        let mut writer = TestWriter::new();
12427
12428        let handler = WebUIHandler::with_plugin(|| {
12429            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12430        });
12431        handler
12432            .handle(
12433                &protocol,
12434                &state,
12435                &RenderOptions::new("index.html", "/"),
12436                &mut writer,
12437            )
12438            .unwrap();
12439
12440        let html = writer.get_content();
12441        assert!(
12442            html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12443            "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12444        );
12445        assert!(
12446            !html.contains("no-css.css"),
12447            "Component without CSS must NOT get a <link> in <head>: {html}"
12448        );
12449    }
12450
12451    #[test]
12452    fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12453        // Simulates a page where app-shell renders cart-panel, but cart-panel
12454        // contains an <if> block with product-card inside. When the condition
12455        // is false (empty cart), product-card is NOT rendered — but it IS
12456        // reachable from the fragment graph. Its template metadata and CSS module
12457        // definition must be in the output so the client can mount it when
12458        // the <if> flips true. However, its bit must NOT be set in the
12459        // inventory — the inventory tracks what was actually rendered.
12460        let mut fragments = HashMap::new();
12461        fragments.insert(
12462            "index.html".to_string(),
12463            FragmentList {
12464                fragments: vec![
12465                    WebUIFragment::raw("<html><head>".to_string()),
12466                    structural_fragment("head_end"),
12467                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12468                    WebUIFragment::component("app-shell"),
12469                    WebUIFragment::raw("</app-shell>".to_string()),
12470                    structural_fragment("body_end"),
12471                    WebUIFragment::raw("</body></html>".to_string()),
12472                ],
12473                contains_boundary: false,
12474            },
12475        );
12476        // app-shell contains a cart panel
12477        fragments.insert(
12478            "app-shell".to_string(),
12479            FragmentList {
12480                fragments: vec![
12481                    WebUIFragment::raw("<div>Shell</div>".to_string()),
12482                    WebUIFragment::component("cart-panel"),
12483                ],
12484                contains_boundary: false,
12485            },
12486        );
12487        // cart-panel has an <if> block containing product-card
12488        fragments.insert(
12489            "cart-panel".to_string(),
12490            FragmentList {
12491                fragments: vec![
12492                    WebUIFragment::raw("<aside>".to_string()),
12493                    WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12494                    WebUIFragment::raw("</aside>".to_string()),
12495                ],
12496                contains_boundary: false,
12497            },
12498        );
12499        // cart-items (if block body) contains product-card
12500        fragments.insert(
12501            "cart-items".to_string(),
12502            FragmentList {
12503                fragments: vec![WebUIFragment::component("product-card")],
12504                contains_boundary: false,
12505            },
12506        );
12507        fragments.insert(
12508            "product-card".to_string(),
12509            FragmentList {
12510                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12511                contains_boundary: false,
12512            },
12513        );
12514
12515        let mut protocol = WebUIProtocol::new(fragments);
12516        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12517        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12518        for name in ["app-shell", "cart-panel", "product-card"] {
12519            let comp = protocol.components.entry(name.to_string()).or_default();
12520            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12521            comp.css = format!(".{name}{{display:block}}");
12522            if name == "cart-panel" {
12523                comp.hydration_mode = StateProjectionMode::Keys as i32;
12524                comp.hydration_keys = vec!["hasItems".to_string()];
12525            }
12526            if name == "product-card" {
12527                comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12528            }
12529        }
12530        protocol.populate_style_closures(&["index.html"]);
12531
12532        // Render with hasItems=false — product-card should NOT be rendered
12533        let state = test_json!({ "hasItems": false });
12534        let mut writer = TestWriter::new();
12535
12536        let handler = WebUIHandler::with_plugin(|| {
12537            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12538        });
12539        handler
12540            .handle(
12541                &protocol,
12542                &state,
12543                &RenderOptions::new("index.html", "/"),
12544                &mut writer,
12545            )
12546            .unwrap();
12547
12548        let html = writer.get_content();
12549
12550        assert!(
12551            html.contains(r#"<script type="application/json" id="webui-data">"#),
12552            "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12553        );
12554        assert!(
12555            html.contains(r#""state":{"hasItems":false}"#),
12556            "SSR state should live in the JSON data block: {html}"
12557        );
12558        assert!(
12559            html.contains(r#""inventory":"#),
12560            "SSR inventory should live in the JSON data block: {html}"
12561        );
12562        assert!(
12563            !html.contains("window.__webui={\""),
12564            "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12565        );
12566        assert!(
12567            !html.contains(r#"document.getElementById("webui-data")"#),
12568            "SSR must not parse webui-data; client packages own that lazy load: {html}"
12569        );
12570        assert!(
12571            !html.contains("window.__webui=w;"),
12572            "executable bootstrap must not replace existing window.__webui registrations: {html}"
12573        );
12574        assert!(
12575            !html.contains("w.templateFns={\""),
12576            "template function emission must not replace existing templateFns registrations: {html}"
12577        );
12578        assert!(
12579            html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12580            "template functions should merge into the flat templateFns registry: {html}"
12581        );
12582
12583        // product-card template IS in the output — it's a known component
12584        // whose template must be available for client-side <if> activation.
12585        assert!(
12586            html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12587            "product-card template should be emitted even when unrendered: {html}"
12588        );
12589
12590        // product-card CSS module IS in the output — reachable components need
12591        // their stylesheet definitions for client-side <if> activation.
12592        assert!(
12593            html.contains(r#""product-card":"data:text/css,"#),
12594            "reachable product-card CSS module importmap should be emitted: {html}"
12595        );
12596
12597        // app-shell and cart-panel SHOULD be in the output (they were rendered)
12598        assert!(
12599            html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12600            "rendered app-shell template should be emitted: {html}"
12601        );
12602        assert!(
12603            html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12604            "rendered cart-panel template should be emitted: {html}"
12605        );
12606    }
12607
12608    #[test]
12609    fn component_asset_manifest_falls_back_to_body_start() {
12610        let fragments = HashMap::from([(
12611            "index.html".to_string(),
12612            FragmentList {
12613                fragments: vec![
12614                    WebUIFragment::raw("<body>".to_string()),
12615                    structural_fragment("body_start"),
12616                    WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12617                ],
12618                contains_boundary: false,
12619            },
12620        )]);
12621        let mut protocol = WebUIProtocol::new(fragments);
12622        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12623            root: "lazy-panel".to_string(),
12624            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12625        }];
12626        let mut writer = TestWriter::new();
12627
12628        handle(
12629            &protocol,
12630            &Value::Null,
12631            &RenderOptions::new("index.html", "/"),
12632            &mut writer,
12633        )
12634        .unwrap();
12635
12636        assert_eq!(
12637            writer.get_content(),
12638            r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12639        );
12640    }
12641
12642    #[test]
12643    fn component_asset_styles_emit_once_when_body_precedes_head() {
12644        let fragments = HashMap::from([(
12645            "index.html".to_string(),
12646            FragmentList {
12647                fragments: vec![
12648                    WebUIFragment::raw("<body>".to_string()),
12649                    structural_fragment("body_start"),
12650                    WebUIFragment::raw("</body><head>".to_string()),
12651                    structural_fragment("head_end"),
12652                    WebUIFragment::raw("</head>".to_string()),
12653                ],
12654                contains_boundary: false,
12655            },
12656        )]);
12657        let mut protocol = WebUIProtocol::new(fragments);
12658        protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12659            root: "lazy-panel".to_string(),
12660            style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12661        }];
12662        let mut writer = TestWriter::new();
12663
12664        handle(
12665            &protocol,
12666            &Value::Null,
12667            &RenderOptions::new("index.html", "/"),
12668            &mut writer,
12669        )
12670        .unwrap();
12671
12672        assert_eq!(
12673            writer
12674                .get_content()
12675                .matches(r#"id="webui-component-assets""#)
12676                .count(),
12677            1
12678        );
12679    }
12680
12681    #[test]
12682    fn light_component_asset_styles_apply_at_head_end() {
12683        let fragments = HashMap::from([(
12684            "index.html".to_string(),
12685            FragmentList {
12686                fragments: vec![
12687                    WebUIFragment::raw("<html><head>".to_string()),
12688                    structural_fragment("head_end"),
12689                    WebUIFragment::raw("</head><body></body></html>".to_string()),
12690                ],
12691                contains_boundary: false,
12692            },
12693        )]);
12694        let mut protocol = WebUIProtocol::new(fragments);
12695        protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12696        for root in ["lazy-panel", "secondary-panel"] {
12697            protocol.components.insert(
12698                root.to_string(),
12699                webui_protocol::ComponentData {
12700                    uses_shadow_dom: false,
12701                    ..Default::default()
12702                },
12703            );
12704        }
12705        protocol.component_asset_style_preloads = vec![
12706            ComponentAssetStylePreload {
12707                root: "lazy-panel".to_string(),
12708                style_hrefs: vec![
12709                    "/assets/lazy-panel.css".to_string(),
12710                    "/assets/shared.css".to_string(),
12711                ],
12712            },
12713            ComponentAssetStylePreload {
12714                root: "secondary-panel".to_string(),
12715                style_hrefs: vec![
12716                    "/assets/secondary-panel.css".to_string(),
12717                    "/assets/shared.css".to_string(),
12718                ],
12719            },
12720        ];
12721        let mut writer = TestWriter::new();
12722
12723        handle(
12724            &protocol,
12725            &Value::Null,
12726            &RenderOptions::new("index.html", "/"),
12727            &mut writer,
12728        )
12729        .unwrap();
12730
12731        let html = writer.get_content();
12732        assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12733        assert_eq!(html.matches("/assets/shared.css").count(), 1);
12734        assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12735        let links_end = html
12736            .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12737            .expect("asset stylesheet must be emitted");
12738        let head_end = html.find("</head>").expect("head must close");
12739        assert!(links_end < head_end);
12740    }
12741
12742    // ── CSP nonce on CSS module importmap ───────────────────────────
12743    //
12744    // When `RenderOptions::with_nonce(...)` is set, every inline
12745    // `<script type="importmap">` definition emitted during SSR for a
12746    // component CSS module must include `nonce="VALUE"` so strict CSP
12747    // `script-src 'nonce-...'` policies allow it. The without-nonce case
12748    // is already covered by other CSS module tests (e.g.
12749    // `test_css_module_emitted_for_route_components`).
12750
12751    #[test]
12752    fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12753        // Per-component first-render path (`emit_css_module`).
12754        let template = r#"<h1>Dashboard</h1>"#;
12755
12756        let mut fragments = HashMap::new();
12757        fragments.insert(
12758            "index.html".to_string(),
12759            FragmentList {
12760                fragments: vec![
12761                    WebUIFragment::raw("<html><head>".to_string()),
12762                    structural_fragment("head_end"),
12763                    WebUIFragment::raw("</head><body>".to_string()),
12764                    WebUIFragment::route("/", "dash-page"),
12765                    structural_fragment("body_end"),
12766                    WebUIFragment::raw("</body></html>".to_string()),
12767                ],
12768                contains_boundary: false,
12769            },
12770        );
12771        fragments.insert(
12772            "dash-page".to_string(),
12773            FragmentList {
12774                fragments: vec![WebUIFragment::raw(template.to_string())],
12775                contains_boundary: false,
12776            },
12777        );
12778
12779        let mut protocol = WebUIProtocol::new(fragments);
12780        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12781        let comp = protocol
12782            .components
12783            .entry("dash-page".to_string())
12784            .or_default();
12785        comp.css = "h1{font-size:2rem}".to_string();
12786        comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12787        protocol.populate_style_closures(&["index.html"]);
12788        let state = test_json!({});
12789        let mut writer = TestWriter::new();
12790
12791        handle(
12792            &protocol,
12793            &state,
12794            &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12795            &mut writer,
12796        )
12797        .unwrap();
12798
12799        let html = writer.get_content();
12800
12801        assert!(
12802            html.contains(
12803                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12804            ),
12805            "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12806        );
12807    }
12808
12809    #[test]
12810    fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12811        // Body-end emission path for reachable-but-unrendered components
12812        // (the second site touched by the patch). Triggered via a false
12813        // `<if>` block under hydration; requires the WebUI plugin so the
12814        // body_end hook executes.
12815        let mut fragments = HashMap::new();
12816        fragments.insert(
12817            "index.html".to_string(),
12818            FragmentList {
12819                fragments: vec![
12820                    WebUIFragment::raw("<html><head>".to_string()),
12821                    structural_fragment("head_end"),
12822                    WebUIFragment::raw("</head><body><app-shell>".to_string()),
12823                    WebUIFragment::component("app-shell"),
12824                    WebUIFragment::raw("</app-shell>".to_string()),
12825                    structural_fragment("body_end"),
12826                    WebUIFragment::raw("</body></html>".to_string()),
12827                ],
12828                contains_boundary: false,
12829            },
12830        );
12831        fragments.insert(
12832            "app-shell".to_string(),
12833            FragmentList {
12834                fragments: vec![WebUIFragment::if_cond(
12835                    ConditionExpr::identifier("hasItems"),
12836                    "cart-items",
12837                )],
12838                contains_boundary: false,
12839            },
12840        );
12841        fragments.insert(
12842            "cart-items".to_string(),
12843            FragmentList {
12844                fragments: vec![WebUIFragment::component("product-card")],
12845                contains_boundary: false,
12846            },
12847        );
12848        fragments.insert(
12849            "product-card".to_string(),
12850            FragmentList {
12851                fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12852                contains_boundary: false,
12853            },
12854        );
12855
12856        let mut protocol = WebUIProtocol::new(fragments);
12857        protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12858        for name in ["app-shell", "product-card"] {
12859            let comp = protocol.components.entry(name.to_string()).or_default();
12860            comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12861            comp.css = format!(".{name}{{display:block}}");
12862        }
12863        protocol.populate_style_closures(&["index.html"]);
12864
12865        // Render with hasItems=false so product-card is reachable but not
12866        // rendered, forcing its CSS module emission through the body_end path.
12867        let state = test_json!({ "hasItems": false });
12868        let mut writer = TestWriter::new();
12869
12870        let handler = WebUIHandler::with_plugin(|| {
12871            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12872        });
12873        handler
12874            .handle(
12875                &protocol,
12876                &state,
12877                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12878                &mut writer,
12879            )
12880            .unwrap();
12881
12882        let html = writer.get_content();
12883
12884        assert!(
12885            html.contains(
12886                r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12887            ),
12888            "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12889        );
12890    }
12891
12892    #[test]
12893    fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12894        let mut fragments = HashMap::new();
12895        fragments.insert(
12896            "index.html".to_string(),
12897            FragmentList {
12898                fragments: vec![
12899                    WebUIFragment::raw("<html><body><style>".to_string()),
12900                    WebUIFragment::signal("tokens.light", true),
12901                    WebUIFragment::raw("</style>".to_string()),
12902                    structural_fragment("body_end"),
12903                    WebUIFragment::raw("</body></html>".to_string()),
12904                ],
12905                contains_boundary: false,
12906            },
12907        );
12908        fragments.insert(
12909            "app-shell".to_string(),
12910            FragmentList {
12911                fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12912                contains_boundary: false,
12913            },
12914        );
12915        let index_fragments = fragments
12916            .get_mut("index.html")
12917            .expect("index fixture should exist");
12918        index_fragments
12919            .fragments
12920            .insert(1, WebUIFragment::component("app-shell"));
12921        let mut protocol = WebUIProtocol::new(fragments);
12922        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12923        // Only `name` is a hydration key. `tokens` is a server-only field
12924        // (used above to resolve SSR CSS variables) and is NOT in the component
12925        // hydration keys,
12926        // so projection MUST keep it out of the client state block.
12927        protocol.components.insert(
12928            "app-shell".to_string(),
12929            webui_protocol::ComponentData {
12930                hydration_mode: StateProjectionMode::Keys as i32,
12931                hydration_keys: vec!["name".to_string()],
12932                ..Default::default()
12933            },
12934        );
12935        let state = test_json!({
12936            "name": "Alice",
12937            "tokens": {
12938                "light": "--color-brand: red;"
12939            }
12940        });
12941        let mut writer = TestWriter::new();
12942        let handler = WebUIHandler::with_plugin(|| {
12943            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12944        });
12945        handler.handle(
12946            &protocol,
12947            &state,
12948            &RenderOptions::new("index.html", "/"),
12949            &mut writer,
12950        )?;
12951        let output = writer.get_content();
12952
12953        // SSR still reads `tokens` to resolve the inline <style>...
12954        assert!(output.contains("--color-brand: red;"));
12955        // ...but only the hydration key reaches the client state.
12956        assert!(output.contains(r#""name":"Alice""#));
12957        assert!(!output.contains(r#""tokens""#));
12958        Ok(())
12959    }
12960
12961    #[test]
12962    fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12963        let mut fragments = HashMap::new();
12964        fragments.insert(
12965            "index.html".to_string(),
12966            FragmentList {
12967                fragments: vec![
12968                    WebUIFragment::raw("<html><body>"),
12969                    structural_fragment("body_end"),
12970                    WebUIFragment::raw("</body></html>"),
12971                ],
12972                contains_boundary: false,
12973            },
12974        );
12975        let protocol = WebUIProtocol::new(fragments);
12976        let state = test_json!({
12977            "client": "visible",
12978            "serverOnly": "also preserved",
12979        });
12980        let handler = WebUIHandler::with_plugin(|| {
12981            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12982        });
12983        let mut writer = TestWriter::new();
12984        handler.handle(
12985            &protocol,
12986            &state,
12987            &RenderOptions::new("index.html", "/"),
12988            &mut writer,
12989        )?;
12990        let output = writer.get_content();
12991        assert!(output.contains(r#""client":"visible""#));
12992        assert!(output.contains(r#""serverOnly":"also preserved""#));
12993        Ok(())
12994    }
12995
12996    #[test]
12997    fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12998        let mut fragments = HashMap::new();
12999        fragments.insert(
13000            "index.html".to_string(),
13001            FragmentList {
13002                fragments: vec![
13003                    WebUIFragment::raw("<html><body>"),
13004                    WebUIFragment::component("app-shell"),
13005                    structural_fragment("body_end"),
13006                    WebUIFragment::raw("</body></html>"),
13007                ],
13008                contains_boundary: false,
13009            },
13010        );
13011        fragments.insert(
13012            "app-shell".to_string(),
13013            FragmentList {
13014                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13015                contains_boundary: false,
13016            },
13017        );
13018        let mut protocol = WebUIProtocol::new(fragments);
13019        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13020        protocol.components.insert(
13021            "app-shell".to_string(),
13022            webui_protocol::ComponentData {
13023                hydration_mode: StateProjectionMode::All as i32,
13024                ..Default::default()
13025            },
13026        );
13027        let state = test_json!({
13028            "known": "value",
13029            "possiblyInherited": "must not be dropped",
13030        });
13031        let handler = WebUIHandler::with_plugin(|| {
13032            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13033        });
13034        let mut writer = TestWriter::new();
13035        handler.handle(
13036            &protocol,
13037            &state,
13038            &RenderOptions::new("index.html", "/"),
13039            &mut writer,
13040        )?;
13041        let output = writer.get_content();
13042        assert!(output.contains(r#""known":"value""#));
13043        assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
13044        Ok(())
13045    }
13046
13047    #[test]
13048    fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
13049        let mut fragments = HashMap::new();
13050        fragments.insert(
13051            "index.html".to_string(),
13052            FragmentList {
13053                fragments: vec![
13054                    WebUIFragment::raw("<html><body>"),
13055                    WebUIFragment::component("app-shell"),
13056                    structural_fragment("body_end"),
13057                    WebUIFragment::raw("</body></html>"),
13058                ],
13059                contains_boundary: false,
13060            },
13061        );
13062        fragments.insert(
13063            "app-shell".to_string(),
13064            FragmentList {
13065                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13066                contains_boundary: false,
13067            },
13068        );
13069        let mut protocol = WebUIProtocol::new(fragments);
13070        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13071        let state = test_json!({
13072            "known": "value",
13073            "serverOnly": "must not be dropped",
13074        });
13075        let handler = WebUIHandler::with_plugin(|| {
13076            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13077        });
13078        let mut writer = TestWriter::new();
13079        handler.handle(
13080            &protocol,
13081            &state,
13082            &RenderOptions::new("index.html", "/"),
13083            &mut writer,
13084        )?;
13085        let output = writer.get_content();
13086        assert!(output.contains(r#""known":"value""#));
13087        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
13088        Ok(())
13089    }
13090
13091    #[test]
13092    fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
13093        let mut fragments = HashMap::new();
13094        fragments.insert(
13095            "index.html".to_string(),
13096            FragmentList {
13097                fragments: vec![
13098                    WebUIFragment::raw("<html><body>"),
13099                    WebUIFragment::component("app-shell"),
13100                    structural_fragment("body_end"),
13101                    WebUIFragment::raw("</body></html>"),
13102                ],
13103                contains_boundary: false,
13104            },
13105        );
13106        fragments.insert(
13107            "app-shell".to_string(),
13108            FragmentList {
13109                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13110                contains_boundary: false,
13111            },
13112        );
13113        let mut protocol = WebUIProtocol::new(fragments);
13114        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13115        protocol.components.insert(
13116            "app-shell".to_string(),
13117            webui_protocol::ComponentData {
13118                hydration_mode: i32::MAX,
13119                ..Default::default()
13120            },
13121        );
13122        let state = test_json!({
13123            "known": "value",
13124            "serverOnly": "must not be dropped",
13125        });
13126        let handler = WebUIHandler::with_plugin(|| {
13127            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13128        });
13129        let mut writer = TestWriter::new();
13130        handler.handle(
13131            &protocol,
13132            &state,
13133            &RenderOptions::new("index.html", "/"),
13134            &mut writer,
13135        )?;
13136        let output = writer.get_content();
13137        assert!(output.contains(r#""known":"value""#));
13138        assert!(output.contains(r#""serverOnly":"must not be dropped""#));
13139        Ok(())
13140    }
13141
13142    #[test]
13143    fn legacy_navigation_keys_with_default_mode_remain_keyed() {
13144        let mut protocol = WebUIProtocol::new(HashMap::new());
13145        protocol.components.insert(
13146            "app-shell".to_string(),
13147            webui_protocol::ComponentData {
13148                navigation_keys: vec!["selected".to_string()],
13149                ..Default::default()
13150            },
13151        );
13152
13153        match collect_navigation_state(&protocol, ["app-shell"]) {
13154            StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
13155            StateSelection::Full
13156            | StateSelection::KeyIds(_)
13157            | StateSelection::FullExceptKeyIds(_) => {
13158                panic!("legacy navigation keys should remain owned and projected")
13159            }
13160        }
13161    }
13162
13163    #[test]
13164    fn legacy_navigation_without_projection_metadata_preserves_full_state() {
13165        let mut protocol = WebUIProtocol::new(HashMap::new());
13166        protocol.components.insert(
13167            "app-shell".to_string(),
13168            webui_protocol::ComponentData::default(),
13169        );
13170
13171        assert!(matches!(
13172            collect_navigation_state(&protocol, ["app-shell"]),
13173            StateSelection::Full
13174        ));
13175    }
13176
13177    #[test]
13178    fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
13179        let mut fragments = HashMap::new();
13180        fragments.insert(
13181            "index.html".to_string(),
13182            FragmentList {
13183                fragments: vec![
13184                    WebUIFragment::raw("<html><body>".to_string()),
13185                    structural_fragment("body_end"),
13186                    WebUIFragment::raw("</body></html>".to_string()),
13187                ],
13188                contains_boundary: false,
13189            },
13190        );
13191        let mut protocol = WebUIProtocol::new(fragments);
13192        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13193        let state = test_json!({
13194            "title": "Legacy state",
13195            "serverOnly": "preserved",
13196        });
13197        let handler = WebUIHandler::with_plugin(|| {
13198            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13199        });
13200
13201        let mut writer = TestWriter::new();
13202        handler.handle(
13203            &protocol,
13204            &state,
13205            &RenderOptions::new("index.html", "/"),
13206            &mut writer,
13207        )?;
13208        assert!(writer.get_content().contains(r#""state":{}"#));
13209        assert!(!writer.get_content().contains("Legacy state"));
13210        assert!(!writer.get_content().contains("preserved"));
13211        Ok(())
13212    }
13213
13214    #[test]
13215    fn scriptless_component_state_is_navigation_only() -> Result<()> {
13216        let mut fragments = HashMap::new();
13217        fragments.insert(
13218            "index.html".to_string(),
13219            FragmentList {
13220                fragments: vec![
13221                    WebUIFragment::raw("<html><body>"),
13222                    WebUIFragment::component("items-page"),
13223                    structural_fragment("body_end"),
13224                    WebUIFragment::raw("</body></html>"),
13225                ],
13226                contains_boundary: false,
13227            },
13228        );
13229        fragments.insert(
13230            "items-page".to_string(),
13231            FragmentList {
13232                fragments: vec![WebUIFragment::raw("<p>Items</p>")],
13233                contains_boundary: false,
13234            },
13235        );
13236        let mut protocol = WebUIProtocol::new(fragments);
13237        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13238        protocol.components.insert(
13239            "items-page".to_string(),
13240            webui_protocol::ComponentData {
13241                template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
13242                navigation_mode: Some(StateProjectionMode::Keys as i32),
13243                navigation_keys: vec!["items".into()],
13244                ..Default::default()
13245            },
13246        );
13247        let state = test_json!({
13248            "items": ["STATE_SENTINEL"],
13249            "serverOnly": "SECRET_SENTINEL",
13250        });
13251        let handler = WebUIHandler::with_plugin(|| {
13252            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13253        });
13254        let mut writer = TestWriter::new();
13255
13256        handler.handle(
13257            &protocol,
13258            &state,
13259            &RenderOptions::new("index.html", "/"),
13260            &mut writer,
13261        )?;
13262
13263        let output = writer.get_content();
13264        assert!(output.contains(r#""state":{}"#));
13265        assert!(!output.contains("STATE_SENTINEL"));
13266        assert!(!output.contains("SECRET_SENTINEL"));
13267        Ok(())
13268    }
13269
13270    #[test]
13271    fn write_selected_state_projects_and_escapes() {
13272        // `keep` is in the sorted key set and its value contains a `</` that
13273        // must be escaped; `drop` is absent and must be projected out.
13274        let state = test_json!({
13275            "drop": "secret",
13276            "keep": "</script><b>"
13277        });
13278        let keys = ["keep"];
13279        let selection = StateSelection::Keys(keys.to_vec());
13280        let mut sink = TestWriter::new();
13281        let mut scratch = Vec::new();
13282        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13283        assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
13284    }
13285
13286    #[test]
13287    fn write_selected_state_non_object_projection_emits_empty_object() {
13288        let state = test_json!("scalar state has nothing hydratable");
13289        let keys: [&str; 0] = [];
13290        let selection = StateSelection::Keys(keys.to_vec());
13291        let mut sink = TestWriter::new();
13292        let mut scratch = Vec::new();
13293        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13294        assert_eq!(sink.get_content(), "{}");
13295    }
13296
13297    #[test]
13298    fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
13299        let state = test_json!({
13300            "keptA": 1,
13301            "keptB": 2,
13302            "serverOnlyA": 3,
13303            "serverOnlyB": 4,
13304        });
13305        let keys = ["keptA", "keptA", "keptB", "missing"];
13306        let selection = StateSelection::Keys(keys.to_vec());
13307        let mut sink = TestWriter::new();
13308        let mut scratch = Vec::new();
13309        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13310        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13311    }
13312
13313    #[test]
13314    fn write_selected_state_map_first_matches_schema_first_output() {
13315        let state = test_json!({
13316            "keptA": 1,
13317            "keptB": 2,
13318        });
13319        let keys = ["keptA", "keptB", "missingA", "missingB"];
13320        let selection = StateSelection::Keys(keys.to_vec());
13321        let mut sink = TestWriter::new();
13322        let mut scratch = Vec::new();
13323        write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13324        assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13325    }
13326
13327    #[test]
13328    fn write_selected_state_full_preserves_and_escapes_state() {
13329        let state = test_json!({
13330            "serverOnly": "</script><b>",
13331            "value": 42,
13332        });
13333        let mut sink = TestWriter::new();
13334        let mut scratch = Vec::new();
13335        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13336        assert_eq!(
13337            sink.get_content(),
13338            r#"{"serverOnly":"<\/script><b>","value":42}"#
13339        );
13340    }
13341
13342    #[test]
13343    fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
13344        let cases = [
13345            ("", ""),
13346            ("</", "<\\/"),
13347            ("</a</b", "<\\/a<\\/b"),
13348            ("<<//", "<<\\//"),
13349            ("a<b>c<", "a<b>c<"),
13350            ("x</", "x<\\/"),
13351            ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
13352            ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
13353        ];
13354        for (json, expected) in cases {
13355            let mut sink = TestWriter::new();
13356            write_script_safe_json_str(&mut sink, json)?;
13357            assert_eq!(sink.get_content(), expected, "input {json:?}");
13358        }
13359        Ok(())
13360    }
13361
13362    #[test]
13363    fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
13364        // The `</` search switches between short-haystack and vectorized
13365        // paths, so move openers and `<`/`/` decoys across several blocks.
13366        let mut json = String::with_capacity(512);
13367        for gap in 0..130 {
13368            json.clear();
13369            json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
13370            json.push_str(&"x".repeat(gap));
13371            json.push_str("</p><br/>");
13372            json.push_str(&"y".repeat(gap % 37));
13373            json.push_str("</");
13374            let mut sink = TestWriter::new();
13375            write_script_safe_json_str(&mut sink, &json)?;
13376            assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
13377        }
13378        Ok(())
13379    }
13380
13381    #[test]
13382    fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
13383        let preloads = vec![ComponentAssetStylePreload {
13384            root: "lazy-panel".to_string(),
13385            style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
13386        }];
13387
13388        let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
13389
13390        assert_eq!(
13391            manifest,
13392            r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
13393        );
13394    }
13395
13396    #[test]
13397    fn json_scratch_capacity_reused_across_checkpoint_payloads() {
13398        // Simulates several checkpoint bootstraps serialized in one render: a
13399        // single request-local buffer must grow once for the largest payload and
13400        // then be reused for subsequent payloads without reallocating. This is
13401        // the deterministic seam behind the streaming path's allocation gate —
13402        // `write_script_safe_json` is `pub(crate)`, so no public API is exposed.
13403        let mut sink = TestWriter::new();
13404        let mut scratch: Vec<u8> = Vec::new();
13405        // No allocation until serialization actually needs it.
13406        assert_eq!(scratch.capacity(), 0);
13407
13408        // The largest payload first establishes the high-water capacity.
13409        let mut large = serde_json::Map::new();
13410        for index in 0..64 {
13411            large.insert(
13412                format!("component_{index:04}"),
13413                Value::String("x".repeat(48)),
13414            );
13415        }
13416        let large = Value::Object(large);
13417        write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
13418        let high_water = scratch.capacity();
13419        assert!(high_water > 0);
13420
13421        // Subsequent smaller checkpoint payloads reuse the same buffer: capacity
13422        // never grows (no per-checkpoint allocation) and never shrinks.
13423        for seq in 0..8 {
13424            let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13425            write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13426            assert_eq!(
13427                scratch.capacity(),
13428                high_water,
13429                "scratch reallocated on checkpoint {seq}"
13430            );
13431        }
13432    }
13433
13434    #[test]
13435    fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13436        let mut fragments = HashMap::new();
13437        fragments.insert(
13438            "index.html".to_string(),
13439            FragmentList {
13440                fragments: vec![
13441                    WebUIFragment::raw("<html><body>"),
13442                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13443                        path: "/".to_string(),
13444                        fragment_id: "home-page".to_string(),
13445                        exact: true,
13446                        ..Default::default()
13447                    }),
13448                    WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13449                        path: "/admin".to_string(),
13450                        fragment_id: "admin-page".to_string(),
13451                        exact: true,
13452                        ..Default::default()
13453                    }),
13454                    structural_fragment("body_end"),
13455                    WebUIFragment::raw("</body></html>"),
13456                ],
13457
13458                contains_boundary: false,
13459            },
13460        );
13461        fragments.insert(
13462            "home-page".to_string(),
13463            FragmentList {
13464                fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13465                contains_boundary: false,
13466            },
13467        );
13468        fragments.insert(
13469            "admin-page".to_string(),
13470            FragmentList {
13471                fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13472                contains_boundary: false,
13473            },
13474        );
13475
13476        let mut protocol = WebUIProtocol::new(fragments);
13477        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13478        protocol.components.insert(
13479            "home-page".to_string(),
13480            webui_protocol::ComponentData {
13481                template_json: "{}".to_string(),
13482                hydration_mode: StateProjectionMode::Keys as i32,
13483                hydration_keys: vec!["homeTitle".to_string()],
13484                ..Default::default()
13485            },
13486        );
13487        protocol.components.insert(
13488            "admin-page".to_string(),
13489            webui_protocol::ComponentData {
13490                template_json: "{}".to_string(),
13491                hydration_mode: StateProjectionMode::Keys as i32,
13492                hydration_keys: vec!["adminToken".to_string()],
13493                ..Default::default()
13494            },
13495        );
13496        let state = test_json!({
13497            "homeTitle": "Welcome",
13498            "adminToken": "TOP_SECRET_SENTINEL",
13499        });
13500        let handler = WebUIHandler::with_plugin(|| {
13501            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13502        });
13503        let mut writer = TestWriter::new();
13504        handler.handle(
13505            &protocol,
13506            &state,
13507            &RenderOptions::new("index.html", "/"),
13508            &mut writer,
13509        )?;
13510        let output = writer.get_content();
13511        assert!(output.contains(r#""homeTitle":"Welcome""#));
13512        assert!(!output.contains("TOP_SECRET_SENTINEL"));
13513        assert!(!output.contains(r#""adminToken""#));
13514        Ok(())
13515    }
13516
13517    #[test]
13518    fn test_component_attr_name_aria() {
13519        // component_attr_name correctly maps ARIA attributes via the shared table
13520        assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13521        assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13522        assert_eq!(
13523            component_attr_name("aria-activedescendant"),
13524            "ariaActiveDescendant"
13525        );
13526        assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13527        assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13528    }
13529
13530    #[test]
13531    fn test_component_attr_name_html_global() {
13532        assert_eq!(component_attr_name("readonly"), "readOnly");
13533        assert_eq!(component_attr_name("tabindex"), "tabIndex");
13534        assert_eq!(component_attr_name("accesskey"), "accessKey");
13535        assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13536        assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13537        assert_eq!(component_attr_name("inputmode"), "inputMode");
13538        assert_eq!(component_attr_name("maxlength"), "maxLength");
13539        assert_eq!(component_attr_name("minlength"), "minLength");
13540        assert_eq!(component_attr_name("novalidate"), "noValidate");
13541        assert_eq!(component_attr_name("formaction"), "formAction");
13542        assert_eq!(component_attr_name("ismap"), "isMap");
13543        assert_eq!(component_attr_name("usemap"), "useMap");
13544    }
13545
13546    #[test]
13547    fn test_component_attr_name_strips_colon() {
13548        assert_eq!(component_attr_name(":readonly"), "readOnly");
13549        assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13550        assert_eq!(component_attr_name(":data-title"), "dataTitle");
13551    }
13552
13553    #[test]
13554    fn test_component_attr_name_regular() {
13555        assert_eq!(component_attr_name("data-title"), "dataTitle");
13556        assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13557        assert_eq!(component_attr_name("simple"), "simple");
13558    }
13559
13560    // ── allowed_query SSR emission tests ─────────────────────────────
13561
13562    fn make_query_route_protocol() -> WebUIProtocol {
13563        use webui_protocol::WebUiFragmentRoute;
13564
13565        let mut fragments = HashMap::new();
13566
13567        fragments.insert(
13568            "index.html".to_string(),
13569            FragmentList {
13570                fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13571                    path: "/".into(),
13572                    fragment_id: "app-shell".into(),
13573                    exact: false,
13574                    children: vec![
13575                        WebUiFragmentRoute {
13576                            path: "compose".into(),
13577                            fragment_id: "compose-page".into(),
13578                            exact: true,
13579                            allowed_query: "action,to,subject".into(),
13580                            keep_alive: false,
13581                            ..Default::default()
13582                        },
13583                        WebUiFragmentRoute {
13584                            path: "settings".into(),
13585                            fragment_id: "settings-page".into(),
13586                            exact: true,
13587                            keep_alive: false,
13588                            ..Default::default()
13589                        },
13590                    ],
13591                    keep_alive: false,
13592                    ..Default::default()
13593                })],
13594
13595                contains_boundary: false,
13596            },
13597        );
13598
13599        fragments.insert(
13600            "app-shell".to_string(),
13601            FragmentList {
13602                fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13603                contains_boundary: false,
13604            },
13605        );
13606        fragments.insert(
13607            "compose-page".to_string(),
13608            FragmentList {
13609                fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13610                contains_boundary: false,
13611            },
13612        );
13613        fragments.insert(
13614            "settings-page".to_string(),
13615            FragmentList {
13616                fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13617                contains_boundary: false,
13618            },
13619        );
13620
13621        WebUIProtocol::new(fragments)
13622    }
13623
13624    #[test]
13625    fn test_matched_route_omits_query_attr_from_dom() {
13626        let protocol = make_query_route_protocol();
13627        let state = test_json!({});
13628        let handler = WebUIHandler::new();
13629        let mut writer = TestWriter::new();
13630
13631        handler
13632            .handle(
13633                &protocol,
13634                &state,
13635                &RenderOptions::new("index.html", "/compose"),
13636                &mut writer,
13637            )
13638            .expect("render failed");
13639
13640        let html = writer.get_content();
13641        // query attr is no longer in DOM — it's in the SSR chain JSON instead
13642        assert!(
13643            !html.contains(r#"query="action,to,subject""#),
13644            "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13645        );
13646    }
13647
13648    #[test]
13649    fn test_nonmatched_route_omits_query_attr_from_dom() {
13650        let protocol = make_query_route_protocol();
13651        let state = test_json!({});
13652        let handler = WebUIHandler::new();
13653        let mut writer = TestWriter::new();
13654
13655        handler
13656            .handle(
13657                &protocol,
13658                &state,
13659                &RenderOptions::new("index.html", "/settings"),
13660                &mut writer,
13661            )
13662            .expect("render failed");
13663
13664        let html = writer.get_content();
13665        // query attr should not be on hidden siblings either
13666        assert!(
13667            !html.contains(r#"query="#),
13668            "hidden route should not have query attr: {html}"
13669        );
13670    }
13671
13672    #[test]
13673    fn test_route_without_query_has_no_query_attr() {
13674        let protocol = make_query_route_protocol();
13675        let state = test_json!({});
13676        let handler = WebUIHandler::new();
13677        let mut writer = TestWriter::new();
13678
13679        handler
13680            .handle(
13681                &protocol,
13682                &state,
13683                &RenderOptions::new("index.html", "/settings"),
13684                &mut writer,
13685            )
13686            .expect("render failed");
13687
13688        let html = writer.get_content();
13689        // Find the settings route element and verify it has no query attr
13690        let settings_idx = html
13691            .find(r#"component="settings-page""#)
13692            .expect("settings route should exist");
13693        let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13694        assert!(
13695            !settings_tag.contains("query="),
13696            "route without allowed_query should not emit query attr: {settings_tag}"
13697        );
13698    }
13699
13700    // ── Per-render head_inject / body_inject (replaces the byte-scanner
13701    //    InjectingStreamingWriter approach with structural signal-based
13702    //    injection) ───────────────────────────────────────────────────
13703
13704    fn build_head_body_protocol() -> WebUIProtocol {
13705        let mut fragments = HashMap::new();
13706        fragments.insert(
13707            "index.html".to_string(),
13708            FragmentList {
13709                fragments: vec![
13710                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13711                    structural_fragment("head_end"),
13712                    WebUIFragment::raw("</head><body>hello".to_string()),
13713                    structural_fragment("body_end"),
13714                    WebUIFragment::raw("</body></html>".to_string()),
13715                ],
13716                contains_boundary: false,
13717            },
13718        );
13719        WebUIProtocol::new(fragments)
13720    }
13721
13722    #[test]
13723    fn head_inject_emits_at_head_end_boundary() {
13724        let protocol = build_head_body_protocol();
13725        let state = test_json!({});
13726        let mut writer = TestWriter::new();
13727        let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13728        handle(&protocol, &state, &opts, &mut writer).unwrap();
13729        let html = writer.get_content();
13730        // The inject must appear immediately before `</head>`.
13731        let inject_idx = html
13732            .find("<link rel=preload>")
13733            .expect("inject HTML missing");
13734        let head_close_idx = html.find("</head>").expect("</head> missing");
13735        assert!(
13736            inject_idx < head_close_idx,
13737            "head_inject must appear before </head>: {html}"
13738        );
13739        // No duplicate.
13740        assert_eq!(html.matches("<link rel=preload>").count(), 1);
13741    }
13742
13743    #[test]
13744    fn component_render_policy_css_emits_once_in_head_with_nonce() {
13745        let mut protocol = build_head_body_protocol();
13746        protocol.component_render_css = concat!(
13747            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13748            "contain-intrinsic-block-size:auto 18rem;}"
13749        )
13750        .to_string();
13751        let state = test_json!({});
13752        let mut writer = TestWriter::new();
13753        let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13754        handle(&protocol, &state, &opts, &mut writer).unwrap();
13755        let html = writer.get_content();
13756
13757        let style = concat!(
13758            r#"<style data-webui-render-policy nonce="test-nonce">"#,
13759            r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13760            "contain-intrinsic-block-size:auto 18rem;}</style>"
13761        );
13762        let style_index = html.find(style).expect("render policy style missing");
13763        let head_close = html.find("</head>").expect("</head> missing");
13764        assert!(style_index < head_close);
13765        assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13766    }
13767
13768    #[test]
13769    fn body_inject_emits_at_body_end_boundary() {
13770        let protocol = build_head_body_protocol();
13771        let state = test_json!({});
13772        let mut writer = TestWriter::new();
13773        let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13774        handle(&protocol, &state, &opts, &mut writer).unwrap();
13775        let html = writer.get_content();
13776        let inject_idx = html
13777            .find("<script>lr</script>")
13778            .expect("inject HTML missing");
13779        let body_close_idx = html.find("</body>").expect("</body> missing");
13780        assert!(
13781            inject_idx < body_close_idx,
13782            "body_inject must appear before </body>: {html}"
13783        );
13784        assert_eq!(html.matches("<script>lr</script>").count(), 1);
13785    }
13786
13787    // ── Reserved `$webui` state inject namespace ──────────────────────
13788
13789    /// Protocol carrying all three structural boundaries, so `body_start`
13790    /// placement can be asserted alongside `head_end` / `body_end`.
13791    fn build_all_boundaries_protocol() -> WebUIProtocol {
13792        let mut fragments = HashMap::new();
13793        fragments.insert(
13794            "index.html".to_string(),
13795            FragmentList {
13796                fragments: vec![
13797                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13798                    structural_fragment("head_end"),
13799                    WebUIFragment::raw("</head><body>".to_string()),
13800                    structural_fragment("body_start"),
13801                    WebUIFragment::raw("hello".to_string()),
13802                    structural_fragment("body_end"),
13803                    WebUIFragment::raw("</body></html>".to_string()),
13804                ],
13805                contains_boundary: false,
13806            },
13807        );
13808        WebUIProtocol::new(fragments)
13809    }
13810
13811    fn state_inject_options<'a>() -> RenderOptions<'a> {
13812        RenderOptions::new("index.html", "/")
13813    }
13814
13815    fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13816        let mut writer = TestWriter::new();
13817        handle(protocol, state, options, &mut writer).unwrap();
13818        writer.get_content().to_string()
13819    }
13820
13821    #[test]
13822    fn state_inject_emits_at_every_structural_boundary() {
13823        let protocol = build_all_boundaries_protocol();
13824        let state = test_json!({
13825            "$webui": {
13826                "headEnd": "<meta name=he>",
13827                "bodyStart": "<span id=bs></span>",
13828                "bodyEnd": "<script>be</script>",
13829            }
13830        });
13831        let html = render_with(&protocol, &state, &state_inject_options());
13832
13833        let head_end = html.find("<meta name=he>").expect("headEnd missing");
13834        let head_close = html.find("</head>").expect("</head> missing");
13835        let body_open = html.find("<body>").expect("<body> missing");
13836        let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13837        let hello = html.find("hello").expect("body content missing");
13838        let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13839        let body_close = html.find("</body>").expect("</body> missing");
13840
13841        assert!(
13842            head_end < head_close,
13843            "headEnd must precede </head>: {html}"
13844        );
13845        assert!(
13846            body_open < body_start && body_start < hello,
13847            "bodyStart must sit immediately after <body>: {html}"
13848        );
13849        assert!(
13850            hello < body_end && body_end < body_close,
13851            "bodyEnd must precede </body>: {html}"
13852        );
13853
13854        for needle in [
13855            "<meta name=he>",
13856            "<span id=bs></span>",
13857            "<script>be</script>",
13858        ] {
13859            assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13860        }
13861    }
13862
13863    #[test]
13864    fn state_inject_follows_render_options_inject() {
13865        let protocol = build_all_boundaries_protocol();
13866        let state = test_json!({
13867            "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13868        });
13869        let options = RenderOptions::new("index.html", "/")
13870            .with_head_inject("<!--opt-he-->")
13871            .with_body_inject("<!--opt-be-->");
13872        let html = render_with(&protocol, &state, &options);
13873
13874        let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13875        let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13876        let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13877        let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13878
13879        assert!(
13880            opt_he < state_he,
13881            "RenderOptions head_inject must precede the state-supplied value: {html}"
13882        );
13883        assert!(
13884            opt_be < state_be,
13885            "RenderOptions body_inject must precede the state-supplied value: {html}"
13886        );
13887    }
13888
13889    #[test]
13890    fn malformed_state_inject_values_are_inert() {
13891        let protocol = build_all_boundaries_protocol();
13892        // Absent key, wrong container type, and per-member null / empty /
13893        // non-string values must all render without output and without error.
13894        for state in [
13895            test_json!({}),
13896            test_json!({ "$webui": "not-an-object" }),
13897            test_json!({ "$webui": [] }),
13898            test_json!({ "$webui": null }),
13899            test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13900            test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13901        ] {
13902            let html = render_with(&protocol, &state, &state_inject_options());
13903            assert!(
13904                html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13905                "malformed reserved state must render the document unchanged: {html}"
13906            );
13907            assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13908            assert!(!html.contains("42"), "non-string member leaked: {html}");
13909        }
13910    }
13911
13912    #[test]
13913    fn state_inject_never_reaches_the_hydration_payload() {
13914        let protocol = build_all_boundaries_protocol();
13915        let state = test_json!({
13916            "visible": "keep",
13917            "$webui": { "bodyEnd": "<script>secret</script>" }
13918        });
13919        let handler = WebUIHandler::with_plugin(|| {
13920            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13921        });
13922        let runtime = Protocol::new(protocol.clone());
13923        let mut writer = TestWriter::new();
13924        handler
13925            .render(&runtime, &state, &state_inject_options(), &mut writer)
13926            .unwrap();
13927        let html = writer.get_content();
13928
13929        let data_start = html
13930            .find(r#"<script type="application/json" id="webui-data""#)
13931            .expect("hydration block missing");
13932        let data_end = html[data_start..]
13933            .find("</script>")
13934            .map(|offset| data_start + offset)
13935            .expect("hydration block never closes");
13936        let payload = &html[data_start..data_end];
13937        assert!(
13938            !payload.contains("$webui"),
13939            "reserved key must be stripped from the hydration payload: {payload}"
13940        );
13941        assert!(
13942            payload.contains("visible"),
13943            "ordinary state must survive the filter: {payload}"
13944        );
13945        // Per the documented precedence the injected HTML is emitted after
13946        // the built-in hydration block, and still before `</body>`.
13947        let inject = html
13948            .find("<script>secret</script>")
13949            .expect("inject missing");
13950        let body_close = html.find("</body>").expect("</body> missing");
13951        assert!(
13952            data_end < inject && inject < body_close,
13953            "state bodyEnd must follow the hydration block and precede </body>: {html}"
13954        );
13955    }
13956
13957    #[test]
13958    fn projected_hydration_strips_reserved_state_inject_key() {
13959        let mut fragments = HashMap::new();
13960        fragments.insert(
13961            "index.html".to_string(),
13962            FragmentList {
13963                fragments: vec![
13964                    WebUIFragment::raw("<html><body>"),
13965                    WebUIFragment::component("app-shell"),
13966                    structural_fragment("body_end"),
13967                    WebUIFragment::raw("</body></html>"),
13968                ],
13969                contains_boundary: false,
13970            },
13971        );
13972        fragments.insert(
13973            "app-shell".to_string(),
13974            FragmentList {
13975                fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13976                contains_boundary: false,
13977            },
13978        );
13979        let mut document = WebUIProtocol::new(fragments);
13980        document.initial_state_strategy = InitialStateStrategy::Components as i32;
13981        document.components.insert(
13982            "app-shell".to_string(),
13983            webui_protocol::ComponentData {
13984                hydration_mode: StateProjectionMode::Keys as i32,
13985                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13986                ..Default::default()
13987            },
13988        );
13989        let state = test_json!({
13990            "$webui": { "bodyEnd": "<script>secret</script>" },
13991            "serverOnly": "drop",
13992            "visible": "keep",
13993        });
13994        let handler = WebUIHandler::with_plugin(|| {
13995            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13996        });
13997        let mut writer = TestWriter::new();
13998
13999        handler
14000            .render(
14001                &Protocol::new(document),
14002                &state,
14003                &RenderOptions::new("index.html", "/"),
14004                &mut writer,
14005            )
14006            .unwrap();
14007        let html = writer.get_content();
14008        let data_start = html
14009            .find(r#"<script type="application/json" id="webui-data""#)
14010            .expect("hydration block missing");
14011        let data_end = html[data_start..]
14012            .find("</script>")
14013            .map(|offset| data_start + offset)
14014            .expect("hydration block never closes");
14015        let payload = &html[data_start..data_end];
14016
14017        assert!(
14018            !payload.contains(STATE_INJECT_KEY),
14019            "reserved key leaked: {payload}"
14020        );
14021        assert!(payload.contains(r#""visible":"keep""#), "{payload}");
14022        assert!(!payload.contains("serverOnly"), "{payload}");
14023        assert!(html.contains("<script>secret</script>"), "{html}");
14024    }
14025
14026    #[test]
14027    fn write_selected_state_strips_reserved_key_from_full_state() {
14028        let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
14029        let mut sink = TestWriter::new();
14030        let mut scratch = Vec::new();
14031        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
14032        let json = sink.get_content();
14033        assert!(!json.contains("$webui"), "reserved key leaked: {json}");
14034        assert!(
14035            json.contains("\"a\":1") && json.contains("\"z\":2"),
14036            "{json}"
14037        );
14038    }
14039
14040    #[test]
14041    fn write_selected_state_strips_reserved_key_from_key_id_projection() {
14042        // The streaming record projects through interned key IDs, so the
14043        // reserved inject key must be filtered on that path too.
14044        let mut protocol = WebUIProtocol::new(HashMap::new());
14045        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
14046        protocol.fragments.insert(
14047            "keep-card".to_string(),
14048            webui_protocol::FragmentList::default(),
14049        );
14050        protocol.components.insert(
14051            "keep-card".to_string(),
14052            webui_protocol::ComponentData {
14053                hydration_mode: StateProjectionMode::Keys as i32,
14054                hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
14055                ..Default::default()
14056            },
14057        );
14058        let protocol = Protocol::new(protocol);
14059        let index = protocol.component_reachability();
14060        let component = protocol.component_index()["keep-card"];
14061        let mut ids = Vec::new();
14062        assert!(!collect_hydration_key_ids_into(
14063            protocol.protocol(),
14064            index,
14065            [component],
14066            &mut ids
14067        ));
14068
14069        let state = test_json!({
14070            "$webui": { "bodyEnd": "<b>x</b>" },
14071            "keep": 1,
14072        });
14073        let mut sink = TestWriter::new();
14074        let mut scratch = Vec::new();
14075
14076        write_selected_state(
14077            &mut sink,
14078            &mut scratch,
14079            &state,
14080            &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
14081        )
14082        .unwrap();
14083
14084        assert_eq!(sink.get_content(), r#"{"keep":1}"#);
14085    }
14086
14087    #[test]
14088    fn write_selected_state_full_is_unchanged_without_reserved_key() {
14089        let state = test_json!({ "a": 1, "z": 2 });
14090        let mut sink = TestWriter::new();
14091        let mut scratch = Vec::new();
14092        write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
14093        assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
14094    }
14095
14096    #[test]
14097    fn body_start_hook_dedupes_on_malformed_protocol() {
14098        let mut fragments = HashMap::new();
14099        fragments.insert(
14100            "index.html".to_string(),
14101            FragmentList {
14102                fragments: vec![
14103                    WebUIFragment::raw("<html><head></head><body>".to_string()),
14104                    structural_fragment("body_start"),
14105                    structural_fragment("body_start"),
14106                    WebUIFragment::raw("</body></html>".to_string()),
14107                ],
14108                contains_boundary: false,
14109            },
14110        );
14111        let protocol = WebUIProtocol::new(fragments);
14112        let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
14113        let html = render_with(&protocol, &state, &state_inject_options());
14114        assert_eq!(
14115            html.matches("<i>once</i>").count(),
14116            1,
14117            "duplicate body_start signals must not duplicate the inject: {html}"
14118        );
14119    }
14120
14121    #[test]
14122    fn injects_are_no_op_when_unset() {
14123        let protocol = build_head_body_protocol();
14124        let state = test_json!({});
14125        let mut writer = TestWriter::new();
14126        handle(
14127            &protocol,
14128            &state,
14129            &RenderOptions::new("index.html", "/"),
14130            &mut writer,
14131        )
14132        .unwrap();
14133        let html = writer.get_content();
14134        assert!(!html.contains("<link rel=preload>"));
14135        assert!(!html.contains("<script>lr</script>"));
14136    }
14137
14138    #[test]
14139    fn empty_inject_string_treated_as_unset() {
14140        let protocol = build_head_body_protocol();
14141        let state = test_json!({});
14142        let mut writer = TestWriter::new();
14143        let opts = RenderOptions::new("index.html", "/")
14144            .with_head_inject("")
14145            .with_body_inject("");
14146        handle(&protocol, &state, &opts, &mut writer).unwrap();
14147        // No injection happens — empty strings are normalised to None
14148        // by the builder, so the output is identical to the no-options case.
14149        let html = writer.get_content();
14150        assert!(html.contains("</head>"));
14151        assert!(html.contains("</body>"));
14152    }
14153
14154    #[test]
14155    fn inject_html_is_passed_through_verbatim() {
14156        // The handler does NOT escape the inject string — hosts pass
14157        // raw HTML they trust. This test pins that contract: a `<` in
14158        // the inject is emitted as-is, not encoded as `&lt;`.
14159        let protocol = build_head_body_protocol();
14160        let state = test_json!({});
14161        let mut writer = TestWriter::new();
14162        let opts =
14163            RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
14164        handle(&protocol, &state, &opts, &mut writer).unwrap();
14165        assert!(writer.get_content().contains("<script>var x=1;</script>"));
14166    }
14167
14168    /// Both injects fire and appear at the correct structural
14169    /// positions. Critically, this is robust against `</head>` /
14170    /// `</body>` literals appearing elsewhere in the document — the
14171    /// signal-based emitter cannot mis-fire on byte patterns inside
14172    /// HTML comments, `<iframe srcdoc>`, or inline scripts (which the
14173    /// previous byte-scanner could).
14174    #[test]
14175    fn injects_robust_against_marker_literals_in_content() {
14176        let mut fragments = HashMap::new();
14177        // The body intentionally contains `</body>` and `</head>`
14178        // literals before the actual structural close — these came
14179        // from a (hypothetical) iframe srcdoc or comment.
14180        fragments.insert(
14181            "index.html".to_string(),
14182            FragmentList {
14183                fragments: vec![
14184                    WebUIFragment::raw("<html><head><title>x</title>".to_string()),
14185                    structural_fragment("head_end"),
14186                    WebUIFragment::raw(
14187                        "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
14188                    ),
14189                    structural_fragment("body_end"),
14190                    WebUIFragment::raw("</body></html>".to_string()),
14191                ],
14192                contains_boundary: false,
14193            },
14194        );
14195        let protocol = WebUIProtocol::new(fragments);
14196        let state = test_json!({});
14197        let mut writer = TestWriter::new();
14198        let opts = RenderOptions::new("index.html", "/")
14199            .with_head_inject("<HEAD-INJ>")
14200            .with_body_inject("<BODY-INJ>");
14201        handle(&protocol, &state, &opts, &mut writer).unwrap();
14202        let html = writer.get_content();
14203        // The head inject sits between `<title>x</title>` and the
14204        // first `</head>` — the structural one, not the comment one.
14205        let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
14206        let head_close_idx = html.find("</head>").expect("</head> missing");
14207        assert!(head_inj_idx < head_close_idx);
14208        // The body inject sits before the structural `</body>` — NOT
14209        // before the `</body>` literal in the comment (which would
14210        // require the inject to appear inside `<p>hi</p>` somewhere).
14211        let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
14212        // Find the LAST `</body>` (the structural one).
14213        let body_close_idx = html.rfind("</body>").expect("</body> missing");
14214        assert!(
14215            body_inj_idx < body_close_idx,
14216            "body_inject must precede the structural </body>: {html}"
14217        );
14218        // And the comment is preserved verbatim.
14219        assert!(html.contains("<!-- </body> </head> -->"));
14220    }
14221
14222    /// Coverage-14: both `head_inject` AND `body_inject` set in the
14223    /// same render. Each fires at the correct structural boundary and
14224    /// neither leaks into the other's region.
14225    #[test]
14226    fn both_injects_fire_at_correct_boundaries() {
14227        let protocol = build_head_body_protocol();
14228        let state = test_json!({});
14229        let mut writer = TestWriter::new();
14230        let opts = RenderOptions::new("index.html", "/")
14231            .with_head_inject("<META-HEAD>")
14232            .with_body_inject("<SCRIPT-BODY>");
14233        handle(&protocol, &state, &opts, &mut writer).unwrap();
14234        let html = writer.get_content();
14235        let head_idx = html.find("<META-HEAD>").expect("head inject missing");
14236        let head_close = html.find("</head>").expect("</head> missing");
14237        let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
14238        let body_close = html.find("</body>").expect("</body> missing");
14239        assert!(head_idx < head_close, "head_inject before </head>");
14240        assert!(head_close < body_idx, "body_inject after </head>");
14241        assert!(body_idx < body_close, "body_inject before </body>");
14242        assert_eq!(html.matches("<META-HEAD>").count(), 1);
14243        assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
14244    }
14245
14246    /// Coverage-15 / Bug-3 (security defense): a malformed protocol
14247    /// emitting `head_end` and `body_end` more than once must NOT
14248    /// duplicate the host inject HTML. Without the dedup guard,
14249    /// double-emission would amplify Security-2 (a 1 MiB inject ×
14250    /// 1000 duplicate signals = 1 GiB output).
14251    #[test]
14252    fn injects_dedupe_against_duplicate_signals() {
14253        let mut fragments = HashMap::new();
14254        fragments.insert(
14255            "index.html".to_string(),
14256            FragmentList {
14257                fragments: vec![
14258                    WebUIFragment::raw("<html><head>".to_string()),
14259                    structural_fragment("head_end"),
14260                    structural_fragment("head_end"), // duplicate
14261                    structural_fragment("head_end"), // triplicate
14262                    WebUIFragment::raw("</head><body>".to_string()),
14263                    structural_fragment("body_end"),
14264                    structural_fragment("body_end"), // duplicate
14265                    WebUIFragment::raw("</body></html>".to_string()),
14266                ],
14267                contains_boundary: false,
14268            },
14269        );
14270        let protocol = WebUIProtocol::new(fragments);
14271        let state = test_json!({});
14272        let mut writer = TestWriter::new();
14273        let opts = RenderOptions::new("index.html", "/")
14274            .with_head_inject("<HINJ>")
14275            .with_body_inject("<BINJ>");
14276        handle(&protocol, &state, &opts, &mut writer).unwrap();
14277        let html = writer.get_content();
14278        assert_eq!(
14279            html.matches("<HINJ>").count(),
14280            1,
14281            "head_inject must emit exactly once even with duplicate head_end signals"
14282        );
14283        assert_eq!(
14284            html.matches("<BINJ>").count(),
14285            1,
14286            "body_inject must emit exactly once even with duplicate body_end signals"
14287        );
14288    }
14289
14290    /// Coverage-15: a Shadow-DOM / component-only protocol that has NO
14291    /// `<head>` / `<body>` tags must NOT emit the inject (the signals
14292    /// never fire). Verifies the injects are no-ops, not panics.
14293    #[test]
14294    fn injects_no_op_when_no_head_or_body_signals() {
14295        let mut fragments = HashMap::new();
14296        fragments.insert(
14297            "index.html".to_string(),
14298            FragmentList {
14299                fragments: vec![WebUIFragment::raw(
14300                    "<my-component>hi</my-component>".to_string(),
14301                )],
14302                contains_boundary: false,
14303            },
14304        );
14305        let protocol = WebUIProtocol::new(fragments);
14306        let state = test_json!({});
14307        let mut writer = TestWriter::new();
14308        let opts = RenderOptions::new("index.html", "/")
14309            .with_head_inject("<HINJ>")
14310            .with_body_inject("<BINJ>");
14311        handle(&protocol, &state, &opts, &mut writer).unwrap();
14312        let html = writer.get_content();
14313        assert!(!html.contains("<HINJ>"), "head_inject must not appear");
14314        assert!(!html.contains("<BINJ>"), "body_inject must not appear");
14315        assert!(html.contains("<my-component>"));
14316    }
14317
14318    /// Coverage-19: the handler's `&self` is shared across threads.
14319    /// Two concurrent renders with different inject values must NOT
14320    /// cross-contaminate (each thread sees only its own inject).
14321    /// Per-render mutable state lives on the `WebUIProcessContext`,
14322    /// which is stack-allocated per call.
14323    #[test]
14324    fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
14325        let protocol = std::sync::Arc::new(build_head_body_protocol());
14326        let state = std::sync::Arc::new(test_json!({}));
14327        let handler = std::sync::Arc::new(WebUIHandler::new());
14328
14329        const N_THREADS: usize = 16;
14330        let mut handles = Vec::with_capacity(N_THREADS);
14331        for tid in 0..N_THREADS {
14332            let h = std::sync::Arc::clone(&handler);
14333            let p = std::sync::Arc::clone(&protocol);
14334            let s = std::sync::Arc::clone(&state);
14335            handles.push(std::thread::spawn(move || {
14336                let head = format!("<HEAD-T{tid}>");
14337                let body = format!("<BODY-T{tid}>");
14338                let mut writer = TestWriter::new();
14339                let opts = RenderOptions::new("index.html", "/")
14340                    .with_head_inject(&head)
14341                    .with_body_inject(&body);
14342                h.handle(&p, &s, &opts, &mut writer).unwrap();
14343                let html = writer.get_content();
14344                // Must contain my own injects exactly once.
14345                assert_eq!(html.matches(&head).count(), 1);
14346                assert_eq!(html.matches(&body).count(), 1);
14347                // Must NOT contain any other thread's inject.
14348                for other in 0..N_THREADS {
14349                    if other == tid {
14350                        continue;
14351                    }
14352                    let other_head = format!("<HEAD-T{other}>");
14353                    let other_body = format!("<BODY-T{other}>");
14354                    assert!(
14355                        !html.contains(&other_head),
14356                        "tid {tid} saw {other}'s head_inject"
14357                    );
14358                    assert!(
14359                        !html.contains(&other_body),
14360                        "tid {tid} saw {other}'s body_inject"
14361                    );
14362                }
14363            }));
14364        }
14365        for h in handles {
14366            h.join().expect("worker panicked");
14367        }
14368    }
14369
14370    /// Coverage-17: a large (1 MiB) head_inject must round-trip
14371    /// correctly without panic, truncation, or excessive overhead.
14372    /// (No size cap is enforced by the handler — the host owns the
14373    /// safety contract; see `with_head_inject` doc comment.)
14374    #[test]
14375    fn large_inject_roundtrips_without_truncation() {
14376        let protocol = build_head_body_protocol();
14377        let state = test_json!({});
14378        let mut writer = TestWriter::new();
14379        let big = "x".repeat(1024 * 1024);
14380        let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
14381        handle(&protocol, &state, &opts, &mut writer).unwrap();
14382        let html = writer.get_content();
14383        assert!(
14384            html.contains(&big),
14385            "large head_inject must be present verbatim ({} bytes)",
14386            big.len()
14387        );
14388        // Sanity: only one copy.
14389        assert_eq!(html.matches(&big).count(), 1);
14390    }
14391
14392    /// `with_nonce("")` must normalize to `None` (no `<meta>` emitted),
14393    /// matching the empty-string semantics of `with_head_inject` /
14394    /// `with_body_inject`. An empty content attribute is browser-
14395    /// ignored noise.
14396    #[test]
14397    fn empty_nonce_treated_as_unset() {
14398        let protocol = build_head_body_protocol();
14399        let state = test_json!({});
14400        let mut writer = TestWriter::new();
14401        let opts = RenderOptions::new("index.html", "/").with_nonce("");
14402        handle(&protocol, &state, &opts, &mut writer).unwrap();
14403        assert!(
14404            !writer.get_content().contains("webui-nonce"),
14405            "empty nonce must not emit <meta name=\"webui-nonce\">"
14406        );
14407    }
14408
14409    /// Regression for the bug Akrosh caught: the `pub` fields on
14410    /// `RenderOptions` let a caller bypass the `with_*` builder
14411    /// normalisation, e.g.:
14412    ///
14413    /// ```ignore
14414    /// RenderOptions { nonce: Some(""), ..RenderOptions::new(e, p) }
14415    /// ```
14416    ///
14417    /// Without defensive normalisation at handler init, this would
14418    /// emit `<script nonce="">` on every inline script. Under a
14419    /// strict `Content-Security-Policy: script-src 'nonce-...'` an
14420    /// empty nonce is a HARD CSP failure that blocks every inline
14421    /// script — a complete inline-script-execution outage.
14422    ///
14423    /// The handler now treats `Some("")` identically to `None` for
14424    /// all three injection points (nonce / head_inject / body_inject)
14425    /// regardless of how the option was populated.
14426    #[test]
14427    fn empty_field_bypass_is_normalised_at_handler_init() {
14428        let protocol = build_head_body_protocol();
14429        let state = test_json!({});
14430
14431        // Bypass the `with_nonce` builder by writing the field directly.
14432        let opts_with_empty_nonce = RenderOptions {
14433            nonce: Some(""),
14434            ..RenderOptions::new("index.html", "/")
14435        };
14436        let mut writer = TestWriter::new();
14437        handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14438        let html = writer.get_content();
14439        assert!(
14440            !html.contains("webui-nonce"),
14441            "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14442        );
14443        assert!(
14444            !html.contains("nonce=\"\""),
14445            "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14446        );
14447
14448        // Same defence for inject fields.
14449        let opts_with_empty_injects = RenderOptions {
14450            head_inject: Some(""),
14451            body_inject: Some(""),
14452            ..RenderOptions::new("index.html", "/")
14453        };
14454        let mut writer = TestWriter::new();
14455        handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14456        // No assertion needed beyond "doesn't panic and doesn't emit
14457        // empty inject markers" — the head_end / body_end paths must
14458        // treat the empty inject as no-op the same way the builder does.
14459    }
14460
14461    /// Regression for the deep-audit's Bug-6 claim. The for-loop hot-
14462    /// path optimisation (insert key once + `get_mut`-swap value
14463    /// in-place) was suspected of corrupting the outer scope when a
14464    /// nested `<for>` loop reuses the same variable name. This test
14465    /// proves the optimisation is correct under that condition by
14466    /// requiring the outer `item` to be visible before, between, and
14467    /// after the inner loop, with its value preserved across inner
14468    /// iterations.
14469    ///
14470    /// Trace through the optimisation on `outer = [A, B]`,
14471    /// `inner = [X, Y]` with both loops using `item` as the variable:
14472    ///
14473    ///   outer pre-insert "item": Null
14474    ///   iter 1: get_mut → write A
14475    ///     emit "outer:A"
14476    ///     enter inner: saved = remove("item") = Some(A)
14477    ///                  pre-insert "item": Null
14478    ///                  iter 1: write X → emit "inner:X"
14479    ///                  iter 2: write Y → emit "inner:Y"
14480    ///                  restore: insert("item", A)   ← outer's A back
14481    ///     emit "outer:A again"               ← reads A correctly
14482    ///   iter 2: get_mut → write B (overwrites the restored A,
14483    ///                              but that's correct — we're now
14484    ///                              in iter 2 of the outer loop)
14485    ///     emit "outer:B"
14486    ///     enter inner: saved = remove("item") = Some(B), …, restore B
14487    ///     emit "outer:B again"
14488    ///
14489    /// If the audit's claim were correct — that the outer's `get_mut`
14490    /// somehow held a reference past the inner loop and clobbered the
14491    /// restoration — we'd see corrupted values in the "outer:X again"
14492    /// emissions. The assertion below pins the correct sequence.
14493    #[test]
14494    fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14495        let mut fragments = HashMap::new();
14496        fragments.insert(
14497            "index.html".to_string(),
14498            FragmentList {
14499                fragments: vec![
14500                    WebUIFragment::raw("["),
14501                    WebUIFragment::for_loop("item", "outer", "outer_body"),
14502                    WebUIFragment::raw("]"),
14503                ],
14504                contains_boundary: false,
14505            },
14506        );
14507        fragments.insert(
14508            "outer_body".to_string(),
14509            FragmentList {
14510                fragments: vec![
14511                    WebUIFragment::raw("(O="),
14512                    WebUIFragment::signal("item.tag", false),
14513                    WebUIFragment::for_loop("item", "inner", "inner_body"),
14514                    WebUIFragment::raw(",O="),
14515                    WebUIFragment::signal("item.tag", false),
14516                    WebUIFragment::raw(")"),
14517                ],
14518                contains_boundary: false,
14519            },
14520        );
14521        fragments.insert(
14522            "inner_body".to_string(),
14523            FragmentList {
14524                fragments: vec![
14525                    WebUIFragment::raw("[I="),
14526                    WebUIFragment::signal("item.tag", false),
14527                    WebUIFragment::raw("]"),
14528                ],
14529                contains_boundary: false,
14530            },
14531        );
14532        let protocol = WebUIProtocol::new(fragments);
14533        let state = test_json!({
14534            "outer": [{"tag": "A"}, {"tag": "B"}],
14535            "inner": [{"tag": "X"}, {"tag": "Y"}],
14536        });
14537        let mut writer = TestWriter::new();
14538        handle(
14539            &protocol,
14540            &state,
14541            &RenderOptions::new("index.html", "/"),
14542            &mut writer,
14543        )
14544        .unwrap();
14545        // Expected sequence:
14546        //   outer iter 1 (item=A):
14547        //     emit "(O=A"               ← outer A before inner
14548        //     inner iter 1 (item=X) emit "[I=X]"
14549        //     inner iter 2 (item=Y) emit "[I=Y]"
14550        //     emit ",O=A)"              ← outer A AFTER inner restore
14551        //   outer iter 2 (item=B):
14552        //     emit "(O=B"
14553        //     inner iter 1 (item=X) emit "[I=X]"
14554        //     inner iter 2 (item=Y) emit "[I=Y]"
14555        //     emit ",O=B)"
14556        assert_eq!(
14557            writer.get_content(),
14558            "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14559            "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14560        );
14561    }
14562    #[derive(Default)]
14563    struct FlushTestWriter {
14564        output: String,
14565        flushes: Vec<usize>,
14566        fail_flush: bool,
14567        fail_flush_at: Option<usize>,
14568        flush_attempts: usize,
14569        ended: bool,
14570    }
14571
14572    impl ResponseWriter for FlushTestWriter {
14573        fn write(&mut self, content: &str) -> Result<()> {
14574            self.output.push_str(content);
14575            Ok(())
14576        }
14577
14578        fn end(&mut self) -> Result<()> {
14579            self.ended = true;
14580            Ok(())
14581        }
14582    }
14583
14584    impl FlushWriter for FlushTestWriter {
14585        fn flush(&mut self) -> Result<()> {
14586            let attempt = self.flush_attempts;
14587            self.flush_attempts += 1;
14588            if self.fail_flush || self.fail_flush_at == Some(attempt) {
14589                return Err(HandlerError::ClientDisconnected);
14590            }
14591            self.flushes.push(self.output.len());
14592            Ok(())
14593        }
14594    }
14595
14596    fn streaming_protocol(with_boundaries: bool) -> Protocol {
14597        streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14598    }
14599
14600    fn streaming_protocol_with_state_strategy(
14601        with_boundaries: bool,
14602        state_strategy: InitialStateStrategy,
14603    ) -> Protocol {
14604        let mut fragments = HashMap::new();
14605        let mut entry = vec![
14606            WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14607            structural_fragment("head_start"),
14608            WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14609            structural_fragment("head_end"),
14610            WebUIFragment::raw("</HEAD><body>"),
14611            structural_fragment("body_start"),
14612        ];
14613        if with_boundaries {
14614            entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14615        }
14616        entry.extend([
14617            WebUIFragment::raw("<my-counter"),
14618            structural_fragment("streaming_root:my-counter"),
14619            WebUIFragment::raw(">"),
14620            WebUIFragment::component("my-counter"),
14621            WebUIFragment::raw("</my-counter>"),
14622        ]);
14623        if with_boundaries {
14624            entry.push(WebUIFragment::boundary_end(0));
14625        }
14626        entry.extend([
14627            WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14628            structural_fragment("body_end"),
14629            WebUIFragment::raw("</body></html>"),
14630        ]);
14631        fragments.insert(
14632            "index.html".to_string(),
14633            FragmentList {
14634                fragments: entry,
14635                contains_boundary: with_boundaries,
14636            },
14637        );
14638        fragments.insert(
14639            "my-counter".to_string(),
14640            FragmentList {
14641                fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14642                contains_boundary: false,
14643            },
14644        );
14645
14646        let mut document = WebUIProtocol::new(fragments);
14647        document.initial_state_strategy = state_strategy as i32;
14648        document.components.insert(
14649            "my-counter".to_string(),
14650            webui_protocol::ComponentData {
14651                template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14652                template_functions: "[function(){return true}]".to_string(),
14653                hydration_mode: StateProjectionMode::Keys as i32,
14654                hydration_keys: vec!["count".to_string()],
14655                ..Default::default()
14656            },
14657        );
14658        Protocol::new(document)
14659    }
14660
14661    #[test]
14662    fn handler_error_stays_small() {
14663        // Boxing the cold `StreamingBoundary` payload keeps `HandlerError` — and
14664        // therefore `Result<(), HandlerError>` threaded through the hot legacy
14665        // render path — down to a single `String`-sized payload plus a
14666        // discriminant word. If the boundary payload is un-boxed back to
14667        // `{ signal, reason }` it grows to two `String`s (48-byte payload) and
14668        // this fails.
14669        assert!(
14670            std::mem::size_of::<HandlerError>()
14671                <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14672            "HandlerError grew to {} bytes",
14673            std::mem::size_of::<HandlerError>()
14674        );
14675    }
14676
14677    #[test]
14678    fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14679        let protocol = streaming_protocol(true);
14680        let handler = WebUIHandler::with_plugin(|| {
14681            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14682        });
14683        let state = test_json!({ "count": 1, "serverOnly": "secret" });
14684        let mut writer = FlushTestWriter::default();
14685
14686        handler
14687            .render_streaming(
14688                &protocol,
14689                &state,
14690                &RenderOptions::new("index.html", "/"),
14691                &mut writer,
14692            )
14693            .unwrap();
14694
14695        assert!(writer.ended);
14696        assert_eq!(
14697            writer.flushes.len(),
14698            3,
14699            "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14700        );
14701        let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14702        assert!(boundary_flush.contains("<!--wb:0-->"));
14703        assert!(boundary_flush.contains("<!--/wb:0-->"));
14704        assert!(
14705            boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14706            "boundary flush: {boundary_flush}"
14707        );
14708        assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14709        assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14710        assert!(!boundary_flush.contains("slow tail"));
14711        assert!(!writer.output.contains("id=\"webui-data\""));
14712        // The terminal flush commits the scriptless tail without manufacturing
14713        // another state/template projection.
14714        let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14715        assert!(terminal_flush.contains("slow tail"));
14716        assert!(writer.output.contains("[1,4,0,{}]"));
14717        assert!(!writer.output.contains("[1,0,1,"));
14718        assert!(!writer.output.contains("[2,4,0,{}]"));
14719
14720        let marker = writer
14721            .output
14722            .find(STREAMING_MARKER)
14723            .expect("streaming marker");
14724        let attributed_head = writer
14725            .output
14726            .find("<HEAD data-shell=\"main\">")
14727            .expect("attributed mixed-case head");
14728        let authored_script = writer
14729            .output
14730            .find("src=\"/index.js\"")
14731            .expect("entry script");
14732        assert!(attributed_head < marker && marker < authored_script);
14733    }
14734
14735    #[test]
14736    fn streaming_terminal_tail_never_resends_full_state() {
14737        let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14738        let handler = WebUIHandler::with_plugin(|| {
14739            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14740        });
14741        let mut writer = FlushTestWriter::default();
14742
14743        handler
14744            .render_streaming(
14745                &protocol,
14746                &test_json!({ "count": 1, "serverOnly": "secret" }),
14747                &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14748                &mut writer,
14749            )
14750            .unwrap();
14751
14752        assert_eq!(
14753            writer.output.matches("serverOnly").count(),
14754            1,
14755            "full state belongs only to the interactive boundary"
14756        );
14757        assert!(writer.output.contains("[1,4,0,{}]"));
14758    }
14759
14760    #[test]
14761    fn streaming_terminal_record_emits_before_body_close() {
14762        let fragments = HashMap::from([(
14763            "index.html".to_string(),
14764            FragmentList {
14765                fragments: vec![
14766                    WebUIFragment::raw("<html><head>"),
14767                    structural_fragment("head_start"),
14768                    structural_fragment("head_end"),
14769                    WebUIFragment::raw("</head><body>"),
14770                    structural_fragment("body_start"),
14771                    WebUIFragment::raw("<main>static</main>"),
14772                    structural_fragment("body_end"),
14773                    WebUIFragment::raw("</body></html>"),
14774                ],
14775                contains_boundary: false,
14776            },
14777        )]);
14778        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14779        let mut writer = FlushTestWriter::default();
14780
14781        WebUIHandler::new()
14782            .render_streaming(
14783                &protocol,
14784                &test_json!({}),
14785                &RenderOptions::new("index.html", "/"),
14786                &mut writer,
14787            )
14788            .unwrap();
14789
14790        let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14791        let body_close = writer.output.find("</body>").expect("body close");
14792        assert!(
14793            terminal < body_close,
14794            "terminal boundary record must stay inside <body>: {}",
14795            writer.output
14796        );
14797    }
14798
14799    /// Streaming must place the reserved-state injects exactly where the
14800    /// ordinary render does, so a host can switch modes without its
14801    /// boundary HTML moving.
14802    #[test]
14803    fn state_inject_placement_matches_between_render_modes() {
14804        let entry = vec![
14805            WebUIFragment::raw("<html><head>"),
14806            structural_fragment("head_start"),
14807            structural_fragment("head_end"),
14808            WebUIFragment::raw("</head><body>"),
14809            structural_fragment("body_start"),
14810            WebUIFragment::raw("<main>static</main>"),
14811            structural_fragment("body_end"),
14812            WebUIFragment::raw("</body></html>"),
14813        ];
14814        let fragments = HashMap::from([(
14815            "index.html".to_string(),
14816            FragmentList {
14817                fragments: entry,
14818                contains_boundary: false,
14819            },
14820        )]);
14821        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14822        let state = test_json!({
14823            "$webui": {
14824                "headEnd": "<meta name=he>",
14825                "bodyStart": "<span id=bs></span>",
14826                "bodyEnd": "<script>be</script>",
14827            }
14828        });
14829        let options = RenderOptions::new("index.html", "/");
14830
14831        let mut ordinary = TestWriter::new();
14832        WebUIHandler::new()
14833            .render(&protocol, &state, &options, &mut ordinary)
14834            .unwrap();
14835        let ordinary_html = ordinary.get_content().to_string();
14836
14837        let mut streamed = FlushTestWriter::default();
14838        WebUIHandler::new()
14839            .render_streaming(&protocol, &state, &options, &mut streamed)
14840            .unwrap();
14841        let streamed_html = &streamed.output;
14842
14843        for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14844            let head_end = html.find("<meta name=he>").expect("headEnd missing");
14845            let head_close = html.find("</head>").expect("</head> missing");
14846            let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14847            let main = html.find("<main>static</main>").expect("content missing");
14848            let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14849            let body_close = html.find("</body>").expect("</body> missing");
14850            assert!(head_end < head_close, "headEnd misplaced: {html}");
14851            assert!(body_start < main, "bodyStart misplaced: {html}");
14852            assert!(
14853                main < body_end && body_end < body_close,
14854                "bodyEnd misplaced: {html}"
14855            );
14856        }
14857
14858        // The streaming response still terminates with its single empty
14859        // terminal record: an inject must not perturb the record stream.
14860        assert!(
14861            streamed_html.contains(",4,0,{}]"),
14862            "streaming must still end in one empty terminal record: {streamed_html}"
14863        );
14864    }
14865
14866    #[test]
14867    fn streaming_state_inject_emits_and_strips_reserved_key() {
14868        let entry = vec![
14869            WebUIFragment::raw("<html><head>"),
14870            structural_fragment("head_start"),
14871            structural_fragment("head_end"),
14872            WebUIFragment::raw("</head><body>"),
14873            structural_fragment("body_start"),
14874            structural_fragment("body_end"),
14875            WebUIFragment::raw("</body></html>"),
14876        ];
14877        let fragments = HashMap::from([(
14878            "index.html".to_string(),
14879            FragmentList {
14880                fragments: entry,
14881                contains_boundary: false,
14882            },
14883        )]);
14884        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14885        let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14886
14887        let mut writer = FlushTestWriter::default();
14888        WebUIHandler::new()
14889            .render_streaming(
14890                &protocol,
14891                &state,
14892                &RenderOptions::new("index.html", "/"),
14893                &mut writer,
14894            )
14895            .unwrap();
14896        assert!(writer.output.contains("<script>be</script>"));
14897        assert!(!writer.output.contains("$webui"));
14898    }
14899
14900    #[test]
14901    fn static_streaming_document_uses_one_empty_terminal_record() {
14902        let fragments = HashMap::from([(
14903            "index.html".to_string(),
14904            FragmentList {
14905                fragments: vec![
14906                    WebUIFragment::raw("<html><head>"),
14907                    structural_fragment("head_start"),
14908                    structural_fragment("head_end"),
14909                    WebUIFragment::raw("</head><body>"),
14910                    structural_fragment("body_start"),
14911                    WebUIFragment::raw("<main>static</main>"),
14912                    structural_fragment("body_end"),
14913                    WebUIFragment::raw("</body></html>"),
14914                ],
14915                contains_boundary: false,
14916            },
14917        )]);
14918        let protocol = Protocol::new(WebUIProtocol::new(fragments));
14919        let mut writer = FlushTestWriter::default();
14920
14921        WebUIHandler::new()
14922            .render_streaming(
14923                &protocol,
14924                &test_json!({ "serverOnly": "secret" }),
14925                &RenderOptions::new("index.html", "/"),
14926                &mut writer,
14927            )
14928            .unwrap();
14929
14930        assert_eq!(writer.flushes.len(), 1);
14931        assert!(writer.output.contains(STREAMING_MARKER));
14932        assert!(writer.output.contains("[0,4,0,{}]"));
14933        assert!(!writer.output.contains("serverOnly"));
14934        assert!(!writer.output.contains("id=\"webui-data\""));
14935        assert!(!writer.output.contains("<!--wb:"));
14936    }
14937
14938    #[test]
14939    fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14940        let protocol = streaming_protocol(true);
14941        let handler = WebUIHandler::with_plugin(|| {
14942            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14943        });
14944        let mut writer = FlushTestWriter::default();
14945        handler
14946            .render_streaming(
14947                &protocol,
14948                &test_json!({ "count": 1 }),
14949                &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14950                &mut writer,
14951            )
14952            .unwrap();
14953
14954        let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14955        let envelope = writer.output[boundary_end..]
14956            .find("data-webui-boundary nonce=\"test-nonce-123\"")
14957            .map(|index| index + boundary_end)
14958            .expect("nonce-bearing envelope");
14959        let functions = writer
14960            .output
14961            .find("templateFns")
14962            .expect("function side channel");
14963        let sentinel = writer
14964            .output
14965            .find("<webui-hydrate>")
14966            .expect("hydration sentinel");
14967        assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14968        assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14969    }
14970
14971    #[test]
14972    fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14973        fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14974            let mut fragments = vec![structural_fragment("head_start")];
14975            fragments.extend(boundaries);
14976            let fragments = HashMap::from([(
14977                "index.html".to_string(),
14978                FragmentList {
14979                    fragments,
14980                    contains_boundary: true,
14981                },
14982            )]);
14983            Protocol::new(WebUIProtocol::new(fragments))
14984        }
14985
14986        let handler = WebUIHandler::new();
14987        let mut writer = FlushTestWriter::default();
14988        let result = handler.render_streaming(
14989            &protocol(vec![WebUIFragment::boundary_end(0)]),
14990            &test_json!({}),
14991            &RenderOptions::new("index.html", "/"),
14992            &mut writer,
14993        );
14994        match result {
14995            Err(HandlerError::StreamingBoundary(err)) => {
14996                assert_eq!(err.signal, "boundary");
14997                assert!(err.reason.contains("no active occurrence"));
14998            }
14999            other => panic!("expected ordered-boundary error, got {other:?}"),
15000        }
15001
15002        let mut writer = FlushTestWriter::default();
15003        let result = handler.render_streaming(
15004            &protocol(vec![
15005                WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15006                WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15007            ]),
15008            &test_json!({}),
15009            &RenderOptions::new("index.html", "/"),
15010            &mut writer,
15011        );
15012        assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
15013    }
15014
15015    #[test]
15016    fn streaming_render_propagates_boundary_flush_disconnect() {
15017        let protocol = streaming_protocol(true);
15018        let handler = WebUIHandler::new();
15019        let mut writer = FlushTestWriter {
15020            fail_flush: true,
15021            ..FlushTestWriter::default()
15022        };
15023        let result = handler.render_streaming(
15024            &protocol,
15025            &test_json!({}),
15026            &RenderOptions::new("index.html", "/"),
15027            &mut writer,
15028        );
15029        assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
15030        assert!(!writer.ended);
15031    }
15032
15033    #[test]
15034    fn streaming_render_requires_structural_head_start_before_writing() {
15035        let fragments = HashMap::from([(
15036            "index.html".to_string(),
15037            FragmentList {
15038                fragments: vec![
15039                    WebUIFragment::raw(
15040                        "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
15041                    ),
15042                    structural_fragment("head_end"),
15043                    WebUIFragment::raw("</HEAD><body>"),
15044                    structural_fragment("body_start"),
15045                    structural_fragment("body_end"),
15046                ],
15047                contains_boundary: false,
15048            },
15049        )]);
15050        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15051        let mut writer = FlushTestWriter::default();
15052
15053        let result = WebUIHandler::new().render_streaming(
15054            &protocol,
15055            &test_json!({}),
15056            &RenderOptions::new("index.html", "/"),
15057            &mut writer,
15058        );
15059
15060        assert!(matches!(
15061            result,
15062            Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
15063        ));
15064        assert_eq!(
15065            writer.output,
15066            "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
15067        );
15068        assert!(writer.flushes.is_empty());
15069        assert!(!writer.ended);
15070    }
15071
15072    #[test]
15073    fn streaming_render_rejects_duplicate_head_start() {
15074        let fragments = HashMap::from([(
15075            "index.html".to_string(),
15076            FragmentList {
15077                fragments: vec![
15078                    WebUIFragment::raw("<html><head>"),
15079                    structural_fragment("head_start"),
15080                    structural_fragment("head_start"),
15081                    structural_fragment("head_end"),
15082                    WebUIFragment::raw("</head><body>"),
15083                    structural_fragment("body_start"),
15084                    structural_fragment("body_end"),
15085                ],
15086                contains_boundary: false,
15087            },
15088        )]);
15089        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15090        let mut writer = FlushTestWriter::default();
15091
15092        let result = WebUIHandler::new().render_streaming(
15093            &protocol,
15094            &test_json!({}),
15095            &RenderOptions::new("index.html", "/"),
15096            &mut writer,
15097        );
15098
15099        assert!(matches!(
15100            result,
15101            Err(HandlerError::DuplicateStreamingHeadStart)
15102        ));
15103        assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
15104        assert!(writer.flushes.is_empty());
15105    }
15106
15107    #[test]
15108    fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
15109        let handler = WebUIHandler::with_plugin(|| {
15110            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15111        });
15112        let state = test_json!({
15113            "count": 1,
15114            "head_start": "must not render",
15115            "boundary_start:0": "must not render",
15116            "boundary_end:0": "must not render",
15117        });
15118        let mut with_boundaries = TestWriter::new();
15119        let mut without_boundaries = TestWriter::new();
15120        handler
15121            .render(
15122                &streaming_protocol(true),
15123                &state,
15124                &RenderOptions::new("index.html", "/"),
15125                &mut with_boundaries,
15126            )
15127            .unwrap();
15128        handler
15129            .render(
15130                &streaming_protocol(false),
15131                &state,
15132                &RenderOptions::new("index.html", "/"),
15133                &mut without_boundaries,
15134            )
15135            .unwrap();
15136        assert_eq!(
15137            with_boundaries.get_content(),
15138            without_boundaries.get_content()
15139        );
15140    }
15141
15142    #[test]
15143    fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
15144        for source in [
15145            r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
15146            r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
15147        ] {
15148            let mut parser = HtmlParser::new();
15149            parser
15150                .parse("index.html", source)
15151                .expect("parse head fixture");
15152            let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15153            let mut writer = TestWriter::new();
15154            WebUIHandler::new()
15155                .render(
15156                    &protocol,
15157                    &test_json!({ "theme": "light" }),
15158                    &RenderOptions::new("index.html", "/"),
15159                    &mut writer,
15160                )
15161                .unwrap();
15162
15163            assert_eq!(
15164                writer.get_content(),
15165                "<html><head><title>T</title></head><body>x</body></html>"
15166            );
15167        }
15168    }
15169
15170    #[test]
15171    fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
15172        let source =
15173            r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
15174        let mut parser = HtmlParser::new();
15175        parser.parse("index.html", source).expect("parse fixture");
15176        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15177
15178        let mut ordinary = TestWriter::new();
15179        WebUIHandler::new()
15180            .render(
15181                &protocol,
15182                &test_json!({}),
15183                &RenderOptions::new("index.html", "/"),
15184                &mut ordinary,
15185            )
15186            .unwrap();
15187        assert_eq!(ordinary.get_content(), source);
15188
15189        let mut streaming = FlushTestWriter::default();
15190        WebUIHandler::new()
15191            .render_streaming(
15192                &protocol,
15193                &test_json!({}),
15194                &RenderOptions::new("index.html", "/"),
15195                &mut streaming,
15196            )
15197            .unwrap();
15198        assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
15199        assert!(streaming.output.contains("</HEAD><BODY>x"));
15200        let opening = streaming.output.find("<HEAD").expect("mixed-case head");
15201        let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
15202        let title = streaming.output.find("<title>").expect("title");
15203        assert!(opening < marker && marker < title);
15204        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
15205    }
15206
15207    /// Only `}}}webui:`-namespaced signals are compiler-owned, so an
15208    /// unprefixed `body_end` is ordinary authored content.
15209    #[test]
15210    fn unnamespaced_signal_is_ordinary_content() {
15211        let fragments = HashMap::from([(
15212            "index.html".to_string(),
15213            FragmentList {
15214                fragments: vec![
15215                    WebUIFragment::raw("<html><head>"),
15216                    structural_fragment("head_end"),
15217                    WebUIFragment::raw("</head><body>"),
15218                    WebUIFragment::signal("body_end".to_string(), false),
15219                    structural_fragment("body_end"),
15220                    WebUIFragment::raw("</body></html>"),
15221                ],
15222                contains_boundary: false,
15223            },
15224        )]);
15225        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15226        let mut writer = TestWriter::new();
15227        WebUIHandler::new()
15228            .render(
15229                &protocol,
15230                &test_json!({ "body_end": "content" }),
15231                &RenderOptions::new("index.html", "/"),
15232                &mut writer,
15233            )
15234            .expect("current protocol must render");
15235        assert!(writer.get_content().contains("content"));
15236    }
15237
15238    #[test]
15239    fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
15240        let source = concat!(
15241            "<html><head></head><body>",
15242            "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
15243            "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
15244            "</body></html>",
15245        );
15246        let state = test_json!({
15247            "head_start": "hs",
15248            "head_end": "he",
15249            "body_start": "bs",
15250            "body_end": "be",
15251            "boundary_start:0": "b0s",
15252            "boundary_end:0": "b0e",
15253            "streaming_root:forged": "root",
15254        });
15255        let mut parser = HtmlParser::new();
15256        parser.parse("index.html", source).expect("parse fixture");
15257        let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15258        let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
15259
15260        let mut ordinary = TestWriter::new();
15261        WebUIHandler::new()
15262            .render(
15263                &protocol,
15264                &state,
15265                &RenderOptions::new("index.html", "/"),
15266                &mut ordinary,
15267            )
15268            .unwrap();
15269        assert_eq!(ordinary.get_content(), expected);
15270
15271        let mut streaming = FlushTestWriter::default();
15272        WebUIHandler::new()
15273            .render_streaming(
15274                &protocol,
15275                &state,
15276                &RenderOptions::new("index.html", "/"),
15277                &mut streaming,
15278            )
15279            .unwrap();
15280        assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
15281        assert!(!streaming.output.contains("<!--wb:"));
15282        assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
15283        assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
15284    }
15285
15286    #[test]
15287    fn streaming_rejects_structural_signals_after_terminal() {
15288        let fragments = HashMap::from([(
15289            "index.html".to_string(),
15290            FragmentList {
15291                fragments: vec![
15292                    WebUIFragment::raw("<html><head>"),
15293                    structural_fragment("head_start"),
15294                    structural_fragment("head_end"),
15295                    WebUIFragment::raw("</head><body>"),
15296                    structural_fragment("body_start"),
15297                    structural_fragment("body_end"),
15298                    WebUIFragment::raw("</body>"),
15299                    structural_fragment("boundary_start:0"),
15300                    WebUIFragment::raw("</html>"),
15301                ],
15302                contains_boundary: false,
15303            },
15304        )]);
15305        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15306        let mut streaming = FlushTestWriter::default();
15307        let result = WebUIHandler::new().render_streaming(
15308            &protocol,
15309            &test_json!({}),
15310            &RenderOptions::new("index.html", "/"),
15311            &mut streaming,
15312        );
15313
15314        match result {
15315            Err(HandlerError::StreamingBoundary(error)) => {
15316                assert_eq!(error.signal, "boundary_start:0");
15317                assert!(
15318                    error
15319                        .reason
15320                        .contains("structural signal arrived after body_end"),
15321                    "reason: {}",
15322                    error.reason
15323                );
15324            }
15325            other => panic!("expected post-terminal rejection, got {other:?}"),
15326        }
15327        assert_eq!(
15328            streaming.output,
15329            concat!(
15330                "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
15331                "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
15332                "<webui-hydrate></webui-hydrate></body>"
15333            )
15334        );
15335        assert!(!streaming.output.contains("<!--wb:0-->"));
15336
15337        let mut ordinary = TestWriter::new();
15338        WebUIHandler::new()
15339            .render(
15340                &protocol,
15341                &test_json!({}),
15342                &RenderOptions::new("index.html", "/"),
15343                &mut ordinary,
15344            )
15345            .unwrap();
15346        assert_eq!(
15347            ordinary.get_content(),
15348            "<html><head></head><body></body></html>"
15349        );
15350    }
15351
15352    fn disjoint_streaming_protocol_ext(
15353        hosts: &[&str],
15354        emit_root_signal: bool,
15355        include_styles: bool,
15356    ) -> Protocol {
15357        let mut fragments = HashMap::new();
15358        let mut entry = vec![
15359            WebUIFragment::raw("<!DOCTYPE html><html><head>"),
15360            structural_fragment("head_start"),
15361            structural_fragment("head_end"),
15362            WebUIFragment::raw("</head><body>"),
15363            structural_fragment("body_start"),
15364        ];
15365        for (sequence, host) in hosts.iter().enumerate() {
15366            entry.push(WebUIFragment::boundary(
15367                sequence as u32,
15368                "index.html",
15369                format!("boundary-{sequence}"),
15370                None,
15371            ));
15372            entry.push(WebUIFragment::raw(format!("<{host}")));
15373            if emit_root_signal {
15374                entry.push(structural_fragment(format!("streaming_root:{host}")));
15375            }
15376            entry.push(WebUIFragment::raw(">"));
15377            entry.push(WebUIFragment::component(*host));
15378            entry.push(WebUIFragment::raw(format!("</{host}>")));
15379            entry.push(WebUIFragment::boundary_end(sequence as u32));
15380        }
15381        entry.push(structural_fragment("body_end"));
15382        entry.push(WebUIFragment::raw("</body></html>"));
15383        fragments.insert(
15384            "index.html".to_string(),
15385            FragmentList {
15386                fragments: entry,
15387                contains_boundary: true,
15388            },
15389        );
15390        fragments.insert(
15391            "comp-a".to_string(),
15392            FragmentList {
15393                fragments: vec![WebUIFragment::raw("<b>A</b>")],
15394                contains_boundary: false,
15395            },
15396        );
15397        fragments.insert(
15398            "comp-b".to_string(),
15399            FragmentList {
15400                fragments: vec![WebUIFragment::raw("<b>B</b>")],
15401                contains_boundary: false,
15402            },
15403        );
15404
15405        let mut document = WebUIProtocol::new(fragments);
15406        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15407        document.components.insert(
15408            "comp-a".to_string(),
15409            webui_protocol::ComponentData {
15410                template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
15411                hydration_mode: StateProjectionMode::Keys as i32,
15412                hydration_keys: vec!["a_count".to_string()],
15413                ..Default::default()
15414            },
15415        );
15416        document.components.insert(
15417            "comp-b".to_string(),
15418            webui_protocol::ComponentData {
15419                template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
15420                hydration_mode: StateProjectionMode::Keys as i32,
15421                hydration_keys: vec!["b_count".to_string()],
15422                ..Default::default()
15423            },
15424        );
15425        if include_styles {
15426            document.set_css_strategy(webui_protocol::CssStrategy::Style);
15427            document
15428                .components
15429                .get_mut("comp-a")
15430                .expect("comp-a component")
15431                .css = ".comp-a{color:red}".to_string();
15432            document
15433                .components
15434                .get_mut("comp-b")
15435                .expect("comp-b component")
15436                .css = ".comp-b{color:blue}".to_string();
15437            document.populate_style_closures(&["index.html"]);
15438        }
15439        Protocol::new(document)
15440    }
15441
15442    fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15443        disjoint_streaming_protocol_ext(hosts, true, false)
15444    }
15445
15446    fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15447        disjoint_streaming_protocol_ext(hosts, true, true)
15448    }
15449
15450    fn streaming_root_validation_protocol(
15451        mut host_fragments: Vec<WebUIFragment>,
15452        tail: Vec<WebUIFragment>,
15453    ) -> Protocol {
15454        let mut entry = vec![
15455            WebUIFragment::raw("<html><head>"),
15456            structural_fragment("head_start"),
15457            structural_fragment("head_end"),
15458            WebUIFragment::raw("</head><body>"),
15459            structural_fragment("body_start"),
15460            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15461        ];
15462        entry.append(&mut host_fragments);
15463        entry.extend(tail);
15464        let fragments = HashMap::from([
15465            (
15466                "index.html".to_string(),
15467                FragmentList {
15468                    fragments: entry,
15469                    contains_boundary: true,
15470                },
15471            ),
15472            (
15473                "comp-a".to_string(),
15474                FragmentList {
15475                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15476                    contains_boundary: false,
15477                },
15478            ),
15479            (
15480                "comp-b".to_string(),
15481                FragmentList {
15482                    fragments: vec![WebUIFragment::raw("<b>B</b>")],
15483                    contains_boundary: false,
15484                },
15485            ),
15486        ]);
15487        Protocol::new(WebUIProtocol::new(fragments))
15488    }
15489
15490    fn completed_streaming_tail() -> Vec<WebUIFragment> {
15491        vec![
15492            WebUIFragment::raw("</comp-a>"),
15493            WebUIFragment::boundary_end(0),
15494            structural_fragment("body_end"),
15495            WebUIFragment::raw("</body></html>"),
15496        ]
15497    }
15498
15499    fn assert_streaming_root_error(
15500        protocol: &Protocol,
15501        expected_signal: &str,
15502        expected_reason: &str,
15503    ) {
15504        let mut writer = FlushTestWriter::default();
15505        let result = WebUIHandler::new().render_streaming(
15506            protocol,
15507            &test_json!({}),
15508            &RenderOptions::new("index.html", "/"),
15509            &mut writer,
15510        );
15511        match result {
15512            Err(HandlerError::StreamingBoundary(error)) => {
15513                assert_eq!(error.signal, expected_signal);
15514                assert!(
15515                    error.reason.contains(expected_reason),
15516                    "reason: {}",
15517                    error.reason
15518                );
15519            }
15520            other => panic!("expected streaming-root rejection, got {other:?}"),
15521        }
15522    }
15523
15524    #[test]
15525    fn streaming_root_signal_injects_data_ws_inside_boundary() {
15526        let protocol = disjoint_streaming_protocol(&["comp-a"]);
15527        let handler = WebUIHandler::with_plugin(|| {
15528            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15529        });
15530        let mut writer = FlushTestWriter::default();
15531        handler
15532            .render_streaming(
15533                &protocol,
15534                &test_json!({ "a_count": 1 }),
15535                &RenderOptions::new("index.html", "/"),
15536                &mut writer,
15537            )
15538            .unwrap();
15539        // The parser-owned signal is consumed to inject exactly ` data-ws`
15540        // inside the host's opening tag, before the custom element upgrades.
15541        assert!(
15542            writer.output.contains("<comp-a data-ws>"),
15543            "streamed host must carry data-ws: {}",
15544            writer.output
15545        );
15546    }
15547
15548    #[test]
15549    fn streaming_root_signal_preserves_ordinary_output_bytes() {
15550        // Ordinary rendering ignores `streaming_root` byte-for-byte: identical
15551        // output with and without the signal, and never a `data-ws` attribute.
15552        let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15553        let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15554        let state = test_json!({ "a_count": 1, "b_count": 2 });
15555        let plugin = || {
15556            WebUIHandler::with_plugin(
15557                || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15558            )
15559        };
15560        let mut with_writer = TestWriter::new();
15561        let mut without_writer = TestWriter::new();
15562        plugin()
15563            .render(
15564                &with_signal,
15565                &state,
15566                &RenderOptions::new("index.html", "/"),
15567                &mut with_writer,
15568            )
15569            .unwrap();
15570        plugin()
15571            .render(
15572                &without_signal,
15573                &state,
15574                &RenderOptions::new("index.html", "/"),
15575                &mut without_writer,
15576            )
15577            .unwrap();
15578        // The rendered DOM (everything up to the inert data block) is
15579        // deterministic and is where a leaked `data-ws` would appear. The
15580        // ordinary template map is HashSet-ordered, so compare the DOM prefix
15581        // for byte identity rather than the whole document.
15582        let dom_prefix = |content: &str| -> String {
15583            content
15584                .split_once(r#"<script type="application/json" id="webui-data""#)
15585                .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15586        };
15587        assert_eq!(
15588            dom_prefix(&with_writer.get_content()),
15589            dom_prefix(&without_writer.get_content())
15590        );
15591        assert!(!with_writer.get_content().contains("data-ws"));
15592        assert!(with_writer.get_content().contains("<comp-a>"));
15593    }
15594
15595    #[test]
15596    fn streaming_component_without_root_signal_is_rejected() {
15597        let protocol = streaming_root_validation_protocol(
15598            vec![
15599                WebUIFragment::raw("<comp-a>"),
15600                WebUIFragment::component("comp-a"),
15601            ],
15602            completed_streaming_tail(),
15603        );
15604
15605        assert_streaming_root_error(
15606            &protocol,
15607            "comp-a",
15608            "has no compiler-owned streaming_root signal",
15609        );
15610    }
15611
15612    #[test]
15613    fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15614        let duplicate = streaming_root_validation_protocol(
15615            vec![
15616                WebUIFragment::raw("<comp-a"),
15617                structural_fragment("streaming_root:comp-a"),
15618                structural_fragment("streaming_root:comp-a"),
15619                WebUIFragment::raw(">"),
15620                WebUIFragment::component("comp-a"),
15621            ],
15622            completed_streaming_tail(),
15623        );
15624        assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15625
15626        let after_tag_close = streaming_root_validation_protocol(
15627            vec![
15628                WebUIFragment::raw("<comp-a>"),
15629                structural_fragment("streaming_root:comp-a"),
15630                WebUIFragment::component("comp-a"),
15631            ],
15632            completed_streaming_tail(),
15633        );
15634        assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15635
15636        let detached_from_opening = streaming_root_validation_protocol(
15637            vec![
15638                WebUIFragment::raw("not an opening tag"),
15639                structural_fragment("streaming_root:comp-a"),
15640                WebUIFragment::raw(">"),
15641                WebUIFragment::component("comp-a"),
15642            ],
15643            completed_streaming_tail(),
15644        );
15645        assert_streaming_root_error(
15646            &detached_from_opening,
15647            "comp-a",
15648            "unclosed component opening-tag close",
15649        );
15650    }
15651
15652    #[test]
15653    fn mismatched_streaming_root_signal_is_rejected() {
15654        let protocol = streaming_root_validation_protocol(
15655            vec![
15656                WebUIFragment::raw("<comp-a"),
15657                structural_fragment("streaming_root:comp-b"),
15658                WebUIFragment::raw(">"),
15659                WebUIFragment::component("comp-a"),
15660            ],
15661            completed_streaming_tail(),
15662        );
15663
15664        assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15665    }
15666
15667    #[test]
15668    fn pending_streaming_root_is_rejected_at_structural_ends() {
15669        let protocol = streaming_root_validation_protocol(
15670            vec![
15671                WebUIFragment::raw("<comp-a"),
15672                structural_fragment("streaming_root:comp-a"),
15673                WebUIFragment::raw(">"),
15674            ],
15675            vec![structural_fragment("boundary_end:0")],
15676        );
15677        assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15678    }
15679
15680    #[test]
15681    fn streaming_root_outside_boundary_is_rejected() {
15682        let fragments = HashMap::from([
15683            (
15684                "index.html".to_string(),
15685                FragmentList {
15686                    fragments: vec![
15687                        WebUIFragment::raw("<html><head>"),
15688                        structural_fragment("head_start"),
15689                        structural_fragment("head_end"),
15690                        WebUIFragment::raw("</head><body>"),
15691                        structural_fragment("body_start"),
15692                        WebUIFragment::raw("<comp-a"),
15693                        structural_fragment("streaming_root:comp-a"),
15694                        WebUIFragment::raw(">"),
15695                        WebUIFragment::component("comp-a"),
15696                        WebUIFragment::raw("</comp-a>"),
15697                        structural_fragment("body_end"),
15698                        WebUIFragment::raw("</body></html>"),
15699                    ],
15700                    contains_boundary: false,
15701                },
15702            ),
15703            (
15704                "comp-a".to_string(),
15705                FragmentList {
15706                    fragments: vec![WebUIFragment::raw("<b>A</b>")],
15707                    contains_boundary: false,
15708                },
15709            ),
15710        ]);
15711        let protocol = Protocol::new(WebUIProtocol::new(fragments));
15712        let mut writer = FlushTestWriter::default();
15713        let result = WebUIHandler::new().render_streaming(
15714            &protocol,
15715            &test_json!({}),
15716            &RenderOptions::new("index.html", "/"),
15717            &mut writer,
15718        );
15719        match result {
15720            Err(HandlerError::StreamingBoundary(err)) => {
15721                assert_eq!(err.signal, "comp-a");
15722                assert!(
15723                    err.reason
15724                        .contains("no boundary or generated component span"),
15725                    "reason: {}",
15726                    err.reason
15727                );
15728            }
15729            other => panic!("expected outside-boundary rejection, got {other:?}"),
15730        }
15731    }
15732
15733    fn parser_route_protocol(with_boundary: bool) -> Protocol {
15734        let mut parser = HtmlParser::with_options(DomStrategy::Light);
15735        parser
15736            .component_registry_mut()
15737            .register_component(ComponentRegistration::new(
15738                "route-page",
15739                "<p>route content</p>",
15740                None,
15741                true,
15742            ))
15743            .expect("register route component");
15744        let route = r#"<route path="/" component="route-page" exact />"#;
15745        let html = if with_boundary {
15746            format!(
15747                "<html><head></head><body>\
15748                 <boundary name=\"route\">{route}</boundary>\
15749                 </body></html>"
15750            )
15751        } else {
15752            format!("<html><head></head><body>{route}</body></html>")
15753        };
15754        parser
15755            .parse("index.html", &html)
15756            .expect("parse route streaming fixture");
15757        let mut document = WebUIProtocol::new(parser.into_fragment_records());
15758        document.components.insert(
15759            "route-page".to_string(),
15760            webui_protocol::ComponentData::default(),
15761        );
15762        Protocol::new(document)
15763    }
15764
15765    #[test]
15766    fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15767        let protocol = parser_route_protocol(true);
15768        let mut writer = FlushTestWriter::default();
15769        WebUIHandler::new()
15770            .render_streaming(
15771                &protocol,
15772                &test_json!({}),
15773                &RenderOptions::new("index.html", "/"),
15774                &mut writer,
15775            )
15776            .unwrap();
15777
15778        assert!(
15779            writer
15780                .output
15781                .contains("<route-page data-ws><p>route content</p></route-page>"),
15782            "matched route host must be deferred before upgrade: {}",
15783            writer.output
15784        );
15785    }
15786
15787    #[test]
15788    fn streaming_parser_route_host_outside_boundary_is_rejected() {
15789        let protocol = parser_route_protocol(false);
15790        let mut writer = FlushTestWriter::default();
15791        let result = WebUIHandler::new().render_streaming(
15792            &protocol,
15793            &test_json!({}),
15794            &RenderOptions::new("index.html", "/"),
15795            &mut writer,
15796        );
15797
15798        match result {
15799            Err(HandlerError::StreamingBoundary(error)) => {
15800                assert_eq!(error.signal, "route-page");
15801                assert!(
15802                    error
15803                        .reason
15804                        .contains("no boundary or generated component span"),
15805                    "reason: {}",
15806                    error.reason
15807                );
15808            }
15809            other => panic!("expected route boundary rejection, got {other:?}"),
15810        }
15811        assert!(
15812            !writer.output.contains("<route-page>"),
15813            "an unmarked custom-element opening must never be completed"
15814        );
15815    }
15816
15817    #[test]
15818    fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15819        let entry = vec![
15820            WebUIFragment::raw("<html><head>"),
15821            structural_fragment("head_start"),
15822            structural_fragment("head_end"),
15823            WebUIFragment::raw("</head><body>"),
15824            structural_fragment("body_start"),
15825            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15826            WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15827                path: "/account".into(),
15828                fragment_id: "account-shell".into(),
15829                exact: false,
15830                ..Default::default()
15831            }),
15832            WebUIFragment::boundary_end(0),
15833            structural_fragment("body_end"),
15834            WebUIFragment::raw("</body></html>"),
15835        ];
15836        let fragments = HashMap::from([
15837            (
15838                "index.html".to_string(),
15839                FragmentList {
15840                    fragments: entry,
15841                    contains_boundary: true,
15842                },
15843            ),
15844            (
15845                "account-shell".to_string(),
15846                FragmentList {
15847                    fragments: vec![WebUIFragment::route_from(
15848                        webui_protocol::WebUiFragmentRoute {
15849                            path: "./details".into(),
15850                            fragment_id: "details-page".into(),
15851                            exact: true,
15852                            pending_component: "details-loading".into(),
15853                            error_component: "details-error".into(),
15854                            ..Default::default()
15855                        },
15856                    )],
15857                    contains_boundary: false,
15858                },
15859            ),
15860            (
15861                "details-page".to_string(),
15862                FragmentList {
15863                    fragments: vec![WebUIFragment::raw("<p>details</p>")],
15864                    contains_boundary: false,
15865                },
15866            ),
15867            (
15868                "details-loading".to_string(),
15869                FragmentList {
15870                    fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15871                    contains_boundary: false,
15872                },
15873            ),
15874            (
15875                "details-error".to_string(),
15876                FragmentList {
15877                    fragments: vec![WebUIFragment::raw("<p>error</p>")],
15878                    contains_boundary: false,
15879                },
15880            ),
15881        ]);
15882        let mut document = WebUIProtocol::new(fragments);
15883        document.initial_state_strategy = InitialStateStrategy::Components as i32;
15884        for name in [
15885            "account-shell",
15886            "details-page",
15887            "details-loading",
15888            "details-error",
15889        ] {
15890            document.components.insert(
15891                name.to_string(),
15892                webui_protocol::ComponentData {
15893                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15894                    ..Default::default()
15895                },
15896            );
15897        }
15898        let protocol = Protocol::new(document);
15899        let handler = WebUIHandler::with_plugin(|| {
15900            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15901        });
15902        let mut writer = FlushTestWriter::default();
15903
15904        handler
15905            .render_streaming(
15906                &protocol,
15907                &test_json!({}),
15908                &RenderOptions::new("index.html", "/account/details"),
15909                &mut writer,
15910            )
15911            .unwrap();
15912
15913        let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15914        assert!(
15915            first.contains(r#""details-loading":"#),
15916            "relative-route pending metadata missing: {first}"
15917        );
15918        assert!(
15919            first.contains(r#""details-error":"#),
15920            "relative-route error metadata missing: {first}"
15921        );
15922    }
15923
15924    #[test]
15925    fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15926        let entry = vec![
15927            WebUIFragment::raw("<html><head>"),
15928            structural_fragment("head_start"),
15929            structural_fragment("head_end"),
15930            WebUIFragment::raw("</head><body>"),
15931            structural_fragment("body_start"),
15932            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15933            WebUIFragment::raw("<static-shell"),
15934            structural_fragment("streaming_root:static-shell"),
15935            WebUIFragment::raw(">"),
15936            WebUIFragment::component("static-shell"),
15937            WebUIFragment::raw("</static-shell>"),
15938            WebUIFragment::raw("<route-shell"),
15939            structural_fragment("streaming_root:route-shell"),
15940            WebUIFragment::raw(">"),
15941            WebUIFragment::component("route-shell"),
15942            WebUIFragment::raw("</route-shell>"),
15943            WebUIFragment::boundary_end(0),
15944            structural_fragment("body_end"),
15945            WebUIFragment::raw("</body></html>"),
15946        ];
15947        let fragments = HashMap::from([
15948            (
15949                "index.html".to_string(),
15950                FragmentList {
15951                    fragments: entry,
15952                    contains_boundary: true,
15953                },
15954            ),
15955            (
15956                "static-shell".to_string(),
15957                FragmentList {
15958                    fragments: vec![WebUIFragment::if_cond(
15959                        ConditionExpr::identifier("show_hidden"),
15960                        "static-hidden-if",
15961                    )],
15962                    contains_boundary: false,
15963                },
15964            ),
15965            (
15966                "static-hidden-if".to_string(),
15967                FragmentList {
15968                    fragments: vec![WebUIFragment::component("static-hidden")],
15969                    contains_boundary: false,
15970                },
15971            ),
15972            (
15973                "static-hidden".to_string(),
15974                FragmentList {
15975                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15976                    contains_boundary: false,
15977                },
15978            ),
15979            (
15980                "route-shell".to_string(),
15981                FragmentList {
15982                    fragments: vec![WebUIFragment::route_from(
15983                        webui_protocol::WebUiFragmentRoute {
15984                            path: "/account".into(),
15985                            fragment_id: "route-page".into(),
15986                            exact: true,
15987                            ..Default::default()
15988                        },
15989                    )],
15990                    contains_boundary: false,
15991                },
15992            ),
15993            (
15994                "route-page".to_string(),
15995                FragmentList {
15996                    fragments: vec![WebUIFragment::raw("<p>route</p>")],
15997                    contains_boundary: false,
15998                },
15999            ),
16000        ]);
16001        let mut document = WebUIProtocol::new(fragments);
16002        document.set_css_strategy(webui_protocol::CssStrategy::Link);
16003        document.initial_state_strategy = InitialStateStrategy::Components as i32;
16004        for (name, keys) in [
16005            ("static-shell", vec!["show_hidden", "static_count"]),
16006            ("static-hidden", vec!["hidden_count"]),
16007            ("route-shell", vec!["route_shell_count"]),
16008            ("route-page", vec!["route_count"]),
16009        ] {
16010            document.components.insert(
16011                name.to_string(),
16012                webui_protocol::ComponentData {
16013                    template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
16014                    hydration_mode: StateProjectionMode::Keys as i32,
16015                    hydration_keys: keys.into_iter().map(str::to_string).collect(),
16016                    css_href: format!("/{name}.css"),
16017                    ..Default::default()
16018                },
16019            );
16020        }
16021        document.populate_style_closures(&["index.html"]);
16022        let protocol = Protocol::new(document);
16023        assert_eq!(
16024            protocol
16025                .component_reachability()
16026                .is_route_dependent(protocol.component_index()["route-shell"]),
16027            Some(true),
16028            "fixture must exercise request-aware checkpoint reachability"
16029        );
16030        let handler = WebUIHandler::with_plugin(|| {
16031            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16032        });
16033        let mut writer = FlushTestWriter::default();
16034
16035        handler
16036            .render_streaming(
16037                &protocol,
16038                &test_json!({
16039                    "show_hidden": false,
16040                    "static_count": 1,
16041                    "hidden_count": 2,
16042                    "route_shell_count": 3,
16043                    "route_count": 4,
16044                    "unrelated": "private",
16045                }),
16046                &RenderOptions::new("index.html", "/account"),
16047                &mut writer,
16048            )
16049            .unwrap();
16050
16051        let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
16052        for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
16053            assert!(
16054                checkpoint.contains(&format!(r#""{template}":"#)),
16055                "{template} metadata missing: {checkpoint}"
16056            );
16057        }
16058        for state in [
16059            r#""show_hidden":false"#,
16060            r#""static_count":1"#,
16061            r#""hidden_count":2"#,
16062            r#""route_shell_count":3"#,
16063            r#""route_count":4"#,
16064        ] {
16065            assert!(
16066                checkpoint.contains(state),
16067                "{state} state missing: {checkpoint}"
16068            );
16069        }
16070        assert!(
16071            !checkpoint.contains("unrelated"),
16072            "checkpoint leaked unrelated state: {checkpoint}"
16073        );
16074        assert!(
16075            checkpoint.contains(
16076                r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
16077            ),
16078            "checkpoint metadata lost source order: {checkpoint}"
16079        );
16080        assert!(
16081            checkpoint.contains(r#""componentStyles":"#)
16082                && checkpoint.contains(r#""strategy":"link""#)
16083                && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
16084            "checkpoint is missing tree-local style metadata: {checkpoint}"
16085        );
16086        for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
16087            assert_eq!(
16088                writer
16089                    .output
16090                    .matches(&format!(r#"data-webui-resource="{resource}""#))
16091                    .count(),
16092                1,
16093                "Document delivery state must persist across streamed checkpoints"
16094            );
16095        }
16096    }
16097
16098    #[test]
16099    fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
16100        // Three boundaries: comp-a, comp-b, then comp-a reused. Each checkpoint
16101        // envelope must be locally scoped — templates/state for exactly the
16102        // components rendered since the previous checkpoint, with reused tags
16103        // carrying hydration state but never a duplicate template.
16104        let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
16105        let handler = WebUIHandler::with_plugin(|| {
16106            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16107        });
16108        let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
16109        let mut writer = FlushTestWriter::default();
16110        handler
16111            .render_streaming(
16112                &protocol,
16113                &state,
16114                &RenderOptions::new("index.html", "/"),
16115                &mut writer,
16116            )
16117            .unwrap();
16118
16119        assert_eq!(
16120            writer.flushes.len(),
16121            5,
16122            "bootstrap, three boundary commits, and one terminal"
16123        );
16124        let segment = |index: usize| -> &str {
16125            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16126        };
16127        let b0 = segment(0);
16128        let b1 = segment(1);
16129        let b2 = segment(2);
16130
16131        // Boundary 0: only comp-a's template/state; no comp-b artifacts.
16132        assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
16133        assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
16134        assert!(
16135            b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
16136            "b0: {b0}"
16137        );
16138        assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
16139        assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
16140        assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
16141
16142        // Boundary 1: only comp-b's template/state; no duplicate comp-a template.
16143        assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
16144        assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
16145        assert!(
16146            b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
16147            "b1: {b1}"
16148        );
16149        assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
16150        assert!(
16151            !b1.contains(r#""templates":{"comp-a"#),
16152            "b1 re-sent comp-a: {b1}"
16153        );
16154        assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
16155
16156        // Boundary 2: comp-a reused — state present, template absent (empty delta).
16157        assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
16158        assert!(
16159            b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
16160            "b2: {b2}"
16161        );
16162        assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
16163        assert!(
16164            !b2.contains(r#""templates""#),
16165            "b2 re-sent a template: {b2}"
16166        );
16167
16168        // Server-only state never leaks into any envelope.
16169        assert!(!writer.output.contains("serverOnly"));
16170    }
16171
16172    #[test]
16173    fn streaming_checkpoints_emit_style_resources_and_closures_once() {
16174        let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
16175        let handler = WebUIHandler::with_plugin(|| {
16176            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16177        });
16178        let mut writer = FlushTestWriter::default();
16179        handler
16180            .render_streaming(
16181                &protocol,
16182                &test_json!({ "a_count": 1, "b_count": 2 }),
16183                &RenderOptions::new("index.html", "/"),
16184                &mut writer,
16185            )
16186            .unwrap();
16187
16188        let segment = |index: usize| -> &str {
16189            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16190        };
16191        let checkpoint_styles = |checkpoint: &str| -> Value {
16192            let script = checkpoint
16193                .rfind(r#"<script type="application/json" data-webui-boundary"#)
16194                .unwrap();
16195            let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
16196            let payload_end =
16197                payload_start + checkpoint[payload_start..].find("</script>").unwrap();
16198            let record: Value =
16199                serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
16200            record[3]["componentStyles"].clone()
16201        };
16202
16203        let first = checkpoint_styles(segment(0));
16204        assert!(first["resources"]["comp-a"].is_object());
16205        assert!(first["resources"]["comp-b"].is_object());
16206        assert!(first["closures"]["index.html"].is_array());
16207        assert!(first["closures"]["comp-a"].is_array());
16208
16209        let second = checkpoint_styles(segment(1));
16210        assert_eq!(second["resources"], test_json!({}));
16211        assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
16212
16213        let repeated = checkpoint_styles(segment(2));
16214        assert_eq!(repeated["resources"], test_json!({}));
16215        assert_eq!(repeated["closures"], test_json!({}));
16216        for resource in ["comp-a", "comp-b"] {
16217            assert_eq!(
16218                [&first, &second, &repeated]
16219                    .iter()
16220                    .filter(|styles| {
16221                        styles["resources"]
16222                            .as_object()
16223                            .is_some_and(|resources| resources.contains_key(resource))
16224                    })
16225                    .count(),
16226                1,
16227                "{resource} style metadata was serialized more than once"
16228            );
16229        }
16230    }
16231
16232    #[test]
16233    fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
16234        let entry = vec![
16235            WebUIFragment::raw("<html><head>"),
16236            structural_fragment("head_start"),
16237            structural_fragment("head_end"),
16238            WebUIFragment::raw("</head><body>"),
16239            structural_fragment("body_start"),
16240            WebUIFragment::boundary(0, "index.html", "boundary-0", None),
16241            WebUIFragment::raw("<comp-a"),
16242            structural_fragment("streaming_root:comp-a"),
16243            WebUIFragment::raw(">"),
16244            WebUIFragment::component("comp-a"),
16245            WebUIFragment::raw("</comp-a>"),
16246            WebUIFragment::boundary_end(0),
16247            WebUIFragment::boundary(1, "index.html", "boundary-1", None),
16248            WebUIFragment::raw("<comp-hidden"),
16249            structural_fragment("streaming_root:comp-hidden"),
16250            WebUIFragment::raw(">"),
16251            WebUIFragment::component("comp-hidden"),
16252            WebUIFragment::raw("</comp-hidden>"),
16253            WebUIFragment::boundary_end(1),
16254            structural_fragment("body_end"),
16255            WebUIFragment::raw("</body></html>"),
16256        ];
16257        let fragments = HashMap::from([
16258            (
16259                "index.html".to_string(),
16260                FragmentList {
16261                    fragments: entry,
16262                    contains_boundary: true,
16263                },
16264            ),
16265            (
16266                "comp-a".to_string(),
16267                FragmentList {
16268                    fragments: vec![WebUIFragment::if_cond(
16269                        ConditionExpr::identifier("show_hidden"),
16270                        "hidden-if",
16271                    )],
16272                    contains_boundary: false,
16273                },
16274            ),
16275            (
16276                "hidden-if".to_string(),
16277                FragmentList {
16278                    fragments: vec![WebUIFragment::component("comp-hidden")],
16279                    contains_boundary: false,
16280                },
16281            ),
16282            (
16283                "comp-hidden".to_string(),
16284                FragmentList {
16285                    fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
16286                    contains_boundary: false,
16287                },
16288            ),
16289        ]);
16290        let mut document = WebUIProtocol::new(fragments);
16291        document.set_css_strategy(webui_protocol::CssStrategy::Module);
16292        document.initial_state_strategy = InitialStateStrategy::Components as i32;
16293        document.components.insert(
16294            "comp-a".to_string(),
16295            webui_protocol::ComponentData {
16296                template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
16297                hydration_mode: StateProjectionMode::Keys as i32,
16298                hydration_keys: vec!["show_hidden".to_string()],
16299                ..Default::default()
16300            },
16301        );
16302        document.components.insert(
16303            "comp-hidden".to_string(),
16304            webui_protocol::ComponentData {
16305                template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
16306                css: ".hidden{display:block}".to_string(),
16307                hydration_mode: StateProjectionMode::Keys as i32,
16308                hydration_keys: vec!["hidden_count".to_string()],
16309                ..Default::default()
16310            },
16311        );
16312        document.populate_style_closures(&["index.html"]);
16313        let protocol = Protocol::new(document);
16314        let handler = WebUIHandler::with_plugin(|| {
16315            Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16316        });
16317        let mut writer = FlushTestWriter::default();
16318
16319        handler
16320            .render_streaming(
16321                &protocol,
16322                &test_json!({
16323                    "show_hidden": false,
16324                    "hidden_count": 7,
16325                    "unrelated": "private",
16326                }),
16327                &RenderOptions::new("index.html", "/"),
16328                &mut writer,
16329            )
16330            .unwrap();
16331
16332        let boundary = |index: usize| -> &str {
16333            &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16334        };
16335        let first = boundary(0);
16336        let second = boundary(1);
16337
16338        assert!(first.contains(r#""comp-a":"#), "first: {first}");
16339        assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
16340        assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
16341        assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
16342        assert!(first.contains(r#""inventory":"01""#), "first: {first}");
16343        assert!(
16344            first.contains(r#"<script type="importmap""#),
16345            "first: {first}"
16346        );
16347        assert!(
16348            !first.contains("unrelated"),
16349            "first leaked unrelated state: {first}"
16350        );
16351
16352        assert!(
16353            !second.contains(r#""templates""#),
16354            "second re-sent metadata: {second}"
16355        );
16356        assert!(
16357            !second.contains(r#"<script type="importmap""#),
16358            "second re-sent CSS: {second}"
16359        );
16360        assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
16361        assert!(second.contains(r#""inventory":"02""#), "second: {second}");
16362        assert_eq!(
16363            writer
16364                .output
16365                .matches(r#"<script type="importmap">"#)
16366                .count(),
16367            1,
16368            "import-map output: {}",
16369            writer.output
16370        );
16371    }
16372
16373    #[test]
16374    fn streaming_flush_count_is_boundaries_plus_one() {
16375        for &count in &[1usize, 3, 10, 100] {
16376            let hosts: Vec<&str> = (0..count)
16377                .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
16378                .collect();
16379            let protocol = disjoint_streaming_protocol(&hosts);
16380            let handler = WebUIHandler::with_plugin(|| {
16381                Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16382            });
16383            let mut writer = FlushTestWriter::default();
16384            handler
16385                .render_streaming(
16386                    &protocol,
16387                    &test_json!({ "a_count": 1, "b_count": 2 }),
16388                    &RenderOptions::new("index.html", "/"),
16389                    &mut writer,
16390                )
16391                .unwrap();
16392            assert_eq!(
16393                writer.flushes.len(),
16394                count + 2,
16395                "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
16396            );
16397        }
16398    }
16399
16400    #[test]
16401    fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
16402        let mut fragments = HashMap::new();
16403        fragments.insert("comp-a".to_string(), FragmentList::default());
16404        fragments.insert("comp-b".to_string(), FragmentList::default());
16405        let mut protocol = WebUIProtocol::new(fragments);
16406        protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
16407        protocol.components.insert(
16408            "comp-a".to_string(),
16409            webui_protocol::ComponentData {
16410                hydration_mode: StateProjectionMode::Keys as i32,
16411                hydration_keys: vec!["a".to_string(), "shared".to_string()],
16412                ..Default::default()
16413            },
16414        );
16415        protocol.components.insert(
16416            "comp-b".to_string(),
16417            webui_protocol::ComponentData {
16418                hydration_mode: StateProjectionMode::Keys as i32,
16419                hydration_keys: vec!["b".to_string(), "shared".to_string()],
16420                ..Default::default()
16421            },
16422        );
16423
16424        let protocol = Protocol::new(protocol);
16425        let component_index = protocol.component_index();
16426        let reachability = protocol.component_reachability();
16427        let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16428        assert!(!collect_hydration_key_ids_into(
16429            protocol.protocol(),
16430            reachability,
16431            [component_index["comp-a"]],
16432            &mut scratch,
16433        ));
16434        let keys = scratch
16435            .iter()
16436            .filter_map(|id| reachability.hydration_key(*id))
16437            .collect::<Vec<_>>();
16438        assert_eq!(keys, ["a", "shared"]);
16439        let pointer = scratch.as_ptr();
16440        let capacity = scratch.capacity();
16441
16442        assert!(!collect_hydration_key_ids_into(
16443            protocol.protocol(),
16444            reachability,
16445            [component_index["comp-b"]],
16446            &mut scratch,
16447        ));
16448        let keys = scratch
16449            .iter()
16450            .filter_map(|id| reachability.hydration_key(*id))
16451            .collect::<Vec<_>>();
16452        assert_eq!(keys, ["b", "shared"]);
16453        assert_eq!(scratch.as_ptr(), pointer);
16454        assert_eq!(scratch.capacity(), capacity);
16455    }
16456}