Skip to main content

forma_ir/
walker.rs

1//! Static HTML walker — walks the IR opcode stream to produce HTML output.
2//!
3//! This is the core SSR rendering engine. It takes a parsed `IrModule` plus
4//! populated `SlotData`, walks the opcode stream linearly, and produces an
5//! HTML string suitable for embedding in the page.
6
7#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
10use crate::parser::IrModule;
11use crate::slot::{SlotData, SlotValue};
12use std::fmt;
13
14// ---------------------------------------------------------------------------
15// Walk state
16// ---------------------------------------------------------------------------
17
18/// Pending island info to be injected on the next OPEN_TAG or VOID_TAG.
19struct IslandPending {
20    id: u16,
21    component_name: String,
22    inline_props: Option<String>,
23    trigger: IslandTrigger,
24}
25
26/// Serialized-JSON size above which `PropsMode::Inline` props are spilled into
27/// the shared `__forma_islands` block instead of a `data-forma-props`
28/// attribute.
29///
30/// Matches the client's documented guidance ("inline for small props, < 1KB;
31/// the script block for larger props"). Measured on the *JSON*, before HTML
32/// attribute escaping — a string-heavy payload inflates roughly 1.8x on the
33/// wire because every `"` becomes `&quot;`, so the attribute this bounds is
34/// closer to 2 KiB in the worst case.
35///
36/// The spill needs no client change: `loadIslandProps`
37/// (`@getforma/core`, src/dom/activate.ts) reads `data-forma-props` first and
38/// falls back to the shared block, and the walker emits exactly one channel
39/// per island so that precedence never has two answers to choose between.
40///
41/// A *fragment* render ([`walk_island`]) never spills — it cannot legally emit
42/// a second `__forma_islands` block — so an over-size fragment keeps its props
43/// inline.
44///
45/// Verified by: crates/forma-ir/src/walker.rs > "inline_props_over_the_ceiling_spill_to_the_shared_block"
46/// Verified by: crates/forma-ir/src/walker.rs > "island_fragment_keeps_over_size_props_inline"
47pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
48
49/// Default cap on the HTML a single walk may produce.
50///
51/// A structurally valid module can otherwise force an arbitrarily large
52/// `String` allocation inside one request handler, and Rust answers allocation
53/// failure with `handle_alloc_error` → process abort, which kills every
54/// in-flight request. Raise it via [`WalkLimits`] for an app with a
55/// legitimately large data-driven page rather than accepting a silent
56/// degradation.
57pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
58
59/// Default cap on total LIST item iterations per render, across all depths.
60///
61/// `MAX_LIST_DEPTH` bounds nesting but not volume: four LISTs bound to the
62/// same array give n^4 body walks from one small module plus one large
63/// request array.
64pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
65
66/// Resource budgets for a single walk.
67///
68/// Both are per-render totals, not per-nesting-level.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct WalkLimits {
71    /// Maximum bytes of HTML the walk may emit.
72    pub max_output_bytes: usize,
73    /// Maximum total LIST item iterations across all nesting depths.
74    pub max_list_iterations: usize,
75}
76
77impl Default for WalkLimits {
78    fn default() -> Self {
79        WalkLimits {
80            max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
81            max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
82        }
83    }
84}
85
86/// Mutable state threaded through recursive `walk_range` calls.
87///
88/// This avoids adding extra parameters every time we need new walker state
89/// (e.g., list depth, output budget).
90struct WalkState {
91    /// Current nesting depth for LIST opcodes.
92    list_depth: u8,
93    /// Pending island info to inject on the next OPEN_TAG/VOID_TAG.
94    pending_island: Option<IslandPending>,
95    /// Pending list-item key to inject on the next OPEN_TAG/VOID_TAG.
96    pending_list_key: Option<String>,
97    /// Accumulated props for ScriptTag mode, keyed by island id.
98    script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
99    /// True when an OpenTag or VoidTag has been opened but the `>` has not
100    /// been written yet. DYN_ATTR opcodes may still append attributes.
101    pending_tag_close: bool,
102    /// Tag name of the element currently being opened — needed to decide
103    /// whether a `data:image/svg+xml` URL is in an image context.
104    pending_tag_name: String,
105    /// Output-size budget. Lives on the state (not as a `walk_range`
106    /// parameter) so every recursive call sharing the same `out` enforces the
107    /// same cumulative budget by construction.
108    max_output_bytes: usize,
109    /// Running total of LIST item iterations, counted across all depths and
110    /// never decremented.
111    list_iterations: usize,
112    /// Cap for `list_iterations`.
113    max_list_iterations: usize,
114    /// Undo log for slot writes made inside a LIST body.
115    ///
116    /// A list row must render from its own scope: the item binding and any
117    /// PROP the body extracts from it are visible to that row and to nothing
118    /// else. The loop used to buy that by deep-cloning the whole `SlotData`
119    /// per row, which costs O(rows x slots) — the walk was measurably
120    /// quadratic in row count. Instead each write swaps the new value in and
121    /// pushes the displaced one here; the row's `Drop`-equivalent pops back to
122    /// the mark it took on entry, restoring exactly the state the caller had.
123    ///
124    /// Entries are strictly LIFO across nesting: an inner list unwinds its own
125    /// entries before its enclosing row unwinds. The `Vec` keeps its capacity
126    /// between rows, so steady-state pushes do not allocate.
127    slot_journal: Vec<(u16, SlotValue)>,
128    /// When true, `PropsMode::ScriptTag` islands inline their props on the
129    /// root instead of accumulating into the shared `__forma_islands` block.
130    /// Set for fragment renders, where a shared block would be dropped on
131    /// return (or, if emitted, would duplicate the page's own block).
132    force_inline_props: bool,
133    /// The text-only element (`textarea`, `title`, `style`, `script`) the
134    /// walk is inside, if any. Inside one, no hydration marker is written; see
135    /// [`TextOnlyTag`].
136    text_only: Option<TextOnlyParent>,
137    /// Form-control state gathered from the attributes of the start tag that
138    /// is still open, resolved when its `>` is written ([`close_start_tag`]).
139    pending_control: PendingControl,
140    /// The bound `select` the walk is inside, if any.
141    select: Option<SelectBinding>,
142    /// An `option` of a bound select with no `value` attribute: its value is
143    /// its text, which is known only when the option ends.
144    option_by_text: Option<OptionByText>,
145    /// Elements and text nodes written so far ([`WalkCounts::nodes`]).
146    nodes: usize,
147    /// The islands open at this point of the walk, outermost first: the
148    /// component path a failure reports ([`WalkFailure::component_path`]).
149    islands: Vec<u16>,
150    /// Whether `fault_injection::FAULT_TRIGGER` may fire: set only by
151    /// [`walk_to_html_counted`], which catches the panic, so the uncaught
152    /// `walk_to_html` path can never take it.
153    #[cfg(any(test, feature = "__fault-injection"))]
154    fault_armed: bool,
155}
156
157impl WalkState {
158    fn new(limits: WalkLimits) -> Self {
159        WalkState {
160            list_depth: 0,
161            pending_island: None,
162            pending_list_key: None,
163            script_tag_props: std::collections::BTreeMap::new(),
164            pending_tag_close: false,
165            pending_tag_name: String::new(),
166            max_output_bytes: limits.max_output_bytes,
167            list_iterations: 0,
168            max_list_iterations: limits.max_list_iterations,
169            slot_journal: Vec::new(),
170            force_inline_props: false,
171            text_only: None,
172            pending_control: PendingControl::None,
173            select: None,
174            option_by_text: None,
175            nodes: 0,
176            islands: Vec::new(),
177            #[cfg(any(test, feature = "__fault-injection"))]
178            fault_armed: false,
179        }
180    }
181
182    /// Record a slot write so the enclosing LIST row can undo it.
183    ///
184    /// Outside a list there is nothing to undo — a PROP at depth 0 writes to
185    /// the walk's own `SlotData` copy for the rest of the page, which is what
186    /// the clone-per-row implementation did too.
187    fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
188        if self.list_depth > 0 {
189            self.slot_journal.push((slot_id, previous));
190        }
191    }
192
193    /// Undo every journalled write back to `mark`, restoring `slots`.
194    fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
195        while self.slot_journal.len() > mark {
196            // `len() > mark >= 0`, so the pop cannot be None.
197            if let Some((slot_id, previous)) = self.slot_journal.pop() {
198                slots.set(slot_id, previous);
199            }
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Dev-mode slot source check
206// ---------------------------------------------------------------------------
207
208/// Log a warning when a server-sourced slot has no value at render time.
209///
210/// This is a dev-mode diagnostic: if a slot is declared as `SlotSource::Server`
211/// but the handler never populated it (still `SlotValue::Null`), the page
212/// handler likely has a bug. The warning is informational and does not affect
213/// output.
214#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
215fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
216    // Gated as a whole: with `tracing` off there is nothing to report, so the
217    // lookup should not run at all. (It is an O(1) index lookup now either
218    // way, but the source should say what it means.)
219    #[cfg(feature = "tracing")]
220    if let Some(entry) = module.slot_entry(slot_id) {
221        if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
222            tracing::warn!(
223                slot_id = slot_id,
224                "Server-sourced slot has no value at render time — handler may have a bug"
225            );
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Public API
232// ---------------------------------------------------------------------------
233
234/// Walk the IR opcode stream and produce an HTML string.
235///
236/// This is the main entry point for SSR rendering. It processes every opcode
237/// in the module's bytecode section, looking up strings in the string table
238/// and slot values in `slots`.
239pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
240    walk_to_html_with_limits(module, slots, WalkLimits::default())
241}
242
243/// Walk the IR opcode stream with explicit resource budgets.
244///
245/// Use this when the default 8 MiB output cap or 50,000-iteration LIST budget
246/// is wrong for the page — raising the limit is the right answer for a
247/// legitimately large list, rather than letting the render fail and silently
248/// degrade to a client-mounted page.
249pub fn walk_to_html_with_limits(
250    module: &IrModule,
251    slots: &SlotData,
252    limits: WalkLimits,
253) -> Result<String, IrError> {
254    let ops = &module.opcodes;
255    let len = ops.len();
256    let mut out = String::with_capacity(initial_capacity(len));
257    let mut state = WalkState::new(limits);
258    let mut slots_mut = slots.clone();
259    walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
260    finish_page(&mut out, &state)?;
261    Ok(out)
262}
263
264/// The output buffer's first reservation for a module's opcode stream.
265///
266/// Capped: `len * 4` is a fine estimate for a real page (measured modules are
267/// tens of bytes to low KiB) but a 4x reservation on an attacker-sized section
268/// is a free amplifier, and `len * 4` can itself overflow on wasm32 where
269/// `usize` is 32 bits. Beyond the cap, String's amortized doubling costs
270/// O(log n) reallocs.
271fn initial_capacity(opcodes_len: usize) -> usize {
272    opcodes_len.saturating_mul(4).min(1 << 20)
273}
274
275/// After a whole-page walk: the `__forma_islands` block for ScriptTag props.
276fn finish_page(out: &mut String, state: &WalkState) -> Result<(), IrError> {
277    if !state.script_tag_props.is_empty() {
278        let json =
279            serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
280        push_checked(
281            out,
282            "<script id=\"__forma_islands\" type=\"application/json\">",
283            state.max_output_bytes,
284        )?;
285        push_json_script_body(out, &json, state.max_output_bytes)?;
286        push_checked(out, "</script>", state.max_output_bytes)?;
287    }
288    Ok(())
289}
290
291/// What a walk wrote, or what it had reached when it stopped.
292#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct WalkCounts {
295    /// Elements and text nodes written from the module's opcodes. Hydration
296    /// markers, comments and the `__forma_islands` data block are not nodes.
297    pub nodes: usize,
298    /// Bytes of HTML written: what [`WalkLimits::max_output_bytes`] bounds.
299    pub bytes: usize,
300    /// LIST rows walked, across every nesting depth: what
301    /// [`WalkLimits::max_list_iterations`] bounds.
302    pub list_iterations: usize,
303}
304
305/// A whole page's HTML and what writing it took.
306#[derive(Debug, Clone, PartialEq, Eq)]
307#[non_exhaustive]
308pub struct WalkOutput {
309    /// The page's HTML, as [`walk_to_html_with_limits`] writes it.
310    pub html: String,
311    /// What the walk wrote.
312    pub counts: WalkCounts,
313}
314
315/// A walk that stopped: why, where, and how far it had got.
316///
317/// The partial HTML is dropped: nothing a failed walk wrote leaves it.
318#[derive(Debug, Clone, PartialEq, Eq)]
319#[non_exhaustive]
320pub struct WalkFailure {
321    /// Why it stopped.
322    pub error: IrError,
323    /// The component names of the islands open where it stopped, outermost
324    /// first; empty when it stopped outside every island. FMIR v2 carries no
325    /// source positions, so a component the compiler inlined into its parent
326    /// has no name here; the source-position table of FMIR v3 adds it.
327    pub component_path: Vec<String>,
328    /// What it had written by then.
329    pub counts: WalkCounts,
330}
331
332impl fmt::Display for WalkFailure {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        write!(f, "{}", self.error)?;
335        if !self.component_path.is_empty() {
336            write!(f, " (in {})", self.component_path.join(" > "))?;
337        }
338        Ok(())
339    }
340}
341
342impl std::error::Error for WalkFailure {
343    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
344        Some(&self.error)
345    }
346}
347
348/// [`walk_to_html_with_limits`], reporting what the walk wrote, and on
349/// failure where it stopped and how far it had got.
350///
351/// The same HTML, byte for byte, and the same errors, with two additions: the
352/// counts (nodes, bytes, list rows), returned on success and on failure alike
353/// so an over-budget page reports how far it got; and the component
354/// path, the islands open at the failure. A panic inside the walk is caught
355/// and returned as [`IrError::Panicked`] with the same path and counts, so
356/// one bad page cannot take its request's connection down with it.
357/// That holds only while the application unwinds on panic: under
358/// `panic = "abort"` the process ends instead.
359///
360/// Verified by: crates/forma-ir/src/walker.rs > "counted_walk_returns_the_counts_reached_when_a_budget_stops_it"
361/// Verified by: crates/forma-ir/src/walker.rs > "counted_walk_catches_a_panic_and_names_its_island"
362pub fn walk_to_html_counted(
363    module: &IrModule,
364    slots: &SlotData,
365    limits: WalkLimits,
366) -> Result<WalkOutput, WalkFailure> {
367    let ops = &module.opcodes;
368    let len = ops.len();
369    let mut out = String::with_capacity(initial_capacity(len));
370    let mut state = WalkState::new(limits);
371    #[cfg(any(test, feature = "__fault-injection"))]
372    {
373        state.fault_armed = true;
374    }
375    let mut slots_mut = slots.clone();
376
377    // The closure only borrows: after an unwind, `state` and `out` still say
378    // where the walk was and how much it had written. Nothing they hold is
379    // used again except to read those counts.
380    let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
381        walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
382        finish_page(&mut out, &state)
383    }));
384    let counts = WalkCounts {
385        nodes: state.nodes,
386        bytes: out.len(),
387        list_iterations: state.list_iterations,
388    };
389    let error = match walked {
390        Ok(Ok(())) => return Ok(WalkOutput { html: out, counts }),
391        Ok(Err(error)) => error,
392        Err(payload) => IrError::Panicked {
393            message: panic_message(payload.as_ref()),
394        },
395    };
396    let component_path = state
397        .islands
398        .iter()
399        .map(|&id| {
400            module
401                .island_entry(id)
402                .and_then(|entry| module.strings.get(entry.name_str_idx).ok())
403                .map_or_else(|| format!("island {id}"), str::to_string)
404        })
405        .collect();
406    Err(WalkFailure {
407        error,
408        component_path,
409        counts,
410    })
411}
412
413/// A panic payload's message: `panic!` with a literal gives a `&str`, with
414/// formatting a `String`.
415fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
416    if let Some(message) = payload.downcast_ref::<&str>() {
417        (*message).to_string()
418    } else if let Some(message) = payload.downcast_ref::<String>() {
419        message.clone()
420    } else {
421        "a panic with no message".to_string()
422    }
423}
424
425/// Append a JSON document as the body of a `<script type="application/json">`
426/// data block, escaped so it cannot terminate or re-open the enclosing
427/// element.
428///
429/// Escaping `<` is total: `<` cannot appear in JSON outside a string literal,
430/// and `<` is a valid JSON string escape, so `JSON.parse` yields
431/// byte-identical *values*. It simultaneously kills `</script`, `<!--` and
432/// `<script`. The previous `</` → `<\/` replace only covered the first of
433/// those: a payload containing `<!--` followed later by `<script` drives a
434/// spec-compliant tokenizer into the *script-data-double-escaped* state, in
435/// which our own trailing `</script>` no longer closes the element and the
436/// entire rest of the page is swallowed as script text.
437///
438/// `>` is escaped for symmetry and U+2028/U+2029 as defence in depth — those
439/// two are not needed for a `JSON.parse(textContent)` consumer, only if the
440/// payload is ever inlined as JS source instead of as a data block.
441///
442/// HYDRATION CONTRACT: the `__forma_islands` payload is JSON with `<`/`>`
443/// escaped as `<`/`>`. Consumers MUST decode it with `JSON.parse`
444/// (which undoes the escapes) and MUST NOT string-match the raw script text.
445fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
446    for ch in json.chars() {
447        match ch {
448            '<' => push_checked(out, "\\u003c", limit)?,
449            '>' => push_checked(out, "\\u003e", limit)?,
450            '\u{2028}' => push_checked(out, "\\u2028", limit)?,
451            '\u{2029}' => push_checked(out, "\\u2029", limit)?,
452            _ => {
453                if out.len() >= limit {
454                    return Err(IrError::OutputLimitExceeded { limit });
455                }
456                out.push(ch);
457            }
458        }
459    }
460    Ok(())
461}
462
463/// Walk a single island's content and produce an HTML string.
464///
465/// This is the fragment rendering entry point for WASM re-renders. It finds
466/// the island by `island_id` in the module's island table, seeks to its
467/// `byte_offset` in the opcode stream, and walks from after the ISLAND_START
468/// opcode until the matching ISLAND_END opcode.
469///
470/// Returns the island ROOT element carrying the full `data-forma-*` attribute
471/// set (`data-forma-island`, `-component`, `-status`, `-hydrate`, and
472/// `-props` when the island has any). Clients MUST swap it in with
473/// `replaceWith`/`outerHTML` and MUST NOT use `innerHTML` — that would discard
474/// exactly the attributes the runtime selects on.
475///
476/// The `<!--f:iN-->` / `<!--/f:iN-->` marker comments are deliberately NOT
477/// emitted: they live outside the root element and survive an `outerHTML`
478/// swap, so re-emitting them would duplicate them.
479///
480/// Props are always inlined on the root as `data-forma-props`, even for an
481/// island declared `PropsMode::ScriptTag`. A fragment must be self-contained:
482/// emitting a second `__forma_islands` block would duplicate a DOM id and,
483/// because the injected copy precedes the SSR one in document order, would
484/// shadow it for every other ScriptTag island on the page. The client already
485/// reads `data-forma-props` first, so this needs no JS change.
486pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
487    walk_island_with_limits(module, slots, island_id, WalkLimits::default())
488}
489
490/// [`walk_island`] with explicit resource budgets.
491pub fn walk_island_with_limits(
492    module: &IrModule,
493    slots: &SlotData,
494    island_id: u16,
495    limits: WalkLimits,
496) -> Result<String, IrError> {
497    // 1. Find the island entry by id
498    let entry = module
499        .island_entry(island_id)
500        .ok_or(IrError::IslandNotFound(island_id))?;
501
502    let byte_offset = entry.byte_offset as usize;
503    let ops = &module.opcodes;
504
505    // 2. Validate byte_offset is in bounds. Saturating arithmetic: byte_offset
506    //    is u32-wide and `usize` is 32 bits on wasm32.
507    if byte_offset >= ops.len() {
508        return Err(IrError::BufferTooShort {
509            expected: byte_offset.saturating_add(1),
510            actual: ops.len(),
511        });
512    }
513
514    // 3. Verify we're at an ISLAND_START opcode
515    let opcode = Opcode::from_byte(ops[byte_offset])?;
516    if opcode != Opcode::IslandStart {
517        return Err(IrError::InvalidOpcode(ops[byte_offset]));
518    }
519
520    // 4. Skip ISLAND_START opcode(1) + island_id(2) = 3 bytes
521    let content_start = byte_offset.saturating_add(3);
522    if content_start > ops.len() {
523        return Err(IrError::BufferTooShort {
524            expected: content_start,
525            actual: ops.len(),
526        });
527    }
528
529    // 5. Replicate the ISLAND_START handler: seeking past the opcode means it
530    //    never runs, so without this the island root comes back with none of
531    //    its data-forma-* attributes and would never hydrate.
532    let mut out = String::with_capacity(256);
533    let mut state = WalkState::new(limits);
534    // Applies to nested islands inside this one as well, so a ScriptTag island
535    // nested in the fragment keeps its props instead of losing them on return.
536    state.force_inline_props = true;
537    let mut slots_mut = slots.clone();
538
539    let component_name = module.strings.get(entry.name_str_idx)?.to_string();
540    let inline_props = match entry.props_mode {
541        // ScriptTag is deliberately treated as Inline here — see the doc
542        // comment: a fragment must carry its own props.
543        PropsMode::Inline | PropsMode::ScriptTag => {
544            if entry.slot_ids.is_empty() {
545                None
546            } else {
547                let props = build_island_props(module, &slots_mut, &entry.slot_ids);
548                Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
549            }
550        }
551        PropsMode::Deferred => None,
552    };
553    state.pending_island = Some(IslandPending {
554        id: island_id,
555        component_name,
556        inline_props,
557        trigger: entry.trigger,
558    });
559
560    // 6. Walk until the matching ISLAND_END, which stops the range.
561    walk_range_until_island_end(
562        module,
563        &mut slots_mut,
564        ops,
565        content_start,
566        island_id,
567        &mut out,
568        &mut state,
569        0,
570    )?;
571
572    Ok(out)
573}
574
575// ---------------------------------------------------------------------------
576// Internal: recursive sub-range walker
577// ---------------------------------------------------------------------------
578
579/// Maximum nesting depth for LIST opcodes. Prevents stack overflow from
580/// deeply nested or malicious input.
581const MAX_LIST_DEPTH: u8 = 4;
582
583/// Maximum recursion depth for control-flow opcodes (SHOW_IF, SWITCH, LIST,
584/// TRY/FALLBACK). Prevents stack overflow from deeply nested or malicious IR.
585const MAX_RECURSION_DEPTH: usize = 64;
586
587/// Controls how the walker decides when to stop processing opcodes.
588enum WalkMode {
589    /// Walk a fixed byte range `ops[start..end]`.  Used for the top-level
590    /// full-page walk **and** for all recursive sub-range calls inside
591    /// control-flow opcodes (ShowIf, Switch, List).
592    Range { end: usize },
593    /// Walk until we encounter an ISLAND_END opcode whose `island_id` matches
594    /// `target_island_id`.  Used by `walk_island()` for fragment rendering.
595    UntilIslandEnd { target_island_id: u16 },
596}
597
598/// Walk a sub-range of the opcode stream `ops[start..end]`, appending HTML to `out`.
599///
600/// This is structured as a sub-range function so that control-flow opcodes
601/// (ShowIf, List, Switch) can recurse into their body ranges.
602///
603/// `state` carries mutable walker context (list depth, output budget, etc.).
604#[allow(clippy::too_many_arguments)]
605fn walk_range(
606    module: &IrModule,
607    slots: &mut SlotData,
608    ops: &[u8],
609    start: usize,
610    end: usize,
611    out: &mut String,
612    state: &mut WalkState,
613    depth: usize,
614) -> Result<(), IrError> {
615    walk_range_impl(
616        module,
617        slots,
618        ops,
619        start,
620        WalkMode::Range { end },
621        out,
622        state,
623        depth,
624    )
625}
626
627/// Walk the opcode stream starting at `start` until we encounter an ISLAND_END
628/// with the matching `target_island_id`. Appends HTML output to `out`.
629///
630/// This is used by `walk_island()` to render just one island's content.
631/// It shares the same opcode interpretation as `walk_range` but has a different
632/// termination condition: it stops at ISLAND_END instead of a fixed end position.
633#[allow(clippy::too_many_arguments)]
634fn walk_range_until_island_end(
635    module: &IrModule,
636    slots: &mut SlotData,
637    ops: &[u8],
638    start: usize,
639    target_island_id: u16,
640    out: &mut String,
641    state: &mut WalkState,
642    depth: usize,
643) -> Result<(), IrError> {
644    walk_range_impl(
645        module,
646        slots,
647        ops,
648        start,
649        WalkMode::UntilIslandEnd { target_island_id },
650        out,
651        state,
652        depth,
653    )
654}
655
656/// Shared implementation for both `walk_range` and `walk_range_until_island_end`.
657///
658/// The `mode` parameter controls the termination condition:
659/// - `WalkMode::Range { end }` — stop when `pos >= end` (with bounds check)
660/// - `WalkMode::UntilIslandEnd { target_island_id }` — stop when we hit an
661///   ISLAND_END opcode matching the target id
662///
663/// All opcode handling is identical regardless of mode. The only differences
664/// are:
665/// 1. The loop termination condition
666/// 2. How ISLAND_END is handled (in Range mode it emits the comment marker
667///    and continues; in UntilIslandEnd mode a match causes an early return)
668/// 3. What happens when the loop ends without an explicit stop (Range returns
669///    Ok, UntilIslandEnd returns an error)
670#[allow(clippy::too_many_arguments)]
671fn walk_range_impl(
672    module: &IrModule,
673    slots: &mut SlotData,
674    ops: &[u8],
675    start: usize,
676    mode: WalkMode,
677    out: &mut String,
678    state: &mut WalkState,
679    depth: usize,
680) -> Result<(), IrError> {
681    if depth > MAX_RECURSION_DEPTH {
682        return Err(IrError::RecursionLimitExceeded);
683    }
684
685    let strings = &module.strings;
686    let mut pos = start;
687    let limit = state.max_output_bytes;
688    // Range-local, not shared on WalkState: a TRY_START and its FALLBACK are
689    // emitted in the same opcode range by construction, so a FALLBACK reached
690    // inside a nested range must never pop an entry pushed by an outer
691    // TRY_START and skip that many bytes of the wrong region. A stray FALLBACK
692    // then finds an empty stack and is a no-op, mirroring how a stray
693    // SHOW_ELSE is already tolerated. This also removes the unbounded growth
694    // when a TRY_START is walked but its FALLBACK never is.
695    let mut fallback_stack: Vec<usize> = Vec::new();
696
697    // The loop bound depends on the mode:
698    // - Range: stop at `end`, but also guard against `pos >= ops.len()`
699    // - UntilIslandEnd: stop at `ops.len()` (or earlier via ISLAND_END match)
700    let loop_bound = match mode {
701        WalkMode::Range { end } => end,
702        WalkMode::UntilIslandEnd { .. } => usize::MAX, // guarded by ops.len() check below
703    };
704
705    // Upper bound every derived offset (branch/body ends) must respect. In
706    // Range mode that is the enclosing region; on the island path there is no
707    // `end`, so the opcode stream itself is the bound.
708    let region_limit = match mode {
709        WalkMode::Range { end } => end.min(ops.len()),
710        WalkMode::UntilIslandEnd { .. } => ops.len(),
711    };
712
713    while pos < loop_bound {
714        // Cheap redundancy: every append also goes through a checked push, so
715        // a single opcode cannot blow past the budget between iterations.
716        if out.len() > limit {
717            return Err(IrError::OutputLimitExceeded { limit });
718        }
719
720        // In Range mode, `end` could exceed ops.len()` for a malformed stream.
721        // In UntilIslandEnd mode, loop_bound is MAX so we always need this check.
722        if pos >= ops.len() {
723            match mode {
724                WalkMode::Range { .. } => {
725                    return Err(IrError::BufferTooShort {
726                        expected: pos + 1,
727                        actual: ops.len(),
728                    });
729                }
730                WalkMode::UntilIslandEnd { .. } => break, // fall through to IslandNotFound error
731            }
732        }
733
734        let opcode = Opcode::from_byte(ops[pos])?;
735        pos += 1; // advance past the opcode byte
736
737        // Flush pending tag close before any opcode that is NOT DYN_ATTR.
738        // DYN_ATTR needs the tag still open so it can append attributes.
739        if state.pending_tag_close && opcode != Opcode::DynAttr {
740            close_start_tag(out, state, limit)?;
741        }
742
743        match opcode {
744            // ----- Fully implemented opcodes -----
745            Opcode::OpenTag | Opcode::VoidTag => {
746                let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
747                let tag = strings.get(tag_str_idx)?;
748                let has_content = opcode == Opcode::OpenTag;
749                emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
750                state.nodes += 1;
751                pos = new_pos;
752            }
753
754            Opcode::CloseTag => {
755                let str_idx = read_u32(ops, pos)?;
756                pos += 4;
757                let tag = strings.get(str_idx)?;
758                // Names are written to the output stream unescaped, so an
759                // IR-authored name is markup injection unless it is validated.
760                if !is_valid_name(tag) {
761                    return Err(IrError::InvalidName(tag.to_string()));
762                }
763                leave_element(out, tag, state, limit)?;
764                push_checked(out, "</", limit)?;
765                push_checked(out, tag, limit)?;
766                push_checked(out, ">", limit)?;
767            }
768
769            Opcode::Text => {
770                let str_idx = read_u32(ops, pos)?;
771                pos += 4;
772                let text = strings.get(str_idx)?;
773                if let Some(parent) = state.text_only.as_mut() {
774                    if !parent.value_bound {
775                        write_text_only(out, parent, text, limit)?;
776                    }
777                } else {
778                    push_escaped_html(out, text, limit)?;
779                    if let Some(option) = state.option_by_text.as_mut() {
780                        option.text.push_str(text);
781                    }
782                }
783                state.nodes += 1;
784            }
785
786            Opcode::DynText => {
787                // slot_id(u16) + marker_id(u16)
788                let slot_id = read_u16(ops, pos)?;
789                pos += 2;
790                let marker_id = read_u16(ops, pos)?;
791                pos += 2;
792
793                check_slot_source(module, slot_id, slots.get(slot_id));
794                let value = slots.get(slot_id).to_text();
795
796                // SR44's stand-in for a component that panics: this crate's
797                // tests and the internal `__fault-injection` feature only, and
798                // armed only inside `walk_to_html_counted`, which catches it.
799                #[cfg(any(test, feature = "__fault-injection"))]
800                #[allow(clippy::panic)]
801                if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
802                    panic!("stand-in component panicked (FAULT_TRIGGER)");
803                }
804
805                if let Some(parent) = state.text_only.as_mut() {
806                    // Inside a text-only element a marker comment is literal
807                    // text, so the value is written on its own (TextOnlyTag).
808                    if !parent.value_bound {
809                        write_text_only(out, parent, &value, limit)?;
810                    }
811                } else {
812                    push_checked(out, "<!--f:t", limit)?;
813                    push_u16(out, marker_id, limit)?;
814                    push_checked(out, "-->", limit)?;
815                    // Always emit at least a zero-width space so the browser
816                    // creates a Text node between the markers. Without this,
817                    // <!--f:t0--><!--/f:t0--> produces no Text node in the DOM
818                    // and the hydration walker has nothing to bind the
819                    // reactive effect to.
820                    let written = if value.is_empty() {
821                        "\u{200B}"
822                    } else {
823                        value.as_str()
824                    };
825                    push_escaped_html(out, written, limit)?;
826                    push_checked(out, "<!--/f:t", limit)?;
827                    push_u16(out, marker_id, limit)?;
828                    push_checked(out, "-->", limit)?;
829                    if let Some(option) = state.option_by_text.as_mut() {
830                        option.text.push_str(written);
831                    }
832                }
833                state.nodes += 1;
834            }
835
836            Opcode::DynAttr => {
837                // attr_str_idx(u32) + slot_id(u16) = 6 bytes
838                let attr_str_idx = read_u32(ops, pos)?;
839                let slot_id = read_u16(ops, pos + 4)?;
840                pos += 6;
841                check_slot_source(module, slot_id, slots.get(slot_id));
842
843                if state.pending_tag_close {
844                    let attr_name = strings.get(attr_str_idx)?;
845                    // `value` on a textarea or select, and `selected` on an
846                    // option of a bound select, are written another way.
847                    if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
848                        continue;
849                    }
850                    // `text` outlives the borrow only for the Valued case; it
851                    // is declared here so the &str can point into it.
852                    let text;
853                    let value = match slots.get(slot_id) {
854                        // An HTML boolean attribute is ON whenever it is
855                        // present, so the old `disabled="false"` meant
856                        // DISABLED. Absent for false/null, bare for true —
857                        // byte-for-byte what `renderAttr` in @getforma/core
858                        // produces for the same value.
859                        SlotValue::Bool(false) | SlotValue::Null => None,
860                        SlotValue::Bool(true) => Some(AttrValue::Bare),
861                        other => {
862                            text = other.to_text();
863                            // An empty string is a value, not an absence:
864                            // `renderAttr` emits `value=""`, and dropping the
865                            // attribute instead is a hydration mismatch on the
866                            // common `value=""` / `alt=""` case.
867                            Some(AttrValue::Valued(text.as_str()))
868                        }
869                    };
870                    if let Some(value) = value {
871                        // `pending_tag_name` is the element this attribute is
872                        // being written to — it decides whether a
873                        // data:image/svg+xml value is in an image context.
874                        let tag = std::mem::take(&mut state.pending_tag_name);
875                        emit_attr(out, &tag, attr_name, value, limit)?;
876                        state.pending_tag_name = tag;
877                    }
878                }
879                // If not inside a tag opening (shouldn't happen in well-formed IR), skip silently.
880            }
881
882            Opcode::IslandStart => {
883                // island_id(u16)
884                let island_id = read_u16(ops, pos)?;
885                pos += 2;
886
887                // Only one element can carry a `data-forma-island` set. If an
888                // island's body starts another island before opening a tag of
889                // its own, the outer island's attributes are silently
890                // discarded and it never hydrates, never disposes, and cannot
891                // be found by `activateIslands`. Fail the render instead: the
892                // compiler owes every island a shell element.
893                if let Some(outer) = &state.pending_island {
894                    return Err(IrError::NestedIslandRoot {
895                        outer: outer.id,
896                        inner: island_id,
897                    });
898                }
899                state.islands.push(island_id);
900
901                // No marker inside a text-only element, where it is text.
902                if state.text_only.is_none() {
903                    push_checked(out, "<!--f:i", limit)?;
904                    push_u16(out, island_id, limit)?;
905                    push_checked(out, "-->", limit)?;
906                }
907
908                // Look up island entry from the island table (O(1) index).
909                // `IrModule::validate` rejects an ISLAND_START naming an
910                // island the table does not declare, so the `else` below is
911                // reachable only for a module that skipped validation.
912                // Verified by: crates/forma-ir/src/parser.rs > "validate_rejects_island_start_missing_from_table"
913                if let Some(entry) = module.island_entry(island_id) {
914                    let component_name = strings.get(entry.name_str_idx)?.to_string();
915
916                    // Build props JSON from the island's slot_ids
917                    let inline_props = match entry.props_mode {
918                        PropsMode::Inline => {
919                            if entry.slot_ids.is_empty() {
920                                None
921                            } else {
922                                let props = build_island_props(module, slots, &entry.slot_ids);
923                                let json = serde_json::to_string(&props)
924                                    .unwrap_or_else(|_| "{}".to_string());
925                                if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
926                                {
927                                    // Spill: the payload goes into the shared
928                                    // block instead of a multi-kilobyte
929                                    // attribute. Exactly one channel is used,
930                                    // so the client's inline-wins precedence
931                                    // never has two answers to choose between.
932                                    state
933                                        .script_tag_props
934                                        .insert(island_id, serde_json::Value::Object(props));
935                                    None
936                                } else {
937                                    Some(json)
938                                }
939                            }
940                        }
941                        PropsMode::ScriptTag if state.force_inline_props => {
942                            // Fragment render: keep this island self-contained
943                            // rather than pushing into a shared block that the
944                            // fragment cannot legally emit.
945                            if entry.slot_ids.is_empty() {
946                                None
947                            } else {
948                                let props = build_island_props(module, slots, &entry.slot_ids);
949                                Some(
950                                    serde_json::to_string(&props)
951                                        .unwrap_or_else(|_| "{}".to_string()),
952                                )
953                            }
954                        }
955                        PropsMode::ScriptTag => {
956                            // Accumulate for script tag emission after walk
957                            if !entry.slot_ids.is_empty() {
958                                let props = build_island_props(module, slots, &entry.slot_ids);
959                                state
960                                    .script_tag_props
961                                    .insert(island_id, serde_json::Value::Object(props));
962                            }
963                            None
964                        }
965                        PropsMode::Deferred => None,
966                    };
967
968                    state.pending_island = Some(IslandPending {
969                        id: island_id,
970                        component_name,
971                        inline_props,
972                        trigger: entry.trigger,
973                    });
974                } else {
975                    #[cfg(feature = "tracing")]
976                    tracing::warn!(
977                        island_id = island_id,
978                        "ISLAND_START names an island that is not in the island table — \
979                         markers emitted but no data-forma-* attributes, so it will not hydrate \
980                         (module was not validated)"
981                    );
982                }
983            }
984
985            Opcode::IslandEnd => {
986                // island_id(u16)
987                let island_id = read_u16(ops, pos)?;
988                pos += 2;
989                if state.islands.last() == Some(&island_id) {
990                    state.islands.pop();
991                }
992
993                // The island is over: anything still pending belongs to no
994                // element inside it. Without this, an ISLAND_START not
995                // followed by a tag leaks its identity attributes onto
996                // whatever element comes next — possibly one emitted after the
997                // island's own closing marker.
998                if state.pending_island.take().is_some() {
999                    #[cfg(feature = "tracing")]
1000                    tracing::warn!(
1001                        island_id = island_id,
1002                        "ISLAND_START was not followed by an element — island attributes dropped, \
1003                         this island will not hydrate (compiler should emit a shell element)"
1004                    );
1005                }
1006
1007                // In UntilIslandEnd mode, a matching island_id means we are done.
1008                if let WalkMode::UntilIslandEnd { target_island_id } = mode {
1009                    if island_id == target_island_id {
1010                        // Flush any pending tag close before returning.
1011                        if state.pending_tag_close {
1012                            close_start_tag(out, state, limit)?;
1013                        }
1014                        return Ok(());
1015                    }
1016                }
1017
1018                // Either Range mode, or a nested (non-matching) island end —
1019                // emit the closing marker comment and continue (no marker
1020                // inside a text-only element, where it is text).
1021                if state.text_only.is_none() {
1022                    push_checked(out, "<!--/f:i", limit)?;
1023                    push_u16(out, island_id, limit)?;
1024                    push_checked(out, "-->", limit)?;
1025                }
1026            }
1027
1028            Opcode::Comment => {
1029                // str_idx(u32)
1030                let str_idx = read_u32(ops, pos)?;
1031                pos += 4;
1032                let text = strings.get(str_idx)?;
1033                push_checked(out, "<!--", limit)?;
1034                // A leading ">" or "->" closes an HTML comment immediately per
1035                // the tokenizer, spilling the rest of the text into the
1036                // document as markup. A single leading space makes that
1037                // impossible and cannot collide with the hydration markers,
1038                // which are emitted directly and never through this opcode.
1039                if text.starts_with('>') || text.starts_with("->") {
1040                    push_checked(out, " ", limit)?;
1041                }
1042                push_checked(out, &text.replace("--", "&#45;&#45;"), limit)?;
1043                push_checked(out, "-->", limit)?;
1044            }
1045
1046            // ----- Control-flow opcodes (recurse via walk_range) -----
1047            Opcode::ShowIf => {
1048                // slot_id(2) + then_len(4) + else_len(4) = 10 bytes header
1049                let slot_id = read_u16(ops, pos)?;
1050                let then_len = read_u32(ops, pos + 2)? as usize;
1051                let else_len = read_u32(ops, pos + 6)? as usize;
1052                pos += 10;
1053
1054                check_slot_source(module, slot_id, slots.get(slot_id));
1055                let condition = slots.get(slot_id).as_bool();
1056                let then_start = pos;
1057                // Checked, and bounded by the enclosing region: a corrupt
1058                // length would otherwise silently relocate content across the
1059                // hydration markers, and plain `+` can wrap on wasm32.
1060                let then_end = bounded_offset(pos, then_len, region_limit)?;
1061                // The SHOW_ELSE marker byte must itself be inside the region
1062                // (`<` not `<=`), or a SHOW_IF at the tail of a sub-range would
1063                // read a byte its parent never granted.
1064                if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
1065                    return Err(IrError::InvalidSection);
1066                }
1067                let else_start = then_end + 1; // skip SHOW_ELSE byte
1068                let else_end = bounded_offset(else_start, else_len, region_limit)?;
1069
1070                // Emit conditional marker — none inside a text-only element,
1071                // where it is text. Decided once, so the pair always matches.
1072                let markers = state.text_only.is_none();
1073                if markers {
1074                    push_checked(out, "<!--f:s", limit)?;
1075                    push_u16(out, slot_id, limit)?;
1076                    push_checked(out, "-->", limit)?;
1077                }
1078
1079                if condition {
1080                    walk_range(
1081                        module,
1082                        slots,
1083                        ops,
1084                        then_start,
1085                        then_end,
1086                        out,
1087                        state,
1088                        depth + 1,
1089                    )?;
1090                } else {
1091                    walk_range(
1092                        module,
1093                        slots,
1094                        ops,
1095                        else_start,
1096                        else_end,
1097                        out,
1098                        state,
1099                        depth + 1,
1100                    )?;
1101                }
1102
1103                if markers {
1104                    push_checked(out, "<!--/f:s", limit)?;
1105                    push_u16(out, slot_id, limit)?;
1106                    push_checked(out, "-->", limit)?;
1107                }
1108
1109                // Advance pos past both branches + SHOW_ELSE marker byte
1110                pos = else_end;
1111            }
1112
1113            Opcode::ShowElse => {
1114                // SHOW_ELSE should never be reached during normal walking — it is
1115                // skipped over by the SHOW_IF handler. If we reach it, the opcode
1116                // stream is malformed, but we tolerate it gracefully by doing nothing.
1117            }
1118
1119            // LIMITATION, not an oversight: SWITCH emits no `<!--f:…-->`
1120            // marker pair, because the hydration wire contract has no switch
1121            // marker — it defines exactly `f:tN` (text), `f:sN` (conditional),
1122            // `f:lN` (list) and `f:iN` (island), and those bytes are shared
1123            // with @getforma/core and ksx Studio. A switched region is
1124            // therefore server-rendered only: the client cannot locate it to
1125            // re-render when the slot changes. Adding one is a coordinated
1126            // change to both renderers and the client walker, not a
1127            // walker-local fix. SWITCH also has no default case in the opcode
1128            // layout; a non-matching value renders nothing.
1129            Opcode::Switch => {
1130                let slot_id = read_u16(ops, pos)?;
1131                let case_count = read_u16(ops, pos + 2)? as usize;
1132                pos += 4;
1133
1134                // Read case headers: [(val_str_idx, body_len)]
1135                // Bound the reservation by the bytes actually available — a
1136                // valid stream still gets a single exact allocation.
1137                let mut cases =
1138                    Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
1139                for _ in 0..case_count {
1140                    let val_str_idx = read_u32(ops, pos)?;
1141                    let body_len = read_u32(ops, pos + 4)? as usize;
1142                    cases.push((val_str_idx, body_len));
1143                    pos += 8;
1144                }
1145
1146                // Now pos points to start of first case body
1147                check_slot_source(module, slot_id, slots.get(slot_id));
1148                let slot_text = slots.get(slot_id).to_text();
1149                let mut body_pos = pos;
1150                // SWITCH is first-match-wins, like the `switch` it compiles
1151                // from. Without this, a module with two cases carrying the
1152                // same value rendered BOTH bodies concatenated.
1153                let mut matched = false;
1154
1155                for (val_str_idx, body_len) in &cases {
1156                    // Hoisted above the match test so a non-matching case is
1157                    // validated too — otherwise a bad length in an unselected
1158                    // case still corrupts `body_pos` for its siblings.
1159                    let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
1160                    let case_val = strings.get(*val_str_idx)?;
1161                    if !matched && case_val == slot_text {
1162                        matched = true;
1163                        walk_range(
1164                            module,
1165                            slots,
1166                            ops,
1167                            body_pos,
1168                            case_end,
1169                            out,
1170                            state,
1171                            depth + 1,
1172                        )?;
1173                    }
1174                    body_pos = case_end;
1175                }
1176
1177                pos = body_pos; // past all case bodies
1178            }
1179
1180            Opcode::List => {
1181                // slot_id(2) + item_slot_id(2) + body_len(4) = 8 bytes header
1182                let slot_id = read_u16(ops, pos)?;
1183                let item_slot_id = read_u16(ops, pos + 2)?;
1184                let body_len = read_u32(ops, pos + 4)? as usize;
1185                pos += 8;
1186
1187                check_slot_source(module, slot_id, slots.get(slot_id));
1188
1189                let body_start = pos;
1190                let body_end = bounded_offset(pos, body_len, region_limit)?;
1191
1192                // Emit list markers — none inside a text-only element, where
1193                // they are text. Decided once, so the pair always matches.
1194                let list_marker_id = slot_id;
1195                let markers = state.text_only.is_none();
1196                if markers {
1197                    push_checked(out, "<!--f:l", limit)?;
1198                    push_u16(out, list_marker_id, limit)?;
1199                    push_checked(out, "-->", limit)?;
1200                }
1201
1202                // Take a cheap handle on the array to release the borrow on
1203                // `slots` before the loop mutates it. The payload is
1204                // Arc-backed, so this is a refcount bump — and holding it
1205                // means a body that writes to the list's own slot cannot
1206                // change what we are iterating.
1207                let items = match slots.get(slot_id) {
1208                    SlotValue::Array(a) => Some(a.clone()),
1209                    _ => None,
1210                };
1211
1212                if let Some(items) = items.filter(|a| !a.is_empty()) {
1213                    state.list_depth += 1;
1214                    if state.list_depth > MAX_LIST_DEPTH {
1215                        return Err(IrError::ListDepthExceeded {
1216                            max: MAX_LIST_DEPTH,
1217                        });
1218                    }
1219                    // A list longer than what is left of the budget fails
1220                    // here, before its first row, instead of walking up to
1221                    // the budget and failing there (DR-13, SR38): a hostile
1222                    // 60,000-row list costs a comparison, not 50,000 rows.
1223                    // It fails exactly when the per-row check below would
1224                    // have, sooner, and before any error the rows would have
1225                    // raised first (an output budget, a malformed row). That
1226                    // check stays: rows of nested lists can spend the budget
1227                    // after this list was let in.
1228                    //
1229                    // Verified by: crates/forma-ir/src/walker.rs > "a_list_longer_than_the_remaining_budget_fails_before_its_first_row"
1230                    // Verified by: crates/forma-ir/src/walker.rs > "a_list_exactly_at_the_remaining_budget_still_renders"
1231                    let remaining = state
1232                        .max_list_iterations
1233                        .saturating_sub(state.list_iterations);
1234                    if items.len() > remaining {
1235                        return Err(IrError::ListBudgetExceeded {
1236                            max: state.max_list_iterations,
1237                        });
1238                    }
1239                    for item in items.iter() {
1240                        // Total across all depths, never decremented: depth
1241                        // alone does not bound volume, and four nested LISTs
1242                        // over the same array give n^4 body walks.
1243                        //
1244                        // Checked before counting, so at the error the count
1245                        // is the rows actually walked (`WalkCounts`).
1246                        if state.list_iterations >= state.max_list_iterations {
1247                            return Err(IrError::ListBudgetExceeded {
1248                                max: state.max_list_iterations,
1249                            });
1250                        }
1251                        state.list_iterations += 1;
1252                        // Scoped binding instead of a per-row `SlotData`
1253                        // clone: swap the item in, walk, swap back. Same
1254                        // isolation between rows, without copying every other
1255                        // slot on the page once per row.
1256                        let mark = state.slot_journal.len();
1257                        let previous = slots.replace(item_slot_id, item.clone());
1258                        state.slot_journal.push((item_slot_id, previous));
1259
1260                        let walked = walk_range(
1261                            module,
1262                            slots,
1263                            ops,
1264                            body_start,
1265                            body_end,
1266                            out,
1267                            state,
1268                            depth + 1,
1269                        );
1270                        // Unwind before propagating: an aborted walk must
1271                        // still hand `slots` back exactly as it was, because
1272                        // an enclosing row is about to unwind on top of it.
1273                        state.unwind_slot_journal(slots, mark);
1274                        walked?;
1275
1276                        // A LIST_ITEM_KEY that never met an element belongs to
1277                        // this item only — do not let it leak onto whatever
1278                        // element the next item (or the code after the list)
1279                        // happens to open.
1280                        state.pending_list_key = None;
1281                    }
1282                    state.list_depth -= 1;
1283                }
1284                state.pending_list_key = None;
1285
1286                if markers {
1287                    push_checked(out, "<!--/f:l", limit)?;
1288                    push_u16(out, list_marker_id, limit)?;
1289                    push_checked(out, "-->", limit)?;
1290                }
1291
1292                pos = body_end;
1293            }
1294
1295            Opcode::TryStart => {
1296                // fallback_len(4) = 4 bytes header
1297                let fallback_len = read_u32(ops, pos)? as usize;
1298                pos += 4;
1299                // Push fallback_len so FALLBACK knows how many bytes to skip.
1300                // Main body follows inline and is walked by the outer loop.
1301                fallback_stack.push(fallback_len);
1302            }
1303
1304            Opcode::Fallback => {
1305                // Phase 2: always render main body, skip fallback.
1306                // Pop the fallback_len pushed by the preceding TRY_START.
1307                if let Some(skip) = fallback_stack.pop() {
1308                    pos = bounded_offset(pos, skip, region_limit)?;
1309                }
1310            }
1311
1312            Opcode::Preload => {
1313                if pos >= ops.len() {
1314                    return Err(IrError::BufferTooShort {
1315                        expected: pos + 1,
1316                        actual: ops.len(),
1317                    });
1318                }
1319                let resource_type = ops[pos];
1320                let url_str_idx = read_u32(ops, pos + 1)?;
1321                let url = strings.get(url_str_idx)?;
1322                pos += 5;
1323
1324                let (as_val, type_attr) = match resource_type {
1325                    1 => ("font", " type=\"font/woff2\" crossorigin"),
1326                    2 => ("style", ""),
1327                    3 => ("script", ""),
1328                    4 => ("image", ""),
1329                    _ => ("fetch", ""),
1330                };
1331                // NOTE: this tag carries no CSP nonce. Under a
1332                // `script-src 'strict-dynamic'` policy an `as="script"`
1333                // preload hint is dropped by the browser (a degradation, not a
1334                // breakage — the real module tag is nonced). Threading a nonce
1335                // in here would change shared emitted HTML and needs the
1336                // matching JS-walker change; the compiler emits no PRELOAD
1337                // opcode today, so it is latent.
1338                push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
1339                push_escaped_attr(out, url, limit)?;
1340                push_checked(out, "\" as=\"", limit)?;
1341                push_checked(out, as_val, limit)?;
1342                push_checked(out, "\"", limit)?;
1343                push_checked(out, type_attr, limit)?;
1344                push_checked(out, ">\n", limit)?;
1345            }
1346
1347            Opcode::ListItemKey => {
1348                // key_str_idx(u32) — 4 bytes (simplified format, no source byte)
1349                let key_str_idx = read_u32(ops, pos)?;
1350                pos += 4;
1351                let key_val = strings.get(key_str_idx)?;
1352                state.pending_list_key = Some(key_val.to_string());
1353            }
1354
1355            Opcode::Prop => {
1356                // src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16) = 8 bytes
1357                let src_slot_id = read_u16(ops, pos)?;
1358                let prop_str_idx = read_u32(ops, pos + 2)?;
1359                let target_slot_id = read_u16(ops, pos + 6)?;
1360                pos += 8;
1361
1362                let prop_name = strings.get(prop_str_idx)?;
1363                let value = slots.get(src_slot_id).get_property(prop_name);
1364                // Mutate `slots` directly and journal the displaced value, so
1365                // the enclosing LIST row restores it when the row ends. A PROP
1366                // outside any list writes for the rest of the page, which is
1367                // what it did when each row got its own clone.
1368                let previous = slots.replace(target_slot_id, value);
1369                state.journal_write(target_slot_id, previous);
1370            }
1371        }
1372    }
1373
1374    // Post-loop handling depends on the mode:
1375    match mode {
1376        WalkMode::Range { .. } => {
1377            // Flush any pending tag close at the end of the opcode range.
1378            //
1379            // NOTE: `pending_island` / `pending_list_key` are deliberately NOT
1380            // cleared here. This function runs for every nested control-flow
1381            // body with the same state, so clearing at range end would wipe
1382            // the island attributes of a legitimate
1383            // ISLAND_START → SHOW_IF → OPEN_TAG stream (the island root would
1384            // silently never hydrate). They are cleared at their owning
1385            // boundaries instead: ISLAND_END and the LIST item loop.
1386            if state.pending_tag_close {
1387                close_start_tag(out, state, limit)?;
1388            }
1389            Ok(())
1390        }
1391        WalkMode::UntilIslandEnd { target_island_id } => {
1392            // If we reach the end of the opcode stream without finding ISLAND_END,
1393            // that means the IR is malformed. Return an error.
1394            Err(IrError::IslandNotFound(target_island_id))
1395        }
1396    }
1397}
1398
1399// ---------------------------------------------------------------------------
1400// Island props helpers
1401// ---------------------------------------------------------------------------
1402
1403/// Build a JSON object from the island's slot_ids, looking up names from
1404/// the slot table and values from the current SlotData.
1405///
1406/// DUPLICATE-NAME RULE: two of the island's slots can carry the same name (the
1407/// compiler derives some names per-element, not per-module). The first one in
1408/// `slot_ids` order wins, matching [`IrModule::slot_id_by_name`] and
1409/// [`SlotData::from_json`]; a plain `insert` made the LAST one win here, so the
1410/// island's props disagreed with every other name lookup in the crate and one
1411/// of the two values vanished with no diagnostic.
1412///
1413/// Verified by: crates/forma-ir/src/walker.rs > "island_props_keep_the_first_slot_for_a_duplicated_name"
1414fn build_island_props(
1415    module: &IrModule,
1416    slots: &SlotData,
1417    slot_ids: &[u16],
1418) -> serde_json::Map<String, serde_json::Value> {
1419    let mut map = serde_json::Map::new();
1420    for &slot_id in slot_ids {
1421        // Look up slot name from the slot table (O(1) index)
1422        if let Some(entry) = module.slot_entry(slot_id) {
1423            if let Ok(name) = module.strings.get(entry.name_str_idx) {
1424                if map.contains_key(name) {
1425                    #[cfg(feature = "tracing")]
1426                    tracing::warn!(
1427                        slot_id = slot_id,
1428                        name = name,
1429                        "Island declares two slots with the same name — keeping the first, \
1430                         the later value is not reachable from the props object"
1431                    );
1432                    continue;
1433                }
1434                let value = slots.get(slot_id).to_json();
1435                map.insert(name.to_string(), value);
1436            }
1437        } else {
1438            // `IrModule::validate` rejects this at parse time; keep the branch
1439            // observable in case a module reaches the walker unvalidated,
1440            // since a silently dropped prop means the island's SSR props
1441            // disagree with what the client expects.
1442            #[cfg(feature = "tracing")]
1443            tracing::warn!(
1444                slot_id = slot_id,
1445                "Island references a slot id that is not in the slot table — prop dropped"
1446            );
1447        }
1448    }
1449    map
1450}
1451
1452// ---------------------------------------------------------------------------
1453// Offset arithmetic
1454// ---------------------------------------------------------------------------
1455
1456/// `base + len`, rejected if it overflows or leaves the enclosing region.
1457///
1458/// Plain `+` on `usize` cannot wrap on 64-bit but can on wasm32, where
1459/// `usize` is 32 bits — and this crate ships a WASM build whose `render()` /
1460/// `render_island()` entry points take arbitrary bytes fetched at runtime.
1461fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1462    base.checked_add(len)
1463        .filter(|e| *e <= limit)
1464        .ok_or(IrError::InvalidSection)
1465}
1466
1467// ---------------------------------------------------------------------------
1468// Name and URL safety
1469// ---------------------------------------------------------------------------
1470
1471/// True if `name` is safe to emit verbatim as a tag or attribute name.
1472///
1473/// Names are written to the output stream unescaped (only *values* go through
1474/// the escapers), so a string-table entry used as a tag name — `div onload=x`
1475/// — or as an attribute key — `x"><script` — is direct markup injection. The
1476/// accepted class is `[A-Za-z_:][-A-Za-z0-9_:.]*`, byte-identical to
1477/// formajs's `isSafeAttrName`/`isSafeTagName`, and covers every valid HTML,
1478/// SVG and custom-element name (`data-*`, `aria-*`, `xlink:href`, `viewBox`,
1479/// dotted component names).
1480fn is_valid_name(name: &str) -> bool {
1481    let mut chars = name.chars();
1482    match chars.next() {
1483        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1484        _ => return false,
1485    }
1486    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1487}
1488
1489/// True for any `on…` event-handler attribute name (case-insensitive).
1490fn is_event_handler_attr(name: &str) -> bool {
1491    name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1492}
1493
1494/// Attributes whose value the browser resolves as a URL.
1495///
1496/// Mirrors formajs's `URL_ATTRS` exactly. `srcset` is deliberately absent (a
1497/// comma-separated candidate list that a single scheme test would misparse,
1498/// and always fetched in image mode) and so is `cite`; adding either would
1499/// make the Rust walker stricter than the JS renderer, which is itself a
1500/// hydration divergence.
1501const URL_ATTRS: [&str; 8] = [
1502    "href",
1503    "src",
1504    "action",
1505    "formaction",
1506    "xlink:href",
1507    "poster",
1508    "background",
1509    "data",
1510];
1511
1512/// Tags whose URL-bearing attributes are always fetched as an image or media
1513/// resource and never parsed as a document — the only places a
1514/// `data:image/svg+xml` value is safe. Mirrors formajs's `IMAGE_CONTEXT_TAGS`.
1515const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1516    "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1517    "td", "th",
1518];
1519
1520/// True if `name` is a URL-bearing attribute (case-insensitive).
1521fn is_url_attr(name: &str) -> bool {
1522    URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1523}
1524
1525/// True if writing `value` to a URL attribute on `<tag>` would create an XSS
1526/// sink.
1527///
1528/// Browsers strip ASCII whitespace and C0/C1 control characters out of a URL
1529/// before resolving its scheme, so `java\tscript:alert(1)` executes even
1530/// though a naive prefix test does not match it. The value is normalized the
1531/// same way before testing — byte-identical to formajs's `isDangerousUrl`, so
1532/// the server emits exactly what the client would have produced.
1533fn is_dangerous_url(value: &str, tag: &str) -> bool {
1534    let normalized: String = value
1535        .chars()
1536        .filter(|c| {
1537            let n = *c as u32;
1538            !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1539        })
1540        .collect();
1541    let lower = normalized.to_ascii_lowercase();
1542
1543    if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1544        return true;
1545    }
1546    if lower.starts_with("data:text/html") {
1547        return true;
1548    }
1549    // `data:image/svg+xml` is a *conditional* hazard: through an image sink it
1550    // decodes in image mode where script never runs; through a document sink
1551    // an `onload=` inside it fires. It is not blanket-blocked because the
1552    // stock CSP explicitly permits `img-src data:`.
1553    if lower.starts_with("data:image/svg+xml") {
1554        return !IMAGE_CONTEXT_TAGS
1555            .iter()
1556            .any(|t| tag.eq_ignore_ascii_case(t));
1557    }
1558    false
1559}
1560
1561/// How an attribute's value is written out.
1562///
1563/// The distinction exists because `key` and `key=""` are the same DOM but
1564/// `key` and `key="false"` are not: an HTML boolean attribute is ON whenever
1565/// it is present, whatever its value, so `disabled="false"` disables the
1566/// control. See [`AttrValue::from_slot`].
1567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1568enum AttrValue<'a> {
1569    /// Write the bare name, no `="…"`.
1570    Bare,
1571    /// Write `key="escaped"`, including when `value` is empty.
1572    Valued(&'a str),
1573}
1574
1575/// Emit one attribute, dropping it entirely if it is unsafe.
1576///
1577/// Dropping (rather than emitting an empty value) is what the client does:
1578/// `renderAttr` returns null and hydration calls `removeAttribute`. An
1579/// SSR-emitted `href=""` resolves to the current page — a real navigation —
1580/// and would not match the client DOM.
1581fn emit_attr(
1582    out: &mut String,
1583    tag: &str,
1584    key: &str,
1585    val: AttrValue<'_>,
1586    limit: usize,
1587) -> Result<(), IrError> {
1588    if !is_valid_name(key) {
1589        // Skip silently (matching the walker's existing convention for
1590        // malformed attribute operands) rather than failing the whole page
1591        // for one bad key.
1592        #[cfg(feature = "tracing")]
1593        tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1594        return Ok(());
1595    }
1596    if is_event_handler_attr(key) {
1597        #[cfg(feature = "tracing")]
1598        tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1599        return Ok(());
1600    }
1601    if let AttrValue::Valued(v) = val {
1602        if is_url_attr(key) && is_dangerous_url(v, tag) {
1603            #[cfg(feature = "tracing")]
1604            tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1605            return Ok(());
1606        }
1607    }
1608
1609    push_checked(out, " ", limit)?;
1610    push_checked(out, key, limit)?;
1611    if let AttrValue::Valued(v) = val {
1612        push_checked(out, "=\"", limit)?;
1613        push_escaped_attr(out, v, limit)?;
1614        push_checked(out, "\"", limit)?;
1615    }
1616    Ok(())
1617}
1618
1619/// Emit `<tag ...attrs`, plus any pending island / list-item attributes.
1620///
1621/// Shared by OPEN_TAG and VOID_TAG, which differ only in whether a matching
1622/// CLOSE_TAG follows (`has_content`). The `>` is deliberately not written yet:
1623/// DYN_ATTR opcodes may still append attributes.
1624fn emit_open_tag(
1625    out: &mut String,
1626    tag: &str,
1627    attrs: &[(&str, &str)],
1628    has_content: bool,
1629    state: &mut WalkState,
1630    limit: usize,
1631) -> Result<(), IrError> {
1632    if !is_valid_name(tag) {
1633        return Err(IrError::InvalidName(tag.to_string()));
1634    }
1635    if has_content {
1636        enter_element(out, tag, state, limit)?;
1637    }
1638    push_checked(out, "<", limit)?;
1639    push_checked(out, tag, limit)?;
1640
1641    for (key, val) in attrs {
1642        // `value` on a textarea or select, and `selected` on an option of a
1643        // bound select, are written another way.
1644        if consume_control_attr(state, key, Prop::Static(val)) {
1645            continue;
1646        }
1647        // A static attribute is authored text, never a boolean. An empty value
1648        // stays the bare name: `key` and `key=""` parse to the same DOM, and
1649        // keeping the shorthand leaves already-compiled modules byte-stable.
1650        let value = if val.is_empty() {
1651            AttrValue::Bare
1652        } else {
1653            AttrValue::Valued(val)
1654        };
1655        emit_attr(out, tag, key, value, limit)?;
1656    }
1657
1658    // Inject pending island attributes
1659    if let Some(island) = state.pending_island.take() {
1660        push_checked(out, " data-forma-island=\"", limit)?;
1661        push_u16(out, island.id, limit)?;
1662        push_checked(out, "\" data-forma-component=\"", limit)?;
1663        push_escaped_attr(out, &island.component_name, limit)?;
1664        push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1665        let trigger_str = match island.trigger {
1666            IslandTrigger::Load => "load",
1667            IslandTrigger::Visible => "visible",
1668            IslandTrigger::Interaction => "interaction",
1669            IslandTrigger::Idle => "idle",
1670        };
1671        push_checked(out, " data-forma-hydrate=\"", limit)?;
1672        push_checked(out, trigger_str, limit)?;
1673        push_checked(out, "\"", limit)?;
1674        if let Some(ref props_json) = island.inline_props {
1675            push_checked(out, " data-forma-props=\"", limit)?;
1676            push_escaped_attr(out, props_json, limit)?;
1677            push_checked(out, "\"", limit)?;
1678        }
1679    }
1680
1681    // Inject pending list-item key
1682    if let Some(key) = state.pending_list_key.take() {
1683        push_checked(out, " data-forma-key=\"", limit)?;
1684        push_escaped_attr(out, &key, limit)?;
1685        push_checked(out, "\"", limit)?;
1686    }
1687
1688    // Don't close the tag yet — DYN_ATTR opcodes may follow.
1689    state.pending_tag_close = true;
1690    state.pending_tag_name.clear();
1691    state.pending_tag_name.push_str(tag);
1692    Ok(())
1693}
1694
1695// ---------------------------------------------------------------------------
1696// Parsing context: text-only parents and form controls
1697// ---------------------------------------------------------------------------
1698
1699/// An element whose content the HTML tokenizer reads as text, not markup.
1700///
1701/// `textarea` and `title` are RCDATA: character references are decoded, but
1702/// a comment is not a comment. `style` (RAWTEXT) and `script` (script data)
1703/// decode nothing. A hydration marker inside any of them is literal text:
1704/// before JavaScript it is the control's value, what a form submits, and the
1705/// title the reader sees. So inside these four the walker writes no `f:`
1706/// marker of any kind (text, conditional, list or island) and no zero-width
1707/// space: the content is the element's static and dynamic text, in order.
1708///
1709/// The client adopts `textarea` and `title` text as a whole, by the element
1710/// (formajs, FORMA-003). A reactive child of `style` or `script` is not
1711/// supported for adoption in 0.2.x: its text is right before JavaScript, but
1712/// adoption may rewrite it. DR-7 makes it a build error in 3.0.0.
1713///
1714/// Dynamic text is still HTML-escaped. In RCDATA the parser decodes it back,
1715/// so the text is exact. In `style` and `script` nothing is decoded, so a
1716/// value holding `&`, `<` or `>` reads as the entity until the client writes
1717/// the element; that is the price of a hole that can neither close its
1718/// element (`</style>`) nor, in a script, swallow the rest of the page. A
1719/// hole there is still a code sink: the escaping does not make the value safe
1720/// as JavaScript or CSS.
1721///
1722/// Matched ASCII case-insensitively and in any namespace. An SVG `title` has
1723/// ordinary content, but its text written without markers is equally right,
1724/// and a single rule is one the client can match exactly.
1725///
1726/// Verified by: crates/forma-ir/src/walker.rs > "dyn_text_in_a_text_only_parent_is_written_without_markers"
1727/// Verified by: crates/forma-ir/src/walker.rs > "no_marker_kind_is_written_inside_a_text_only_parent"
1728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1729enum TextOnlyTag {
1730    Textarea,
1731    Title,
1732    Style,
1733    Script,
1734}
1735
1736impl TextOnlyTag {
1737    fn from_tag(tag: &str) -> Option<Self> {
1738        [Self::Textarea, Self::Title, Self::Style, Self::Script]
1739            .into_iter()
1740            .find(|t| tag.eq_ignore_ascii_case(t.name()))
1741    }
1742
1743    fn name(self) -> &'static str {
1744        match self {
1745            TextOnlyTag::Textarea => "textarea",
1746            TextOnlyTag::Title => "title",
1747            TextOnlyTag::Style => "style",
1748            TextOnlyTag::Script => "script",
1749        }
1750    }
1751}
1752
1753/// The text-only element the walk is inside.
1754#[derive(Debug)]
1755struct TextOnlyParent {
1756    tag: TextOnlyTag,
1757    /// Whether any content has been written: the leading-LF rule looks at the
1758    /// first byte only.
1759    started: bool,
1760    /// A `textarea` whose `value` is bound: the value is its whole content,
1761    /// as it is on the live control, so its children are not written.
1762    value_bound: bool,
1763}
1764
1765/// Write text into a text-only parent: escaped, with no marker and no
1766/// zero-width space, under the textarea's leading-LF rule.
1767///
1768/// The parser drops one line feed straight after `<textarea>` (a CR or CRLF is
1769/// a line feed by then). A marker used to stand between the tag and the first
1770/// line; without one, content that starts with a line break gets one more, so
1771/// the parsed value is the value written.
1772///
1773/// Verified by: crates/forma-ir/src/walker.rs > "a_textarea_doubles_a_leading_line_feed"
1774fn write_text_only(
1775    out: &mut String,
1776    parent: &mut TextOnlyParent,
1777    text: &str,
1778    limit: usize,
1779) -> Result<(), IrError> {
1780    if text.is_empty() {
1781        return Ok(());
1782    }
1783    if parent.tag == TextOnlyTag::Textarea
1784        && !parent.started
1785        && (text.starts_with('\n') || text.starts_with('\r'))
1786    {
1787        push_checked(out, "\n", limit)?;
1788    }
1789    parent.started = true;
1790    push_escaped_html(out, text, limit)
1791}
1792
1793/// Form-control state gathered from a start tag that is still open.
1794///
1795/// A `value` on a `textarea` or a `select` is a property: HTML never reads it
1796/// as an attribute, so the server shipped an empty textarea and a select
1797/// showing its first option, and a form submitted those before JavaScript.
1798/// The client's writer (`writeFormControlProperty` in `@getforma/core`) sets
1799/// `value == null ? '' : String(value)`; the walker writes the HTML that
1800/// parses to the same control, and no `value` attribute.
1801#[derive(Debug, Default)]
1802enum PendingControl {
1803    #[default]
1804    None,
1805    /// A `textarea`; `value` is the text its bound value stands for.
1806    Textarea { value: Option<String> },
1807    /// A `select`; `multiple` records whether that attribute is written.
1808    Select {
1809        value: Option<SlotValue>,
1810        multiple: bool,
1811    },
1812    /// An `option` of a bound select; `value` is its `value` attribute as
1813    /// written, `None` when it has none.
1814    OptionTag { value: Option<String> },
1815}
1816
1817/// What a bound `select` selects.
1818///
1819/// Mirrors the client's writer. A `multiple` select given an array selects
1820/// every option whose value is in it (`value.map(String)`). Anything else is
1821/// `select.value = String(value)`, which selects the FIRST option with that
1822/// value; a second `selected` in the HTML would win instead, since the parser
1823/// keeps the last. An option's value is its `value` attribute or, without
1824/// one, its text, stripped and collapsed. A value no option has selects
1825/// nothing, and the browser then shows the first option: HTML cannot write
1826/// the client's `selectedIndex = -1`.
1827///
1828/// Verified by: crates/forma-ir/src/walker.rs > "a_select_value_binding_selects_the_matching_option"
1829/// Verified by: crates/forma-ir/src/walker.rs > "a_single_select_selects_only_the_first_matching_option"
1830/// Verified by: crates/forma-ir/src/walker.rs > "a_multiple_select_selects_every_option_in_an_array_value"
1831#[derive(Debug)]
1832struct SelectBinding {
1833    wanted: SelectWanted,
1834    /// A single select has selected its option.
1835    matched: bool,
1836}
1837
1838#[derive(Debug)]
1839enum SelectWanted {
1840    One(String),
1841    Any(Vec<String>),
1842}
1843
1844impl SelectBinding {
1845    fn new(value: &SlotValue, multiple: bool) -> Self {
1846        let wanted = match value {
1847            SlotValue::Array(items) if multiple => {
1848                SelectWanted::Any(items.iter().map(js_string).collect())
1849            }
1850            // `value == null ? '' : String(value)` is exactly `to_text`.
1851            other => SelectWanted::One(other.to_text()),
1852        };
1853        SelectBinding {
1854            wanted,
1855            matched: false,
1856        }
1857    }
1858
1859    /// Whether the option valued `option_value` is selected.
1860    fn selects(&mut self, option_value: &str) -> bool {
1861        match &self.wanted {
1862            SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
1863            SelectWanted::One(value) => {
1864                let hit = !self.matched && value == option_value;
1865                self.matched |= hit;
1866                hit
1867            }
1868        }
1869    }
1870}
1871
1872/// `String(value)` for one member of an array: unlike a text hole, `null` is
1873/// `"null"`.
1874fn js_string(value: &SlotValue) -> String {
1875    match value {
1876        SlotValue::Null => "null".to_string(),
1877        other => other.to_text(),
1878    }
1879}
1880
1881/// An option of a bound select with no `value` attribute. Its value is its
1882/// text, known only when the option ends, so ` selected` is inserted then.
1883#[derive(Debug)]
1884struct OptionByText {
1885    /// Byte offset of the `>` that ends the option's start tag. The walker
1886    /// only ever appends to its output, so the offset stays valid.
1887    insert_at: usize,
1888    /// The option's text as the parser will read it.
1889    text: String,
1890}
1891
1892/// An attribute value as the IR gives it: authored text, or a slot.
1893#[derive(Clone, Copy)]
1894enum Prop<'a> {
1895    Static(&'a str),
1896    Slot(&'a SlotValue),
1897}
1898
1899impl Prop<'_> {
1900    /// The client's control text: `value == null ? '' : String(value)`.
1901    fn control_text(self) -> String {
1902        match self {
1903            Prop::Static(s) => s.to_string(),
1904            Prop::Slot(v) => v.to_text(),
1905        }
1906    }
1907
1908    /// Whether the generic path writes the attribute at all (DYN_ATTR's
1909    /// boolean rule: `false` and `null` omit it).
1910    fn is_written(self) -> bool {
1911        !matches!(
1912            self,
1913            Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
1914        )
1915    }
1916
1917    /// The value the attribute is written with: `None` when omitted, `""`
1918    /// when bare.
1919    fn written(self) -> Option<String> {
1920        match self {
1921            _ if !self.is_written() => None,
1922            Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
1923            other => Some(other.control_text()),
1924        }
1925    }
1926
1927    fn to_slot(self) -> SlotValue {
1928        match self {
1929            Prop::Static(s) => SlotValue::Text(s.to_string()),
1930            Prop::Slot(v) => v.clone(),
1931        }
1932    }
1933}
1934
1935/// Apply the form-control rules to one attribute of the open start tag.
1936/// Returns true when the attribute is consumed and must not be written.
1937///
1938/// Keys match exactly, as the client's `isFormControlProperty` does.
1939fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
1940    match &mut state.pending_control {
1941        PendingControl::None => false,
1942        PendingControl::Textarea { value } => {
1943            if key != "value" {
1944                return false;
1945            }
1946            *value = Some(prop.control_text());
1947            true
1948        }
1949        PendingControl::Select { value, multiple } => match key {
1950            "value" => {
1951                *value = Some(prop.to_slot());
1952                true
1953            }
1954            "multiple" => {
1955                *multiple |= prop.is_written();
1956                false
1957            }
1958            _ => false,
1959        },
1960        PendingControl::OptionTag { value } => match key {
1961            // The binding decides, as on the client, where the select's
1962            // value is written after its options.
1963            "selected" => true,
1964            "value" => {
1965                // The parser keeps the first of two same-named attributes.
1966                if value.is_none() {
1967                    *value = prop.written();
1968                }
1969                false
1970            }
1971            _ => false,
1972        },
1973    }
1974}
1975
1976/// Set up the parsing context for an element whose start tag (OPEN_TAG) is
1977/// about to be written.
1978fn enter_element(
1979    out: &mut String,
1980    tag: &str,
1981    state: &mut WalkState,
1982    limit: usize,
1983) -> Result<(), IrError> {
1984    let is = |name: &str| tag.eq_ignore_ascii_case(name);
1985    // A new option or optgroup ends the open option: `</option>` is optional.
1986    if is("option") || is("optgroup") {
1987        finish_option_by_text(out, state, limit)?;
1988    }
1989    state.pending_control = if is("textarea") {
1990        PendingControl::Textarea { value: None }
1991    } else if is("select") {
1992        PendingControl::Select {
1993            value: None,
1994            multiple: false,
1995        }
1996    } else if is("option") && state.select.is_some() {
1997        PendingControl::OptionTag { value: None }
1998    } else {
1999        PendingControl::None
2000    };
2001    if state.text_only.is_none() {
2002        state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
2003            tag,
2004            started: false,
2005            value_bound: false,
2006        });
2007    }
2008    Ok(())
2009}
2010
2011/// Tear down the parsing context at a CLOSE_TAG, before `</tag>` is written.
2012fn leave_element(
2013    out: &mut String,
2014    tag: &str,
2015    state: &mut WalkState,
2016    limit: usize,
2017) -> Result<(), IrError> {
2018    let is = |name: &str| tag.eq_ignore_ascii_case(name);
2019    if is("option") || is("optgroup") || is("select") {
2020        finish_option_by_text(out, state, limit)?;
2021    }
2022    if is("select") {
2023        state.select = None;
2024    }
2025    if state
2026        .text_only
2027        .as_ref()
2028        .is_some_and(|parent| is(parent.tag.name()))
2029    {
2030        state.text_only = None;
2031    }
2032    Ok(())
2033}
2034
2035/// Write the `>` that ends the open start tag, resolving the form-control
2036/// state gathered from its attributes.
2037///
2038/// Every place that used to write a bare `>` for `pending_tag_close` calls
2039/// this instead, so a control is resolved whichever opcode ends its tag.
2040fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
2041    state.pending_tag_close = false;
2042    match std::mem::take(&mut state.pending_control) {
2043        PendingControl::None => {}
2044        PendingControl::Textarea { value } => {
2045            push_checked(out, ">", limit)?;
2046            if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
2047                if parent.tag == TextOnlyTag::Textarea {
2048                    parent.value_bound = true;
2049                    write_text_only(out, parent, &value, limit)?;
2050                }
2051            }
2052            return Ok(());
2053        }
2054        PendingControl::Select { value, multiple } => {
2055            state.select = value.map(|value| SelectBinding::new(&value, multiple));
2056        }
2057        PendingControl::OptionTag { value: Some(value) } => {
2058            if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2059                push_checked(out, " selected", limit)?;
2060            }
2061        }
2062        PendingControl::OptionTag { value: None } => {
2063            state.option_by_text = Some(OptionByText {
2064                insert_at: out.len(),
2065                text: String::new(),
2066            });
2067        }
2068    }
2069    push_checked(out, ">", limit)
2070}
2071
2072/// End an option whose value is its text, selecting it now if the text
2073/// matches. Runs at `</option>` and wherever the parser ends one implicitly.
2074///
2075/// Verified by: crates/forma-ir/src/walker.rs > "a_select_value_binding_matches_an_option_without_a_value_by_its_text"
2076fn finish_option_by_text(
2077    out: &mut String,
2078    state: &mut WalkState,
2079    limit: usize,
2080) -> Result<(), IrError> {
2081    let Some(option) = state.option_by_text.take() else {
2082        return Ok(());
2083    };
2084    let value = strip_and_collapse_ascii_whitespace(&option.text);
2085    if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2086        const SELECTED: &str = " selected";
2087        if out.len().saturating_add(SELECTED.len()) > limit {
2088            return Err(IrError::OutputLimitExceeded { limit });
2089        }
2090        out.insert_str(option.insert_at, SELECTED);
2091    }
2092    Ok(())
2093}
2094
2095/// HTML's "strip and collapse ASCII whitespace", which turns an option's text
2096/// into its value.
2097fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
2098    let mut out = String::with_capacity(text.len());
2099    for word in text.split_ascii_whitespace() {
2100        if !out.is_empty() {
2101            out.push(' ');
2102        }
2103        out.push_str(word);
2104    }
2105    out
2106}
2107
2108// ---------------------------------------------------------------------------
2109// Byte-reading helpers
2110// ---------------------------------------------------------------------------
2111
2112/// Read a little-endian u16 from `data` at `pos`.
2113pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
2114    if pos + 2 > data.len() {
2115        return Err(IrError::BufferTooShort {
2116            expected: pos + 2,
2117            actual: data.len(),
2118        });
2119    }
2120    Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
2121}
2122
2123/// Read a little-endian u32 from `data` at `pos`.
2124pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
2125    if pos + 4 > data.len() {
2126        return Err(IrError::BufferTooShort {
2127            expected: pos + 4,
2128            actual: data.len(),
2129        });
2130    }
2131    Ok(u32::from_le_bytes([
2132        data[pos],
2133        data[pos + 1],
2134        data[pos + 2],
2135        data[pos + 3],
2136    ]))
2137}
2138
2139/// Read an OPEN_TAG or VOID_TAG operand block:
2140/// `str_idx(u32) + attr_count(u16) + [(key_str_idx(u32), val_str_idx(u32))] x attr_count`
2141///
2142/// Returns `(tag_str_idx, vec of (key, val) string refs, new position)`.
2143pub(crate) fn read_tag_with_attrs<'a>(
2144    ops: &[u8],
2145    pos: usize,
2146    strings: &'a crate::parser::StringTable,
2147) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
2148    let tag_str_idx = read_u32(ops, pos)?;
2149    let attr_count = read_u16(ops, pos + 4)? as usize;
2150    let mut cursor = pos + 6; // past str_idx(4) + attr_count(2)
2151
2152    // `attr_count` is u16-bounded but each pair costs 8 bytes on disk and a
2153    // fat-pointer pair in memory — bound the reservation by what is actually
2154    // left. A valid stream still gets one exact allocation.
2155    let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
2156    for _ in 0..attr_count {
2157        let key_idx = read_u32(ops, cursor)?;
2158        let val_idx = read_u32(ops, cursor + 4)?;
2159        let key = strings.get(key_idx)?;
2160        let val = strings.get(val_idx)?;
2161        attrs.push((key, val));
2162        cursor += 8;
2163    }
2164
2165    Ok((tag_str_idx, attrs, cursor))
2166}
2167
2168// ---------------------------------------------------------------------------
2169// Budgeted output helpers
2170// ---------------------------------------------------------------------------
2171
2172/// Longest escape sequence any of the escapers can emit for one character
2173/// (`&amp;`, `&quot;`, `<`).
2174const MAX_ESCAPE_EXPANSION: usize = 6;
2175
2176/// Append `s`, refusing to exceed the walk's output budget.
2177///
2178/// Every append routes through here (or one of the escaping variants below)
2179/// because a *single* opcode can emit gigabytes: OPEN_TAG accepts up to 65,535
2180/// attributes, each with a 65,535-character value that escapes 5x, so a check
2181/// only at the top of the opcode loop never runs in time.
2182fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
2183    if out.len().saturating_add(s.len()) > limit {
2184        return Err(IrError::OutputLimitExceeded { limit });
2185    }
2186    out.push_str(s);
2187    Ok(())
2188}
2189
2190/// Append a `u16` in decimal, within the output budget, without allocating.
2191///
2192/// Marker ids are the hottest thing the walker writes — every DYN_TEXT emits
2193/// its id twice, every SHOW_IF and LIST likewise, and a list body repeats all
2194/// of that once per row. `to_string()` heap-allocated for each one.
2195fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2196    // u16::MAX is 65535: five digits, never more.
2197    let mut buf = [0u8; 5];
2198    let mut remaining = value;
2199    let mut start = buf.len();
2200    loop {
2201        start -= 1;
2202        buf[start] = b'0' + (remaining % 10) as u8;
2203        remaining /= 10;
2204        if remaining == 0 {
2205            break;
2206        }
2207    }
2208    // ASCII digits are always UTF-8; the error arm is unreachable and costs
2209    // nothing, where `expect` would put a panic on the hottest write.
2210    match core::str::from_utf8(&buf[start..]) {
2211        Ok(digits) => push_checked(out, digits, limit),
2212        Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
2213    }
2214}
2215
2216/// Escape text for HTML content (`& < >`), within the output budget.
2217fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2218    // Fast path: even worst-case expansion fits, so no per-character checks.
2219    if out
2220        .len()
2221        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2222        <= limit
2223    {
2224        for ch in text.chars() {
2225            match ch {
2226                '&' => out.push_str("&amp;"),
2227                '<' => out.push_str("&lt;"),
2228                '>' => out.push_str("&gt;"),
2229                _ => out.push(ch),
2230            }
2231        }
2232        return Ok(());
2233    }
2234    for ch in text.chars() {
2235        match ch {
2236            '&' => push_checked(out, "&amp;", limit)?,
2237            '<' => push_checked(out, "&lt;", limit)?,
2238            '>' => push_checked(out, "&gt;", limit)?,
2239            _ => {
2240                if out.len() >= limit {
2241                    return Err(IrError::OutputLimitExceeded { limit });
2242                }
2243                out.push(ch);
2244            }
2245        }
2246    }
2247    Ok(())
2248}
2249
2250/// Escape text for HTML attribute values (`& " < >`), within the output budget.
2251fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2252    if out
2253        .len()
2254        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2255        <= limit
2256    {
2257        for ch in text.chars() {
2258            match ch {
2259                '&' => out.push_str("&amp;"),
2260                '"' => out.push_str("&quot;"),
2261                '<' => out.push_str("&lt;"),
2262                '>' => out.push_str("&gt;"),
2263                _ => out.push(ch),
2264            }
2265        }
2266        return Ok(());
2267    }
2268    for ch in text.chars() {
2269        match ch {
2270            '&' => push_checked(out, "&amp;", limit)?,
2271            '"' => push_checked(out, "&quot;", limit)?,
2272            '<' => push_checked(out, "&lt;", limit)?,
2273            '>' => push_checked(out, "&gt;", limit)?,
2274            _ => {
2275                if out.len() >= limit {
2276                    return Err(IrError::OutputLimitExceeded { limit });
2277                }
2278                out.push(ch);
2279            }
2280        }
2281    }
2282    Ok(())
2283}
2284
2285// ---------------------------------------------------------------------------
2286// Tests
2287// ---------------------------------------------------------------------------
2288
2289#[cfg(test)]
2290mod tests {
2291    use super::*;
2292    use crate::parser::test_helpers::{
2293        build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
2294        encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
2295    };
2296    use crate::slot::{SlotData, SlotValue};
2297
2298    // -- Opcode encoding helpers local to walker tests ----------------------
2299
2300    /// Encode a DYN_TEXT opcode: opcode(1) + slot_id(2) + marker_id(2)
2301    fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2302        let mut buf = Vec::new();
2303        buf.push(0x05); // Opcode::DynText
2304        buf.extend_from_slice(&slot_id.to_le_bytes());
2305        buf.extend_from_slice(&marker_id.to_le_bytes());
2306        buf
2307    }
2308
2309    /// Encode an ISLAND_START opcode: opcode(1) + island_id(2)
2310    fn encode_island_start(island_id: u16) -> Vec<u8> {
2311        let mut buf = vec![0x0B];
2312        buf.extend_from_slice(&island_id.to_le_bytes());
2313        buf
2314    }
2315
2316    /// Encode an ISLAND_END opcode: opcode(1) + island_id(2)
2317    fn encode_island_end(island_id: u16) -> Vec<u8> {
2318        let mut buf = vec![0x0C];
2319        buf.extend_from_slice(&island_id.to_le_bytes());
2320        buf
2321    }
2322
2323    /// Encode a COMMENT opcode: opcode(1) + str_idx(4)
2324    fn encode_comment(str_idx: u32) -> Vec<u8> {
2325        let mut buf = vec![0x10];
2326        buf.extend_from_slice(&str_idx.to_le_bytes());
2327        buf
2328    }
2329
2330    /// Helper: build an IrModule, walk it with empty slots, return the HTML.
2331    fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2332        let data = build_minimal_ir(strings, &[], opcodes, &[]);
2333        let module = IrModule::parse(&data).unwrap();
2334        let slots = SlotData::new(0);
2335        walk_to_html(&module, &slots).unwrap()
2336    }
2337
2338    /// Helper: build an IrModule with slot declarations, walk with given SlotData.
2339    fn walk_with_slots(
2340        strings: &[&str],
2341        slot_decls: &[(u16, u32, u8, u8, &[u8])],
2342        opcodes: &[u8],
2343        slots: &SlotData,
2344    ) -> String {
2345        let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
2346        let module = IrModule::parse(&data).unwrap();
2347        walk_to_html(&module, slots).unwrap()
2348    }
2349
2350    /// Helper: build an IrModule with slots AND islands, walk with given SlotData.
2351    fn walk_with_islands(
2352        strings: &[&str],
2353        slot_decls: &[(u16, u32, u8, u8, &[u8])],
2354        island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2355        opcodes: &[u8],
2356        slots: &SlotData,
2357    ) -> String {
2358        let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
2359        let module = IrModule::parse(&data).unwrap();
2360        walk_to_html(&module, slots).unwrap()
2361    }
2362
2363    /// Encode a LIST_ITEM_KEY opcode: opcode(1) + key_str_idx(4)
2364    fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
2365        let mut buf = vec![0x11]; // Opcode::ListItemKey
2366        buf.extend_from_slice(&key_str_idx.to_le_bytes());
2367        buf
2368    }
2369
2370    /// Encode a DYN_ATTR opcode: opcode(1) + attr_str_idx(4) + slot_id(2)
2371    fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2372        let mut bytes = vec![0x06]; // Opcode::DynAttr
2373        bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2374        bytes.extend_from_slice(&slot_id.to_le_bytes());
2375        bytes
2376    }
2377
2378    // -- Test 1: walk_static_div -------------------------------------------
2379
2380    #[test]
2381    fn walk_static_div() {
2382        // <div class="container">Hello</div>
2383        // strings: 0="div", 1="class", 2="container", 3="Hello"
2384        let mut opcodes = Vec::new();
2385        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2386        opcodes.extend_from_slice(&encode_text(3));
2387        opcodes.extend_from_slice(&encode_close_tag(0));
2388
2389        let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2390        assert_eq!(html, r#"<div class="container">Hello</div>"#);
2391    }
2392
2393    // -- Test 2: walk_void_tag ---------------------------------------------
2394
2395    #[test]
2396    fn walk_void_tag() {
2397        // <input type="email">
2398        // strings: 0="input", 1="type", 2="email"
2399        let opcodes = encode_void_tag(0, &[(1, 2)]);
2400
2401        let html = walk_static(&["input", "type", "email"], &opcodes);
2402        assert_eq!(html, r#"<input type="email">"#);
2403    }
2404
2405    // -- Test 3: walk_nested_elements --------------------------------------
2406
2407    #[test]
2408    fn walk_nested_elements() {
2409        // <div><span>Hi</span></div>
2410        // strings: 0="div", 1="span", 2="Hi"
2411        let mut opcodes = Vec::new();
2412        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2413        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2414        opcodes.extend_from_slice(&encode_text(2));
2415        opcodes.extend_from_slice(&encode_close_tag(1));
2416        opcodes.extend_from_slice(&encode_close_tag(0));
2417
2418        let html = walk_static(&["div", "span", "Hi"], &opcodes);
2419        assert_eq!(html, "<div><span>Hi</span></div>");
2420    }
2421
2422    // -- Test 4: walk_text_escaping ----------------------------------------
2423
2424    #[test]
2425    fn walk_text_escaping() {
2426        // TEXT with "<script>alert('xss')</script>"
2427        let opcodes = encode_text(0);
2428
2429        let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2430        assert_eq!(html, "&lt;script&gt;alert('xss')&lt;/script&gt;");
2431    }
2432
2433    // -- Test 5: walk_attr_escaping ----------------------------------------
2434
2435    #[test]
2436    fn walk_attr_escaping() {
2437        // OPEN_TAG with attr value containing a double quote
2438        // strings: 0="div", 1="title", 2=r#"a"b"#
2439        let opcodes = encode_open_tag(0, &[(1, 2)]);
2440
2441        let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2442        assert_eq!(html, r#"<div title="a&quot;b">"#);
2443    }
2444
2445    // -- Test 6: walk_empty_element ----------------------------------------
2446
2447    #[test]
2448    fn walk_empty_element() {
2449        // <div></div> (no children)
2450        let mut opcodes = Vec::new();
2451        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2452        opcodes.extend_from_slice(&encode_close_tag(0));
2453
2454        let html = walk_static(&["div"], &opcodes);
2455        assert_eq!(html, "<div></div>");
2456    }
2457
2458    // -- Test 7: walk_dyn_text ---------------------------------------------
2459
2460    #[test]
2461    fn walk_dyn_text() {
2462        // DYN_TEXT with slot data produces marker comments + value
2463        // strings: 0="greeting" (slot name)
2464        // slot decl: slot_id=0, name_str_idx=0, type=Text(0x01)
2465        let opcodes = encode_dyn_text(0, 0);
2466
2467        let mut slots = SlotData::new(1);
2468        slots.set(0, SlotValue::Text("World".to_string()));
2469
2470        let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2471        assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2472    }
2473
2474    #[test]
2475    fn walk_dyn_text_null_emits_zwsp() {
2476        // DYN_TEXT with null slot value must emit a zero-width space between
2477        // markers so the browser creates a Text node for hydration to bind to.
2478        let opcodes = encode_dyn_text(0, 0);
2479        let slots = SlotData::new(1); // slot 0 defaults to Null
2480
2481        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2482        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2483    }
2484
2485    #[test]
2486    fn walk_dyn_text_empty_string_emits_zwsp() {
2487        // DYN_TEXT with explicit empty string also needs the ZWSP placeholder.
2488        let opcodes = encode_dyn_text(0, 0);
2489        let mut slots = SlotData::new(1);
2490        slots.set(0, SlotValue::Text(String::new()));
2491
2492        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2493        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2494    }
2495
2496    // -- Test 8: walk_dyn_text_escaping ------------------------------------
2497
2498    #[test]
2499    fn walk_dyn_text_escaping() {
2500        // DYN_TEXT value with HTML chars is escaped
2501        let opcodes = encode_dyn_text(0, 1);
2502
2503        let mut slots = SlotData::new(1);
2504        slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2505
2506        let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2507        assert_eq!(html, "<!--f:t1-->&lt;b&gt;bold&lt;/b&gt;<!--/f:t1-->");
2508    }
2509
2510    // -- Test 9: walk_island_markers ---------------------------------------
2511
2512    #[test]
2513    fn walk_island_markers() {
2514        // ISLAND_START(0) + TEXT content + ISLAND_END(0). The island body
2515        // opens no element, so the island contributes markers only — its
2516        // attributes are dropped at ISLAND_END with a warning.
2517        let mut opcodes = Vec::new();
2518        opcodes.extend_from_slice(&encode_island_start(0));
2519        opcodes.extend_from_slice(&encode_text(0));
2520        opcodes.extend_from_slice(&encode_island_end(0));
2521
2522        let slots = SlotData::new(0);
2523        let html = walk_with_islands(
2524            &["Hello"],
2525            &[],
2526            &[(0, 0x01, 0x01, 0, 0, &[])],
2527            &opcodes,
2528            &slots,
2529        );
2530        assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2531    }
2532
2533    // -- Test 10: walk_comment ---------------------------------------------
2534
2535    #[test]
2536    fn walk_comment() {
2537        let opcodes = encode_comment(0);
2538
2539        let html = walk_static(&["This is a comment"], &opcodes);
2540        assert_eq!(html, "<!--This is a comment-->");
2541    }
2542
2543    // -- Test 11: walk_boolean_attr ----------------------------------------
2544
2545    #[test]
2546    fn walk_boolean_attr() {
2547        // <input disabled> — attribute with empty string value renders as valueless
2548        // strings: 0="input", 1="disabled", 2="" (empty string for boolean attr)
2549        let opcodes = encode_void_tag(0, &[(1, 2)]);
2550
2551        let html = walk_static(&["input", "disabled", ""], &opcodes);
2552        assert_eq!(html, "<input disabled>");
2553    }
2554
2555    // -- DYN_ATTR value semantics (W2 / F15) --------------------------------
2556
2557    /// Helper: `<input DYN_ATTR(disabled, slot 0)>` with slot 0 set to `value`.
2558    fn walk_dyn_attr_with(value: SlotValue) -> String {
2559        let mut opcodes = encode_void_tag(0, &[]);
2560        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2561        let mut slots = SlotData::new(1);
2562        slots.set(0, value);
2563        walk_with_slots(
2564            &["input", "disabled"],
2565            &[(0, 1, 0x02, 0x00, &[])],
2566            &opcodes,
2567            &slots,
2568        )
2569    }
2570
2571    #[test]
2572    fn dyn_attr_bool_matches_the_client_renderer() {
2573        // An HTML boolean attribute is ON whenever it is PRESENT, whatever its
2574        // value — `disabled="false"` disables the control. The walker used to
2575        // emit exactly that, so SSR shipped a disabled/hidden/checked control
2576        // that only un-broke itself after hydration, and never at all for a
2577        // visible/idle/interaction island the user had not reached, or with JS
2578        // off. These are byte-for-byte what `renderAttr` in @getforma/core
2579        // produces for the same values.
2580        assert_eq!(
2581            walk_dyn_attr_with(SlotValue::Bool(true)),
2582            "<input disabled>",
2583            "true must render the bare attribute"
2584        );
2585        assert_eq!(
2586            walk_dyn_attr_with(SlotValue::Bool(false)),
2587            "<input>",
2588            "false must omit the attribute, not write disabled=\"false\""
2589        );
2590        assert_eq!(
2591            walk_dyn_attr_with(SlotValue::Null),
2592            "<input>",
2593            "null must omit the attribute"
2594        );
2595    }
2596
2597    #[test]
2598    fn dyn_attr_empty_string_is_a_value_not_an_absence() {
2599        // `renderAttr(key, "")` emits `key=""`, and the client hydrating the
2600        // same slot calls setAttribute(key, "") — dropping the attribute here
2601        // is a real DOM difference on the common `value=""` / `alt=""` case.
2602        assert_eq!(
2603            walk_dyn_attr_with(SlotValue::Text(String::new())),
2604            "<input disabled=\"\">"
2605        );
2606        assert_eq!(
2607            walk_dyn_attr_with(SlotValue::Text("yes".into())),
2608            "<input disabled=\"yes\">"
2609        );
2610        // Numbers stringify, they do not vanish: 0 is a value.
2611        assert_eq!(
2612            walk_dyn_attr_with(SlotValue::Number(0.0)),
2613            "<input disabled=\"0\">"
2614        );
2615    }
2616
2617    // -- Test 12: walk_multiple_attrs --------------------------------------
2618
2619    #[test]
2620    fn walk_multiple_attrs() {
2621        // <div id="main" class="container" data-x="42"></div>
2622        // strings: 0="div", 1="id", 2="main", 3="class", 4="container", 5="data-x", 6="42"
2623        let mut opcodes = Vec::new();
2624        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
2625        opcodes.extend_from_slice(&encode_close_tag(0));
2626
2627        let html = walk_static(
2628            &["div", "id", "main", "class", "container", "data-x", "42"],
2629            &opcodes,
2630        );
2631        assert_eq!(
2632            html,
2633            r#"<div id="main" class="container" data-x="42"></div>"#
2634        );
2635    }
2636
2637    // -- Test 13: walk_show_if_true_branch ---------------------------------
2638
2639    #[test]
2640    fn walk_show_if_true_branch() {
2641        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
2642        // slot 0 = Bool(true) → render "Yes"
2643        // strings: 0="visible", 1="Yes", 2="No"
2644        let then_ops = encode_text(1); // TEXT "Yes"
2645        let else_ops = encode_text(2); // TEXT "No"
2646        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2647
2648        let mut slots = SlotData::new(1);
2649        slots.set(0, SlotValue::Bool(true));
2650
2651        let html = walk_with_slots(
2652            &["visible", "Yes", "No"],
2653            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="visible", type=Bool
2654            &opcodes,
2655            &slots,
2656        );
2657        assert!(html.contains("Yes"), "should contain then-branch text");
2658        assert!(!html.contains("No"), "should NOT contain else-branch text");
2659        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2660        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2661    }
2662
2663    // -- Test 14: walk_show_if_false_branch --------------------------------
2664
2665    #[test]
2666    fn walk_show_if_false_branch() {
2667        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
2668        // slot 0 = Bool(false) → render "No"
2669        let then_ops = encode_text(1);
2670        let else_ops = encode_text(2);
2671        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2672
2673        let mut slots = SlotData::new(1);
2674        slots.set(0, SlotValue::Bool(false));
2675
2676        let html = walk_with_slots(
2677            &["visible", "Yes", "No"],
2678            &[(0, 0, 0x02, 0x00, &[])],
2679            &opcodes,
2680            &slots,
2681        );
2682        assert!(html.contains("No"), "should contain else-branch text");
2683        assert!(!html.contains("Yes"), "should NOT contain then-branch text");
2684        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2685        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2686    }
2687
2688    // -- Test 15: walk_show_if_truthy_text ---------------------------------
2689
2690    #[test]
2691    fn walk_show_if_truthy_text() {
2692        // slot 0 = Text("hello") → truthy → render then branch
2693        let then_ops = encode_text(1);
2694        let else_ops = encode_text(2);
2695        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2696
2697        let mut slots = SlotData::new(1);
2698        slots.set(0, SlotValue::Text("hello".to_string()));
2699
2700        let html = walk_with_slots(
2701            &["greeting", "Shown", "Hidden"],
2702            &[(0, 0, 0x01, 0x00, &[])], // type=Text
2703            &opcodes,
2704            &slots,
2705        );
2706        assert!(
2707            html.contains("Shown"),
2708            "truthy text should render then branch"
2709        );
2710        assert!(
2711            !html.contains("Hidden"),
2712            "truthy text should NOT render else branch"
2713        );
2714    }
2715
2716    // -- Test 16: walk_show_if_falsy_null ----------------------------------
2717
2718    #[test]
2719    fn walk_show_if_falsy_null() {
2720        // slot 0 = Null (default) → falsy → render else branch
2721        let then_ops = encode_text(1);
2722        let else_ops = encode_text(2);
2723        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2724
2725        let slots = SlotData::new(1); // slot 0 is Null by default
2726
2727        let html = walk_with_slots(
2728            &["maybe", "Present", "Missing"],
2729            &[(0, 0, 0x01, 0x00, &[])],
2730            &opcodes,
2731            &slots,
2732        );
2733        assert!(html.contains("Missing"), "null should render else branch");
2734        assert!(
2735            !html.contains("Present"),
2736            "null should NOT render then branch"
2737        );
2738    }
2739
2740    // -- Test 17: walk_show_if_no_else ------------------------------------
2741
2742    #[test]
2743    fn walk_show_if_no_else() {
2744        // SHOW_IF with empty else branch. When false, nothing rendered between markers.
2745        let then_ops = encode_text(1);
2746        let else_ops: Vec<u8> = vec![]; // empty else
2747        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2748
2749        let slots = SlotData::new(1); // Null → false
2750
2751        let html = walk_with_slots(
2752            &["flag", "Content"],
2753            &[(0, 0, 0x02, 0x00, &[])],
2754            &opcodes,
2755            &slots,
2756        );
2757        assert_eq!(
2758            html, "<!--f:s0--><!--/f:s0-->",
2759            "empty else should produce only markers"
2760        );
2761    }
2762
2763    // -- Test 18: walk_show_if_nested -------------------------------------
2764
2765    #[test]
2766    fn walk_show_if_nested() {
2767        // Outer SHOW_IF(slot=0, true) → then branch contains inner SHOW_IF(slot=1, false)
2768        // Inner then=TEXT("inner-yes"), inner else=TEXT("inner-no")
2769        // strings: 0="outer", 1="inner", 2="outer-yes", 3="outer-no", 4="inner-yes", 5="inner-no"
2770        let inner_then = encode_text(4);
2771        let inner_else = encode_text(5);
2772        let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2773
2774        let outer_then = inner_show_if; // outer then = inner SHOW_IF
2775        let outer_else = encode_text(3);
2776        let opcodes = encode_show_if(0, &outer_then, &outer_else);
2777
2778        let mut slots = SlotData::new(2);
2779        slots.set(0, SlotValue::Bool(true)); // outer true
2780        slots.set(1, SlotValue::Bool(false)); // inner false
2781
2782        let html = walk_with_slots(
2783            &[
2784                "outer",
2785                "inner",
2786                "outer-yes",
2787                "outer-no",
2788                "inner-yes",
2789                "inner-no",
2790            ],
2791            &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2792            &opcodes,
2793            &slots,
2794        );
2795        // Outer is true → walk inner. Inner is false → render "inner-no"
2796        assert!(
2797            html.contains("inner-no"),
2798            "nested: inner false should render inner else"
2799        );
2800        assert!(
2801            !html.contains("inner-yes"),
2802            "nested: inner true branch should not render"
2803        );
2804        assert!(
2805            !html.contains("outer-no"),
2806            "nested: outer else should not render"
2807        );
2808        // Verify correct nesting of markers
2809        assert!(
2810            html.contains("<!--f:s0-->"),
2811            "should have outer opening marker"
2812        );
2813        assert!(
2814            html.contains("<!--/f:s0-->"),
2815            "should have outer closing marker"
2816        );
2817        assert!(
2818            html.contains("<!--f:s1-->"),
2819            "should have inner opening marker"
2820        );
2821        assert!(
2822            html.contains("<!--/f:s1-->"),
2823            "should have inner closing marker"
2824        );
2825    }
2826
2827    // -- Test 19: walk_show_if_with_elements ------------------------------
2828
2829    #[test]
2830    fn walk_show_if_with_elements() {
2831        // Then branch contains OPEN_TAG("span") + TEXT("Hello") + CLOSE_TAG("span")
2832        // strings: 0="flag", 1="span", 2="Hello", 3="Bye"
2833        let mut then_ops = Vec::new();
2834        then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2835        then_ops.extend_from_slice(&encode_text(2));
2836        then_ops.extend_from_slice(&encode_close_tag(1));
2837
2838        let else_ops = encode_text(3);
2839        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2840
2841        let mut slots = SlotData::new(1);
2842        slots.set(0, SlotValue::Bool(true));
2843
2844        let html = walk_with_slots(
2845            &["flag", "span", "Hello", "Bye"],
2846            &[(0, 0, 0x02, 0x00, &[])],
2847            &opcodes,
2848            &slots,
2849        );
2850        assert_eq!(
2851            html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2852            "then branch should produce full HTML element"
2853        );
2854    }
2855
2856    // -- Test 20: walk_list_renders_items -----------------------------------
2857
2858    #[test]
2859    fn walk_list_renders_items() {
2860        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2861        // slot 0 = Array([Text("Alice"), Text("Bob")])
2862        // strings: 0="items", 1="item"
2863        let body = encode_dyn_text(1, 0);
2864        let opcodes = encode_list(0, 1, &body);
2865
2866        let mut slots = SlotData::new(2);
2867        slots.set(
2868            0,
2869            SlotValue::array(vec![
2870                SlotValue::Text("Alice".to_string()),
2871                SlotValue::Text("Bob".to_string()),
2872            ]),
2873        );
2874
2875        let html = walk_with_slots(
2876            &["items", "item"],
2877            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
2878            &opcodes,
2879            &slots,
2880        );
2881        assert!(html.contains("Alice"), "should contain first item");
2882        assert!(html.contains("Bob"), "should contain second item");
2883        assert!(
2884            html.starts_with("<!--f:l0-->"),
2885            "should start with list open marker"
2886        );
2887        assert!(
2888            html.ends_with("<!--/f:l0-->"),
2889            "should end with list close marker"
2890        );
2891    }
2892
2893    // -- Test 21: walk_list_empty_array ------------------------------------
2894
2895    #[test]
2896    fn walk_list_empty_array() {
2897        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2898        // slot 0 = Array([])
2899        let body = encode_dyn_text(1, 0);
2900        let opcodes = encode_list(0, 1, &body);
2901
2902        let mut slots = SlotData::new(2);
2903        slots.set(0, SlotValue::array(vec![]));
2904
2905        let html = walk_with_slots(
2906            &["items", "item"],
2907            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2908            &opcodes,
2909            &slots,
2910        );
2911        assert_eq!(
2912            html, "<!--f:l0--><!--/f:l0-->",
2913            "empty array should produce only markers"
2914        );
2915    }
2916
2917    // -- Test 22: walk_list_with_elements ----------------------------------
2918
2919    #[test]
2920    fn walk_list_with_elements() {
2921        // LIST(slot=0, item_slot=1): body = <li>DYN_TEXT(slot=1)</li>
2922        // slot 0 = Array([Text("A"), Text("B")])
2923        // strings: 0="items", 1="item", 2="li"
2924        let mut body = Vec::new();
2925        body.extend_from_slice(&encode_open_tag(2, &[]));
2926        body.extend_from_slice(&encode_dyn_text(1, 0));
2927        body.extend_from_slice(&encode_close_tag(2));
2928
2929        let opcodes = encode_list(0, 1, &body);
2930
2931        let mut slots = SlotData::new(2);
2932        slots.set(
2933            0,
2934            SlotValue::array(vec![
2935                SlotValue::Text("A".to_string()),
2936                SlotValue::Text("B".to_string()),
2937            ]),
2938        );
2939
2940        let html = walk_with_slots(
2941            &["items", "item", "li"],
2942            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2943            &opcodes,
2944            &slots,
2945        );
2946        assert!(
2947            html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2948            "should render li elements with content, got: {html}"
2949        );
2950    }
2951
2952    // -- Test 23: walk_list_null_slot --------------------------------------
2953
2954    #[test]
2955    fn walk_list_null_slot() {
2956        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2957        // slot 0 = Null (not Array) → no iteration, empty markers
2958        let body = encode_dyn_text(1, 0);
2959        let opcodes = encode_list(0, 1, &body);
2960
2961        let slots = SlotData::new(2); // slot 0 remains Null
2962
2963        let html = walk_with_slots(
2964            &["items", "item"],
2965            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2966            &opcodes,
2967            &slots,
2968        );
2969        assert_eq!(
2970            html, "<!--f:l0--><!--/f:l0-->",
2971            "null slot should produce only markers"
2972        );
2973    }
2974
2975    // -- Test 24: walk_list_nested -----------------------------------------
2976
2977    #[test]
2978    fn walk_list_nested() {
2979        // Outer LIST(slot=0, item_slot=1): each item is an Array
2980        // Inner LIST(slot=1, item_slot=2): each sub-item is Text
2981        // Body of inner: DYN_TEXT(slot=2, marker=0)
2982        // strings: 0="outer", 1="inner", 2="item"
2983        let inner_body = encode_dyn_text(2, 0);
2984        let inner_list = encode_list(1, 2, &inner_body);
2985        let opcodes = encode_list(0, 1, &inner_list);
2986
2987        let mut slots = SlotData::new(3);
2988        slots.set(
2989            0,
2990            SlotValue::array(vec![
2991                SlotValue::array(vec![
2992                    SlotValue::Text("a".to_string()),
2993                    SlotValue::Text("b".to_string()),
2994                ]),
2995                SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2996            ]),
2997        );
2998
2999        let html = walk_with_slots(
3000            &["outer", "inner", "item"],
3001            &[
3002                (0, 0, 0x04, 0x00, &[]),
3003                (1, 1, 0x04, 0x00, &[]),
3004                (2, 2, 0x01, 0x00, &[]),
3005            ],
3006            &opcodes,
3007            &slots,
3008        );
3009        // Verify all items rendered
3010        assert!(html.contains("a"), "should contain nested item 'a'");
3011        assert!(html.contains("b"), "should contain nested item 'b'");
3012        assert!(html.contains("c"), "should contain nested item 'c'");
3013        // Verify outer list markers
3014        assert!(
3015            html.starts_with("<!--f:l0-->"),
3016            "should have outer list open marker"
3017        );
3018        assert!(
3019            html.ends_with("<!--/f:l0-->"),
3020            "should have outer list close marker"
3021        );
3022        // Verify inner list markers present
3023        assert!(
3024            html.contains("<!--f:l1-->"),
3025            "should have inner list open marker"
3026        );
3027        assert!(
3028            html.contains("<!--/f:l1-->"),
3029            "should have inner list close marker"
3030        );
3031    }
3032
3033    // -- Test 25: walk_list_depth_exceeded ---------------------------------
3034
3035    #[test]
3036    fn walk_list_depth_exceeded() {
3037        // Nest 5 lists deep (exceeds MAX_LIST_DEPTH of 4)
3038        // Innermost body is a DYN_TEXT
3039        // strings: 0..=5 = "s0".."s5"
3040        let innermost_body = encode_dyn_text(5, 0);
3041        // Build from inside out: level 5 (innermost) → level 1 (outermost)
3042        let level5 = encode_list(4, 5, &innermost_body);
3043        let level4 = encode_list(3, 4, &level5);
3044        let level3 = encode_list(2, 3, &level4);
3045        let level2 = encode_list(1, 2, &level3);
3046        let level1 = encode_list(0, 1, &level2);
3047
3048        let mut slots = SlotData::new(6);
3049        // Each level needs an array with at least one item to trigger iteration
3050        slots.set(
3051            0,
3052            SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
3053                SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
3054                    "deep".to_string(),
3055                )])]),
3056            ])])]),
3057        );
3058        // Inner slots get shadowed during iteration
3059
3060        let data = build_minimal_ir(
3061            &["s0", "s1", "s2", "s3", "s4", "s5"],
3062            &[
3063                (0, 0, 0x04, 0x00, &[]),
3064                (1, 1, 0x04, 0x00, &[]),
3065                (2, 2, 0x04, 0x00, &[]),
3066                (3, 3, 0x04, 0x00, &[]),
3067                (4, 4, 0x04, 0x00, &[]),
3068                (5, 5, 0x01, 0x00, &[]),
3069            ],
3070            &level1,
3071            &[],
3072        );
3073        let module = IrModule::parse(&data).unwrap();
3074        let result = walk_to_html(&module, &slots);
3075        assert!(result.is_err(), "should fail with depth exceeded");
3076        match result.unwrap_err() {
3077            IrError::ListDepthExceeded { max: 4 } => {}
3078            other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
3079        }
3080    }
3081
3082    // -- Test 26: walk_switch_matching_case --------------------------------
3083
3084    #[test]
3085    fn walk_switch_matching_case() {
3086        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
3087        // slot 0 = Text("about") → render "About"
3088        // strings: 0="page", 1="home", 2="Home", 3="about", 4="About"
3089        let case_home_body = encode_text(2); // TEXT "Home"
3090        let case_about_body = encode_text(4); // TEXT "About"
3091        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
3092
3093        let mut slots = SlotData::new(1);
3094        slots.set(0, SlotValue::Text("about".to_string()));
3095
3096        let html = walk_with_slots(
3097            &["page", "home", "Home", "about", "About"],
3098            &[(0, 0, 0x01, 0x00, &[])],
3099            &opcodes,
3100            &slots,
3101        );
3102        assert!(html.contains("About"), "should contain matching case text");
3103        assert!(
3104            !html.contains("Home"),
3105            "should NOT contain non-matching case text"
3106        );
3107    }
3108
3109    // -- Test 27: walk_switch_no_match ------------------------------------
3110
3111    #[test]
3112    fn walk_switch_no_match() {
3113        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
3114        // slot 0 = Text("missing") → no match, empty output
3115        // strings: 0="page", 1="home", 2="Home", 3="about", 4="About"
3116        let case_home_body = encode_text(2);
3117        let case_about_body = encode_text(4);
3118        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
3119
3120        let mut slots = SlotData::new(1);
3121        slots.set(0, SlotValue::Text("missing".to_string()));
3122
3123        let html = walk_with_slots(
3124            &["page", "home", "Home", "about", "About"],
3125            &[(0, 0, 0x01, 0x00, &[])],
3126            &opcodes,
3127            &slots,
3128        );
3129        assert!(
3130            html.is_empty(),
3131            "no match should produce empty output, got: {html}"
3132        );
3133    }
3134
3135    // -- Test 28: walk_switch_first_case ----------------------------------
3136
3137    #[test]
3138    fn walk_switch_first_case() {
3139        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
3140        // slot 0 = Text("home") → matches first case, render "Home"
3141        let case_home_body = encode_text(2);
3142        let case_about_body = encode_text(4);
3143        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
3144
3145        let mut slots = SlotData::new(1);
3146        slots.set(0, SlotValue::Text("home".to_string()));
3147
3148        let html = walk_with_slots(
3149            &["page", "home", "Home", "about", "About"],
3150            &[(0, 0, 0x01, 0x00, &[])],
3151            &opcodes,
3152            &slots,
3153        );
3154        assert!(html.contains("Home"), "should contain first case text");
3155        assert!(
3156            !html.contains("About"),
3157            "should NOT contain second case text"
3158        );
3159    }
3160
3161    #[test]
3162    fn walk_switch_stops_at_the_first_match() {
3163        // Two cases carrying the SAME value. Without a break the walker
3164        // rendered both bodies concatenated — a `switch` statement does not.
3165        let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
3166
3167        let mut slots = SlotData::new(1);
3168        slots.set(0, SlotValue::Text("home".to_string()));
3169
3170        let html = walk_with_slots(
3171            &["page", "home", "FIRST", "SECOND"],
3172            &[(0, 0, 0x01, 0x00, &[])],
3173            &opcodes,
3174            &slots,
3175        );
3176        assert_eq!(html, "FIRST");
3177    }
3178
3179    // -- Test 29: walk_switch_with_elements -------------------------------
3180
3181    #[test]
3182    fn walk_switch_with_elements() {
3183        // SWITCH(slot=0): matching case body = <span>Active</span>
3184        // strings: 0="page", 1="active", 2="span", 3="Active", 4="inactive", 5="Inactive"
3185        let mut active_body = Vec::new();
3186        active_body.extend_from_slice(&encode_open_tag(2, &[]));
3187        active_body.extend_from_slice(&encode_text(3));
3188        active_body.extend_from_slice(&encode_close_tag(2));
3189
3190        let inactive_body = encode_text(5);
3191        let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
3192
3193        let mut slots = SlotData::new(1);
3194        slots.set(0, SlotValue::Text("active".to_string()));
3195
3196        let html = walk_with_slots(
3197            &["page", "active", "span", "Active", "inactive", "Inactive"],
3198            &[(0, 0, 0x01, 0x00, &[])],
3199            &opcodes,
3200            &slots,
3201        );
3202        assert_eq!(
3203            html, "<span>Active</span>",
3204            "should produce full HTML element from matching case"
3205        );
3206    }
3207
3208    // -- Test 30: walk_try_renders_main -----------------------------------
3209
3210    #[test]
3211    fn walk_try_renders_main() {
3212        // TRY_START with main body TEXT "Main" + FALLBACK TEXT "Fallback"
3213        // strings: 0="Main", 1="Fallback"
3214        let main_ops = encode_text(0);
3215        let fallback_ops = encode_text(1);
3216        let opcodes = encode_try(&main_ops, &fallback_ops);
3217
3218        let html = walk_static(&["Main", "Fallback"], &opcodes);
3219        assert!(html.contains("Main"), "should contain main body text");
3220        assert!(
3221            !html.contains("Fallback"),
3222            "should NOT contain fallback text"
3223        );
3224    }
3225
3226    // -- Test 31: walk_preload_font ---------------------------------------
3227
3228    #[test]
3229    fn walk_preload_font() {
3230        // PRELOAD resource_type=1 (font), url="/_assets/dm-mono.woff2"
3231        // strings: 0="/_assets/dm-mono.woff2"
3232        let opcodes = encode_preload(1, 0);
3233
3234        let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
3235        assert!(
3236            html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
3237            "should produce preload link for font, got: {html}"
3238        );
3239    }
3240
3241    // -- Test 32: walk_preload_style --------------------------------------
3242
3243    #[test]
3244    fn walk_preload_style() {
3245        // PRELOAD resource_type=2 (style), url="/_assets/styles.css"
3246        // strings: 0="/_assets/styles.css"
3247        let opcodes = encode_preload(2, 0);
3248
3249        let html = walk_static(&["/_assets/styles.css"], &opcodes);
3250        assert!(
3251            html.contains(r#"as="style""#),
3252            "should contain as=\"style\", got: {html}"
3253        );
3254        assert!(
3255            html.contains(r#"href="/_assets/styles.css""#),
3256            "should contain correct href, got: {html}"
3257        );
3258    }
3259
3260    // =========================================================================
3261    // Hydration-roundtrip tests — verify walker produces exact expected HTML
3262    // from programmatically-built IR. These tests complement the TS-side
3263    // roundtrip tests (ir-roundtrip.test.ts) to ensure the binary contract
3264    // between the TS emitter and the Rust walker is correct.
3265    // =========================================================================
3266
3267    // -- Roundtrip 1: Full page skeleton ------------------------------------
3268
3269    #[test]
3270    fn roundtrip_page_structure() {
3271        // Build IR for a realistic page skeleton:
3272        // <html lang="en"><head><meta charset="utf-8"><title>Test</title></head>
3273        // <body><div id="app"></div></body></html>
3274        //
3275        // strings: 0="html", 1="lang", 2="en", 3="head", 4="meta",
3276        //          5="charset", 6="utf-8", 7="title", 8="Test",
3277        //          9="body", 10="div", 11="id", 12="app"
3278        let mut opcodes = Vec::new();
3279        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <html lang="en">
3280        opcodes.extend_from_slice(&encode_open_tag(3, &[])); // <head>
3281        opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); // <meta charset="utf-8">
3282        opcodes.extend_from_slice(&encode_open_tag(7, &[])); // <title>
3283        opcodes.extend_from_slice(&encode_text(8)); // Test
3284        opcodes.extend_from_slice(&encode_close_tag(7)); // </title>
3285        opcodes.extend_from_slice(&encode_close_tag(3)); // </head>
3286        opcodes.extend_from_slice(&encode_open_tag(9, &[])); // <body>
3287        opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); // <div id="app">
3288        opcodes.extend_from_slice(&encode_close_tag(10)); // </div>
3289        opcodes.extend_from_slice(&encode_close_tag(9)); // </body>
3290        opcodes.extend_from_slice(&encode_close_tag(0)); // </html>
3291
3292        let ir = build_minimal_ir(
3293            &[
3294                "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
3295                "div", "id", "app",
3296            ],
3297            &[],
3298            &opcodes,
3299            &[],
3300        );
3301        let module = IrModule::parse(&ir).unwrap();
3302        let slots = SlotData::new(0);
3303        let html = walk_to_html(&module, &slots).unwrap();
3304        assert_eq!(
3305            html,
3306            r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
3307        );
3308    }
3309
3310    // -- Roundtrip 2: Dynamic greeting with DYN_TEXT ------------------------
3311
3312    #[test]
3313    fn roundtrip_dynamic_greeting() {
3314        // Build IR for: <div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>
3315        // with slot 0 = Text("Hello, World")
3316        //
3317        // strings: 0="div", 1="class", 2="greeting", 3="content"
3318        // slot decl: slot_id=0, name_str_idx=3 ("content"), type=Text
3319        let mut opcodes = Vec::new();
3320        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <div class="greeting">
3321        opcodes.extend_from_slice(&encode_dyn_text(0, 0)); // DYN_TEXT slot=0 marker=0
3322        opcodes.extend_from_slice(&encode_close_tag(0)); // </div>
3323
3324        let ir = build_minimal_ir(
3325            &["div", "class", "greeting", "content"],
3326            &[(0, 3, 0x01, 0x00, &[])], // slot 0, name="content", type=Text
3327            &opcodes,
3328            &[],
3329        );
3330        let module = IrModule::parse(&ir).unwrap();
3331        let mut slots = SlotData::new(1);
3332        slots.set(0, SlotValue::Text("Hello, World".into()));
3333        let html = walk_to_html(&module, &slots).unwrap();
3334        assert_eq!(
3335            html,
3336            r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
3337        );
3338    }
3339
3340    // -- Roundtrip 3: Conditional content (SHOW_IF both branches) -----------
3341
3342    #[test]
3343    fn roundtrip_conditional_content() {
3344        // SHOW_IF slot=0
3345        //   then: <span class="badge">Logged In</span>
3346        //   else: <span class="badge">Guest</span>
3347        //
3348        // strings: 0="auth", 1="span", 2="class", 3="badge",
3349        //          4="Logged In", 5="Guest"
3350        let mut then_ops = Vec::new();
3351        then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3352        then_ops.extend_from_slice(&encode_text(4));
3353        then_ops.extend_from_slice(&encode_close_tag(1));
3354
3355        let mut else_ops = Vec::new();
3356        else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3357        else_ops.extend_from_slice(&encode_text(5));
3358        else_ops.extend_from_slice(&encode_close_tag(1));
3359
3360        let opcodes = encode_show_if(0, &then_ops, &else_ops);
3361
3362        let ir = build_minimal_ir(
3363            &["auth", "span", "class", "badge", "Logged In", "Guest"],
3364            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="auth", type=Bool
3365            &opcodes,
3366            &[],
3367        );
3368
3369        // Test true branch
3370        {
3371            let module = IrModule::parse(&ir).unwrap();
3372            let mut slots = SlotData::new(1);
3373            slots.set(0, SlotValue::Bool(true));
3374            let html = walk_to_html(&module, &slots).unwrap();
3375            assert_eq!(
3376                html,
3377                r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3378            );
3379        }
3380
3381        // Test false branch
3382        {
3383            let module = IrModule::parse(&ir).unwrap();
3384            let mut slots = SlotData::new(1);
3385            slots.set(0, SlotValue::Bool(false));
3386            let html = walk_to_html(&module, &slots).unwrap();
3387            assert_eq!(
3388                html,
3389                r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3390            );
3391        }
3392    }
3393
3394    // -- Roundtrip 4: List rendering ----------------------------------------
3395
3396    #[test]
3397    fn roundtrip_list_rendering() {
3398        // LIST slot=0 item=1 → <li><!--f:t0-->{item}<!--/f:t0--></li>
3399        // Items: ["Alice", "Bob", "Charlie"]
3400        //
3401        // strings: 0="items", 1="item", 2="li"
3402        // slot decls: slot 0=Array, slot 1=Text (item)
3403        let mut body = Vec::new();
3404        body.extend_from_slice(&encode_open_tag(2, &[])); // <li>
3405        body.extend_from_slice(&encode_dyn_text(1, 0)); // DYN_TEXT slot=1 marker=0
3406        body.extend_from_slice(&encode_close_tag(2)); // </li>
3407
3408        let opcodes = encode_list(0, 1, &body);
3409
3410        let ir = build_minimal_ir(
3411            &["items", "item", "li"],
3412            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
3413            &opcodes,
3414            &[],
3415        );
3416        let module = IrModule::parse(&ir).unwrap();
3417        let mut slots = SlotData::new(2);
3418        slots.set(
3419            0,
3420            SlotValue::array(vec![
3421                SlotValue::Text("Alice".into()),
3422                SlotValue::Text("Bob".into()),
3423                SlotValue::Text("Charlie".into()),
3424            ]),
3425        );
3426        let html = walk_to_html(&module, &slots).unwrap();
3427        assert_eq!(
3428            html,
3429            "<!--f:l0-->\
3430             <li><!--f:t0-->Alice<!--/f:t0--></li>\
3431             <li><!--f:t0-->Bob<!--/f:t0--></li>\
3432             <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3433             <!--/f:l0-->"
3434        );
3435    }
3436
3437    // -- Roundtrip 5: Switch view -------------------------------------------
3438
3439    #[test]
3440    fn roundtrip_switch_view() {
3441        // SWITCH slot=0, cases: "home" → <h1>Home</h1>, "about" → <h1>About</h1>
3442        // slot = "about"
3443        //
3444        // strings: 0="page", 1="home", 2="h1", 3="Home", 4="about", 5="About"
3445        let mut home_body = Vec::new();
3446        home_body.extend_from_slice(&encode_open_tag(2, &[]));
3447        home_body.extend_from_slice(&encode_text(3));
3448        home_body.extend_from_slice(&encode_close_tag(2));
3449
3450        let mut about_body = Vec::new();
3451        about_body.extend_from_slice(&encode_open_tag(2, &[]));
3452        about_body.extend_from_slice(&encode_text(5));
3453        about_body.extend_from_slice(&encode_close_tag(2));
3454
3455        let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
3456
3457        let ir = build_minimal_ir(
3458            &["page", "home", "h1", "Home", "about", "About"],
3459            &[(0, 0, 0x01, 0x00, &[])], // slot 0, name="page", type=Text
3460            &opcodes,
3461            &[],
3462        );
3463        let module = IrModule::parse(&ir).unwrap();
3464        let mut slots = SlotData::new(1);
3465        slots.set(0, SlotValue::Text("about".into()));
3466        let html = walk_to_html(&module, &slots).unwrap();
3467        assert_eq!(html, "<h1>About</h1>");
3468    }
3469
3470    // -- Roundtrip 6: Nested realistic structure ----------------------------
3471
3472    #[test]
3473    fn roundtrip_nested_structure() {
3474        // Realistic page fragment: nav with conditional auth + list of links
3475        //
3476        // <nav class="main-nav">
3477        //   <!--f:s0-->  (SHOW_IF: logged in?)
3478        //     <span>Welcome</span>
3479        //   <!--/f:s0-->
3480        //   <ul>
3481        //     <!--f:l1-->  (LIST: nav items)
3482        //       <li><!--f:t0-->{item}<!--/f:t0--></li>
3483        //     <!--/f:l1-->
3484        //   </ul>
3485        // </nav>
3486        //
3487        // strings: 0="nav", 1="class", 2="main-nav", 3="auth",
3488        //          4="span", 5="Welcome", 6="items", 7="item",
3489        //          8="ul", 9="li"
3490        //
3491        // slots: 0=Bool (auth), 1=Array (items), 2=Text (item shadow)
3492
3493        // SHOW_IF then body: <span>Welcome</span>
3494        let mut then_ops = Vec::new();
3495        then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3496        then_ops.extend_from_slice(&encode_text(5));
3497        then_ops.extend_from_slice(&encode_close_tag(4));
3498
3499        let show_if_ops = encode_show_if(0, &then_ops, &[]);
3500
3501        // LIST body: <li>DYN_TEXT(slot=2)</li>
3502        let mut list_body = Vec::new();
3503        list_body.extend_from_slice(&encode_open_tag(9, &[]));
3504        list_body.extend_from_slice(&encode_dyn_text(2, 0));
3505        list_body.extend_from_slice(&encode_close_tag(9));
3506
3507        let list_ops = encode_list(1, 2, &list_body);
3508
3509        // Full opcode stream
3510        let mut opcodes = Vec::new();
3511        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <nav class="main-nav">
3512        opcodes.extend_from_slice(&show_if_ops); // SHOW_IF auth
3513        opcodes.extend_from_slice(&encode_open_tag(8, &[])); // <ul>
3514        opcodes.extend_from_slice(&list_ops); // LIST items
3515        opcodes.extend_from_slice(&encode_close_tag(8)); // </ul>
3516        opcodes.extend_from_slice(&encode_close_tag(0)); // </nav>
3517
3518        let ir = build_minimal_ir(
3519            &[
3520                "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3521            ],
3522            &[
3523                (0, 3, 0x02, 0x00, &[]), // slot 0 = Bool (auth)
3524                (1, 6, 0x04, 0x00, &[]), // slot 1 = Array (items)
3525                (2, 7, 0x01, 0x00, &[]), // slot 2 = Text (item)
3526            ],
3527            &opcodes,
3528            &[],
3529        );
3530        let module = IrModule::parse(&ir).unwrap();
3531        let mut slots = SlotData::new(3);
3532        slots.set(0, SlotValue::Bool(true));
3533        slots.set(
3534            1,
3535            SlotValue::array(vec![
3536                SlotValue::Text("Home".into()),
3537                SlotValue::Text("About".into()),
3538            ]),
3539        );
3540
3541        let html = walk_to_html(&module, &slots).unwrap();
3542        assert_eq!(
3543            html,
3544            r#"<nav class="main-nav"><!--f:s0--><span>Welcome</span><!--/f:s0--><ul><!--f:l1--><li><!--f:t0-->Home<!--/f:t0--></li><li><!--f:t0-->About<!--/f:t0--></li><!--/f:l1--></ul></nav>"#
3545        );
3546    }
3547
3548    // =========================================================================
3549    // Island attribute injection tests
3550    // =========================================================================
3551
3552    // -- Test 33: walk_island_attrs_on_open_tag --------------------------------
3553
3554    #[test]
3555    fn walk_island_attrs_on_open_tag() {
3556        // ISLAND_START(0) + OPEN_TAG("div") + TEXT("Hello") + CLOSE_TAG("div") + ISLAND_END(0)
3557        // With island table entry: id=0, trigger=Load, props=Inline, name="AuthForm", no slots
3558        // strings: 0="div", 1="Hello", 2="AuthForm"
3559        let mut opcodes = Vec::new();
3560        opcodes.extend_from_slice(&encode_island_start(0));
3561        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3562        opcodes.extend_from_slice(&encode_text(1));
3563        opcodes.extend_from_slice(&encode_close_tag(0));
3564        opcodes.extend_from_slice(&encode_island_end(0));
3565
3566        let slots = SlotData::new(0);
3567        let html = walk_with_islands(
3568            &["div", "Hello", "AuthForm"],
3569            &[],
3570            // island: id=0, trigger=Load(0x01), props=Inline(0x01), name_str_idx=2, byte_offset=0, no slot_ids
3571            &[(0, 0x01, 0x01, 2, 0, &[])],
3572            &opcodes,
3573            &slots,
3574        );
3575        assert_eq!(
3576            html,
3577            r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
3578        );
3579    }
3580
3581    // -- Test 34: walk_island_attrs_with_existing_attrs -----------------------
3582
3583    #[test]
3584    fn walk_island_attrs_with_existing_attrs() {
3585        // ISLAND_START(0) + OPEN_TAG("div" class="form") + CLOSE_TAG + ISLAND_END(0)
3586        // strings: 0="div", 1="class", 2="form", 3="AuthForm"
3587        let mut opcodes = Vec::new();
3588        opcodes.extend_from_slice(&encode_island_start(0));
3589        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3590        opcodes.extend_from_slice(&encode_close_tag(0));
3591        opcodes.extend_from_slice(&encode_island_end(0));
3592
3593        let slots = SlotData::new(0);
3594        let html = walk_with_islands(
3595            &["div", "class", "form", "AuthForm"],
3596            &[],
3597            &[(0, 0x01, 0x01, 3, 0, &[])],
3598            &opcodes,
3599            &slots,
3600        );
3601        assert_eq!(
3602            html,
3603            r#"<!--f:i0--><div class="form" data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load"></div><!--/f:i0-->"#
3604        );
3605    }
3606
3607    // -- Test 35: walk_island_attrs_on_void_tag -------------------------------
3608
3609    #[test]
3610    fn walk_island_attrs_on_void_tag() {
3611        // ISLAND_START(0) + VOID_TAG("input") + ISLAND_END(0)
3612        // strings: 0="input", 1="Widget"
3613        let mut opcodes = Vec::new();
3614        opcodes.extend_from_slice(&encode_island_start(0));
3615        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3616        opcodes.extend_from_slice(&encode_island_end(0));
3617
3618        let slots = SlotData::new(0);
3619        let html = walk_with_islands(
3620            &["input", "Widget"],
3621            &[],
3622            &[(0, 0x01, 0x01, 1, 0, &[])],
3623            &opcodes,
3624            &slots,
3625        );
3626        assert_eq!(
3627            html,
3628            r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
3629        );
3630    }
3631
3632    // -- Test 36: walk_inline_props_attribute ---------------------------------
3633
3634    #[test]
3635    fn walk_inline_props_attribute() {
3636        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3637        // Island entry has slots [0, 1], PropsMode::Inline
3638        // Slot 0 = "title" (Text), Slot 1 = "count" (Number)
3639        // strings: 0="div", 1="MyComponent", 2="title", 3="count"
3640        let mut opcodes = Vec::new();
3641        opcodes.extend_from_slice(&encode_island_start(0));
3642        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3643        opcodes.extend_from_slice(&encode_close_tag(0));
3644        opcodes.extend_from_slice(&encode_island_end(0));
3645
3646        let mut slots = SlotData::new(2);
3647        slots.set(0, SlotValue::Text("Hello".to_string()));
3648        slots.set(1, SlotValue::Number(42.0));
3649
3650        let html = walk_with_islands(
3651            &["div", "MyComponent", "title", "count"],
3652            &[
3653                (0, 2, 0x01, 0x00, &[]), // slot 0, name="title", Text, Server
3654                (1, 3, 0x03, 0x00, &[]), // slot 1, name="count", Number, Server
3655            ],
3656            // island: id=0, Load, Inline, name_str_idx=1, byte_offset=0, slot_ids=[0, 1]
3657            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3658            &opcodes,
3659            &slots,
3660        );
3661        // Props should be JSON: {"title":"Hello","count":42}
3662        assert!(
3663            html.contains("data-forma-props="),
3664            "should contain data-forma-props, got: {html}"
3665        );
3666        assert!(
3667            html.contains(r#"data-forma-island="0""#),
3668            "should contain island id, got: {html}"
3669        );
3670        assert!(
3671            html.contains(r#"data-forma-component="MyComponent""#),
3672            "should contain component name, got: {html}"
3673        );
3674        // Parse the JSON from the props attribute to verify correctness.
3675        // The attribute uses double quotes with HTML entity escaping:
3676        //   data-forma-props="{&quot;title&quot;:&quot;Hello&quot;,&quot;count&quot;:42}"
3677        // Browsers auto-decode &quot; back to " when reading getAttribute(),
3678        // so we simulate that here by reversing the entity encoding.
3679        let marker = "data-forma-props=\"";
3680        let props_start = html.find(marker).unwrap() + marker.len();
3681        let props_end = html[props_start..].find('"').unwrap() + props_start;
3682        let raw_attr = &html[props_start..props_end];
3683        let decoded = raw_attr
3684            .replace("&quot;", "\"")
3685            .replace("&amp;", "&")
3686            .replace("&lt;", "<")
3687            .replace("&gt;", ">");
3688        let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3689        assert_eq!(props_json["title"], "Hello");
3690        assert_eq!(props_json["count"], 42);
3691    }
3692
3693    // -- Test 37: walk_list_item_key_on_first_open_tag -----------------------
3694
3695    #[test]
3696    fn walk_list_item_key_on_first_open_tag() {
3697        // LIST_ITEM_KEY(key_str_idx=1) + OPEN_TAG("li") + TEXT("item") + CLOSE_TAG
3698        // strings: 0="li", 1="user-123", 2="item"
3699        let mut opcodes = Vec::new();
3700        opcodes.extend_from_slice(&encode_list_item_key(1));
3701        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3702        opcodes.extend_from_slice(&encode_text(2));
3703        opcodes.extend_from_slice(&encode_close_tag(0));
3704
3705        let html = walk_static(&["li", "user-123", "item"], &opcodes);
3706        assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
3707    }
3708
3709    // -- Test 38: walk_list_item_key_on_void_tag -----------------------------
3710
3711    #[test]
3712    fn walk_list_item_key_on_void_tag() {
3713        // LIST_ITEM_KEY(key_str_idx=1) + VOID_TAG("hr")
3714        // strings: 0="hr", 1="key-42"
3715        let mut opcodes = Vec::new();
3716        opcodes.extend_from_slice(&encode_list_item_key(1));
3717        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3718
3719        let html = walk_static(&["hr", "key-42"], &opcodes);
3720        assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
3721    }
3722
3723    // -- Test 39: walk_list_item_key_with_attrs ------------------------------
3724
3725    #[test]
3726    fn walk_list_item_key_with_attrs() {
3727        // LIST_ITEM_KEY + OPEN_TAG("li" class="item") + TEXT + CLOSE_TAG
3728        // strings: 0="li", 1="class", 2="item", 3="k1", 4="content"
3729        let mut opcodes = Vec::new();
3730        opcodes.extend_from_slice(&encode_list_item_key(3));
3731        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3732        opcodes.extend_from_slice(&encode_text(4));
3733        opcodes.extend_from_slice(&encode_close_tag(0));
3734
3735        let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
3736        assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
3737    }
3738
3739    // -- Test 40: walk_script_tag_props_mode ---------------------------------
3740
3741    #[test]
3742    fn walk_script_tag_props_mode() {
3743        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3744        // Island entry: PropsMode::ScriptTag with slots [0]
3745        // strings: 0="div", 1="Counter", 2="label"
3746        let mut opcodes = Vec::new();
3747        opcodes.extend_from_slice(&encode_island_start(0));
3748        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3749        opcodes.extend_from_slice(&encode_close_tag(0));
3750        opcodes.extend_from_slice(&encode_island_end(0));
3751
3752        let mut slots = SlotData::new(1);
3753        slots.set(0, SlotValue::Text("Click me".to_string()));
3754
3755        let html = walk_with_islands(
3756            &["div", "Counter", "label"],
3757            &[(0, 2, 0x01, 0x00, &[])], // slot 0, name="label", Text, Server
3758            // island: id=0, Load, ScriptTag(0x02), name_str_idx=1, byte_offset=0, slot_ids=[0]
3759            &[(0, 0x01, 0x02, 1, 0, &[0])],
3760            &opcodes,
3761            &slots,
3762        );
3763        // Should NOT have inline data-forma-props
3764        assert!(
3765            !html.contains("data-forma-props"),
3766            "ScriptTag mode should not emit inline props, got: {html}"
3767        );
3768        // Should have island attrs
3769        assert!(
3770            html.contains(r#"data-forma-island="0""#),
3771            "should contain island id"
3772        );
3773        // Should have script tag at the end
3774        assert!(
3775            html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3776            "should contain script tag, got: {html}"
3777        );
3778        assert!(html.contains("</script>"), "should close script tag");
3779        // Parse the JSON from the script tag
3780        let script_start =
3781            html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3782        let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3783        let json: serde_json::Value =
3784            serde_json::from_str(&html[script_start..script_end]).unwrap();
3785        assert_eq!(json["0"]["label"], "Click me");
3786    }
3787
3788    // -- Test 41: walk_deferred_props_mode -----------------------------------
3789
3790    #[test]
3791    fn walk_deferred_props_mode() {
3792        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3793        // Island entry: PropsMode::Deferred — should not emit props at all
3794        // strings: 0="div", 1="LazyWidget", 2="data"
3795        let mut opcodes = Vec::new();
3796        opcodes.extend_from_slice(&encode_island_start(0));
3797        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3798        opcodes.extend_from_slice(&encode_close_tag(0));
3799        opcodes.extend_from_slice(&encode_island_end(0));
3800
3801        let mut slots = SlotData::new(1);
3802        slots.set(0, SlotValue::Text("ignored".to_string()));
3803
3804        let html = walk_with_islands(
3805            &["div", "LazyWidget", "data"],
3806            &[(0, 2, 0x01, 0x00, &[])],
3807            // island: id=0, Load, Deferred(0x03), name_str_idx=1, byte_offset=0, slot_ids=[0]
3808            &[(0, 0x01, 0x03, 1, 0, &[0])],
3809            &opcodes,
3810            &slots,
3811        );
3812        assert!(
3813            !html.contains("data-forma-props"),
3814            "Deferred mode should not emit inline props, got: {html}"
3815        );
3816        assert!(
3817            !html.contains("__forma_islands"),
3818            "Deferred mode should not emit script tag, got: {html}"
3819        );
3820        // Should still have island attrs
3821        assert!(
3822            html.contains(r#"data-forma-island="0""#),
3823            "should contain island id"
3824        );
3825        assert!(
3826            html.contains(r#"data-forma-component="LazyWidget""#),
3827            "should contain component name"
3828        );
3829    }
3830
3831    // -- Test 42: walk_island_no_entry_in_table -------------------------------
3832
3833    #[test]
3834    fn walk_island_no_entry_in_table() {
3835        // An ISLAND_START whose id is absent from the island table used to
3836        // render happily: markers, but no `data-forma-*` attributes, so the
3837        // client could never hydrate that island and nothing said why. It is
3838        // now a load-time error instead of an invisible one at runtime.
3839        let mut opcodes = Vec::new();
3840        opcodes.extend_from_slice(&encode_island_start(5));
3841        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3842        opcodes.extend_from_slice(&encode_text(1));
3843        opcodes.extend_from_slice(&encode_close_tag(0));
3844        opcodes.extend_from_slice(&encode_island_end(5));
3845
3846        let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3847        assert_eq!(
3848            IrModule::parse(&data).unwrap_err(),
3849            IrError::UnknownIslandId(5)
3850        );
3851    }
3852
3853    #[test]
3854    fn walk_nested_island_start_before_a_root_element_is_an_error() {
3855        // Only one element can carry a data-forma-island set, so an island
3856        // that opens another island before its own root element would have
3857        // silently lost its identity — never hydrated, never disposed.
3858        let mut opcodes = Vec::new();
3859        opcodes.extend_from_slice(&encode_island_start(0));
3860        opcodes.extend_from_slice(&encode_island_start(1));
3861        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3862        opcodes.extend_from_slice(&encode_close_tag(0));
3863        opcodes.extend_from_slice(&encode_island_end(1));
3864        opcodes.extend_from_slice(&encode_island_end(0));
3865
3866        let slots = SlotData::new(0);
3867        let data = build_minimal_ir(
3868            &["div", "Outer", "Inner"],
3869            &[],
3870            &opcodes,
3871            &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3872        );
3873        let module = IrModule::parse(&data).unwrap();
3874        assert_eq!(
3875            walk_to_html(&module, &slots).unwrap_err(),
3876            IrError::NestedIslandRoot { outer: 0, inner: 1 }
3877        );
3878    }
3879
3880    #[test]
3881    fn walk_island_nested_inside_another_islands_root_is_fine() {
3882        // The legal shape: each island opens its own element first.
3883        let mut opcodes = Vec::new();
3884        opcodes.extend_from_slice(&encode_island_start(0));
3885        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3886        opcodes.extend_from_slice(&encode_island_start(1));
3887        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3888        opcodes.extend_from_slice(&encode_close_tag(0));
3889        opcodes.extend_from_slice(&encode_island_end(1));
3890        opcodes.extend_from_slice(&encode_close_tag(0));
3891        opcodes.extend_from_slice(&encode_island_end(0));
3892
3893        let slots = SlotData::new(0);
3894        let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3895        let html = walk_with_islands(
3896            &["div", "Outer", "Inner"],
3897            &[],
3898            &[
3899                (0, 0x01, 0x01, 1, 0, &[]),
3900                (1, 0x01, 0x01, 2, inner_offset, &[]),
3901            ],
3902            &opcodes,
3903            &slots,
3904        );
3905        assert!(html.contains(r#"data-forma-component="Outer""#));
3906        assert!(html.contains(r#"data-forma-component="Inner""#));
3907    }
3908
3909    // -- Test 43: walk_inline_props_empty_slots ------------------------------
3910
3911    #[test]
3912    fn walk_inline_props_empty_slots() {
3913        // Island with Inline props mode but no slot_ids — should not emit data-forma-props
3914        // strings: 0="div", 1="Simple"
3915        let mut opcodes = Vec::new();
3916        opcodes.extend_from_slice(&encode_island_start(0));
3917        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3918        opcodes.extend_from_slice(&encode_close_tag(0));
3919        opcodes.extend_from_slice(&encode_island_end(0));
3920
3921        let slots = SlotData::new(0);
3922        let html = walk_with_islands(
3923            &["div", "Simple"],
3924            &[],
3925            &[(0, 0x01, 0x01, 1, 0, &[])], // no slot_ids
3926            &opcodes,
3927            &slots,
3928        );
3929        assert!(
3930            !html.contains("data-forma-props"),
3931            "empty slot_ids should not emit props, got: {html}"
3932        );
3933        assert!(
3934            html.contains(r#"data-forma-island="0""#),
3935            "should contain island id"
3936        );
3937    }
3938
3939    // -- Inline props size ceiling (F8) --------------------------------------
3940
3941    /// Build a one-island page whose single prop slot holds `payload`.
3942    fn walk_island_with_prop_payload(payload: &str) -> String {
3943        let mut opcodes = Vec::new();
3944        opcodes.extend_from_slice(&encode_island_start(0));
3945        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3946        opcodes.extend_from_slice(&encode_close_tag(0));
3947        opcodes.extend_from_slice(&encode_island_end(0));
3948
3949        let mut slots = SlotData::new(1);
3950        slots.set(0, SlotValue::Text(payload.to_string()));
3951        walk_with_islands(
3952            &["div", "Big", "blob"],
3953            &[(0, 2, 0x01, 0x00, &[])],
3954            &[(0, 0x01, 0x01, 1, 0, &[0])],
3955            &opcodes,
3956            &slots,
3957        )
3958    }
3959
3960    #[test]
3961    fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3962        // Slots 0 and 1 are both named "dup". The three name-resolving paths
3963        // in this crate used to pick differently (first / last / last by
3964        // ascending id); they all keep the first now.
3965        let mut opcodes = Vec::new();
3966        opcodes.extend_from_slice(&encode_island_start(0));
3967        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3968        opcodes.extend_from_slice(&encode_close_tag(0));
3969        opcodes.extend_from_slice(&encode_island_end(0));
3970
3971        let mut slots = SlotData::new(2);
3972        slots.set(0, SlotValue::Text("first".into()));
3973        slots.set(1, SlotValue::Text("second".into()));
3974
3975        let html = walk_with_islands(
3976            &["div", "Comp", "dup"],
3977            &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3978            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3979            &opcodes,
3980            &slots,
3981        );
3982        assert!(
3983            html.contains(r#"data-forma-props="{&quot;dup&quot;:&quot;first&quot;}""#),
3984            "expected the first slot to win, got: {html}"
3985        );
3986    }
3987
3988    #[test]
3989    fn inline_props_under_the_ceiling_stay_inline() {
3990        let html = walk_island_with_prop_payload("small");
3991        assert!(html.contains(r#"data-forma-props="{&quot;blob&quot;:&quot;small&quot;}""#));
3992        assert!(!html.contains("__forma_islands"));
3993    }
3994
3995    #[test]
3996    fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3997        // An unbounded `data-forma-props` is a multi-kilobyte attribute
3998        // repeated on every island root. Above the ceiling the payload moves
3999        // to the shared block — and to EXACTLY one of the two channels, since
4000        // the client reads the inline attribute first and would never look at
4001        // the block if both were present.
4002        let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
4003        let html = walk_island_with_prop_payload(&payload);
4004        assert!(
4005            !html.contains("data-forma-props"),
4006            "over-size props must not stay in the attribute"
4007        );
4008        assert!(
4009            html.contains(r#"<script id="__forma_islands" type="application/json">"#),
4010            "over-size props must land in the shared block, got: {}",
4011            &html[..html.len().min(200)]
4012        );
4013        assert!(html.contains(&payload), "the payload itself must survive");
4014    }
4015
4016    #[test]
4017    fn island_fragment_keeps_over_size_props_inline() {
4018        // A fragment cannot emit a second `__forma_islands` block: it would
4019        // duplicate a DOM id and, being earlier in document order after the
4020        // swap, shadow the page's own block for every other island.
4021        let mut opcodes = Vec::new();
4022        opcodes.extend_from_slice(&encode_island_start(0));
4023        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4024        opcodes.extend_from_slice(&encode_close_tag(0));
4025        opcodes.extend_from_slice(&encode_island_end(0));
4026
4027        let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
4028        let mut slots = SlotData::new(1);
4029        slots.set(0, SlotValue::Text(payload));
4030
4031        let data = build_minimal_ir(
4032            &["div", "Big", "blob"],
4033            &[(0, 2, 0x01, 0x00, &[])],
4034            &opcodes,
4035            &[(0, 0x01, 0x01, 1, 0, &[0])],
4036        );
4037        let module = IrModule::parse(&data).unwrap();
4038        let fragment = walk_island(&module, &slots, 0).unwrap();
4039        assert!(fragment.contains("data-forma-props"));
4040        assert!(!fragment.contains("__forma_islands"));
4041    }
4042
4043    // -- Test 44: walk_island_and_list_key_combined --------------------------
4044
4045    #[test]
4046    fn walk_island_and_list_key_combined() {
4047        // ISLAND_START(0) + LIST_ITEM_KEY + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
4048        // Both pending_island and pending_list_key active on same OPEN_TAG
4049        // strings: 0="div", 1="Card", 2="item-1"
4050        let mut opcodes = Vec::new();
4051        opcodes.extend_from_slice(&encode_island_start(0));
4052        opcodes.extend_from_slice(&encode_list_item_key(2));
4053        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4054        opcodes.extend_from_slice(&encode_close_tag(0));
4055        opcodes.extend_from_slice(&encode_island_end(0));
4056
4057        let slots = SlotData::new(0);
4058        let html = walk_with_islands(
4059            &["div", "Card", "item-1"],
4060            &[],
4061            &[(0, 0x01, 0x01, 1, 0, &[])],
4062            &opcodes,
4063            &slots,
4064        );
4065        assert!(
4066            html.contains(r#"data-forma-island="0""#),
4067            "should contain island attrs"
4068        );
4069        assert!(
4070            html.contains(r#"data-forma-key="item-1""#),
4071            "should contain list key attr, got: {html}"
4072        );
4073    }
4074
4075    // -- Test 45: walk_multiple_islands_script_tag ---------------------------
4076
4077    #[test]
4078    fn walk_multiple_islands_script_tag() {
4079        // Two islands, both ScriptTag mode, should produce a single script tag
4080        // with both entries
4081        // strings: 0="div", 1="span", 2="CompA", 3="CompB", 4="msg", 5="flag"
4082        let mut opcodes = Vec::new();
4083        // Island 0
4084        opcodes.extend_from_slice(&encode_island_start(0));
4085        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4086        opcodes.extend_from_slice(&encode_close_tag(0));
4087        opcodes.extend_from_slice(&encode_island_end(0));
4088        // Island 1 — its byte_offset must point at its own ISLAND_START.
4089        let island1_offset = opcodes.len() as u32;
4090        opcodes.extend_from_slice(&encode_island_start(1));
4091        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4092        opcodes.extend_from_slice(&encode_close_tag(1));
4093        opcodes.extend_from_slice(&encode_island_end(1));
4094
4095        let mut slots = SlotData::new(2);
4096        slots.set(0, SlotValue::Text("hello".to_string()));
4097        slots.set(1, SlotValue::Bool(true));
4098
4099        let html = walk_with_islands(
4100            &["div", "span", "CompA", "CompB", "msg", "flag"],
4101            &[
4102                (0, 4, 0x01, 0x00, &[]), // slot 0, name="msg", Text
4103                (1, 5, 0x02, 0x00, &[]), // slot 1, name="flag", Bool
4104            ],
4105            &[
4106                (0, 0x01, 0x02, 2, 0, &[0]), // island 0, ScriptTag, name="CompA", byte_offset=0, slots=[0]
4107                (1, 0x01, 0x02, 3, island1_offset, &[1]), // island 1, ScriptTag, name="CompB", slots=[1]
4108            ],
4109            &opcodes,
4110            &slots,
4111        );
4112        // Parse the script tag JSON
4113        let script_start =
4114            html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
4115        let script_end = html[script_start..].find("</script>").unwrap() + script_start;
4116        let json: serde_json::Value =
4117            serde_json::from_str(&html[script_start..script_end]).unwrap();
4118        assert_eq!(json["0"]["msg"], "hello");
4119        assert_eq!(json["1"]["flag"], true);
4120    }
4121
4122    // -- Test 46: walk_list_item_key_consumed_once --------------------------
4123
4124    #[test]
4125    fn walk_list_item_key_consumed_once() {
4126        // LIST_ITEM_KEY + OPEN_TAG("li") + OPEN_TAG("span") + TEXT + CLOSE_TAG("span") + CLOSE_TAG("li")
4127        // Key should only appear on the first tag (li), not span
4128        // strings: 0="li", 1="span", 2="abc", 3="text"
4129        let mut opcodes = Vec::new();
4130        opcodes.extend_from_slice(&encode_list_item_key(2));
4131        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4132        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4133        opcodes.extend_from_slice(&encode_text(3));
4134        opcodes.extend_from_slice(&encode_close_tag(1));
4135        opcodes.extend_from_slice(&encode_close_tag(0));
4136
4137        let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
4138        assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
4139    }
4140
4141    // -- Test 47: walk_slot_value_to_json -----------------------------------
4142
4143    #[test]
4144    fn slot_value_to_json_all_types() {
4145        // Verify SlotValue::to_json() for all variants
4146        assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
4147        assert_eq!(
4148            SlotValue::Text("hello".to_string()).to_json(),
4149            serde_json::Value::String("hello".to_string())
4150        );
4151        assert_eq!(
4152            SlotValue::Bool(true).to_json(),
4153            serde_json::Value::Bool(true)
4154        );
4155        assert_eq!(
4156            SlotValue::Bool(false).to_json(),
4157            serde_json::Value::Bool(false)
4158        );
4159        assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
4160        assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
4161        assert_eq!(
4162            SlotValue::array(vec![
4163                SlotValue::Text("a".to_string()),
4164                SlotValue::Number(1.0)
4165            ])
4166            .to_json(),
4167            serde_json::json!(["a", 1])
4168        );
4169        assert_eq!(
4170            SlotValue::object(vec![(
4171                "key".to_string(),
4172                SlotValue::Text("val".to_string())
4173            ),])
4174            .to_json(),
4175            serde_json::json!({"key": "val"})
4176        );
4177    }
4178
4179    // -- Test 48: dyn_attr_emits_attribute_with_value ----------------------
4180
4181    #[test]
4182    fn dyn_attr_emits_attribute_with_value() {
4183        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + CLOSE_TAG("input")
4184        // slot 0 = Text("password") → <input type="password"></input>
4185        // strings: 0="input", 1="type", 2="input_type" (slot name)
4186        let mut opcodes = Vec::new();
4187        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4188        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4189        opcodes.extend_from_slice(&encode_close_tag(0));
4190
4191        let mut slots = SlotData::new(1);
4192        slots.set(0, SlotValue::Text("password".to_string()));
4193
4194        let html = walk_with_slots(
4195            &["input", "type", "input_type"],
4196            &[(0, 2, 0x01, 0x01, b"password")], // slot_id=0, name_str_idx=2, Text, Client, default="password"
4197            &opcodes,
4198            &slots,
4199        );
4200        assert_eq!(html, r#"<input type="password"></input>"#);
4201    }
4202
4203    // -- Test 49: dyn_attr_skips_when_slot_null ----------------------------
4204
4205    #[test]
4206    fn dyn_attr_skips_when_slot_null() {
4207        // DYN_ATTR with Null slot value should emit no attribute at all.
4208        // <div> (no class attr) </div>
4209        // strings: 0="div", 1="class", 2="cls" (slot name)
4210        let mut opcodes = Vec::new();
4211        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4212        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4213        opcodes.extend_from_slice(&encode_close_tag(0));
4214
4215        let slots = SlotData::new(1); // slot 0 is Null by default
4216
4217        let html = walk_with_slots(
4218            &["div", "class", "cls"],
4219            &[(0, 2, 0x01, 0x00, &[])],
4220            &opcodes,
4221            &slots,
4222        );
4223        assert_eq!(html, "<div></div>");
4224    }
4225
4226    // -- Test 50: dyn_attr_multiple_on_same_element ------------------------
4227
4228    #[test]
4229    fn dyn_attr_multiple_on_same_element() {
4230        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + DYN_ATTR("class", slot=1) + CLOSE_TAG
4231        // strings: 0="input", 1="type", 2="class", 3="slot_type", 4="slot_class"
4232        let mut opcodes = Vec::new();
4233        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4234        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4235        opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
4236        opcodes.extend_from_slice(&encode_close_tag(0));
4237
4238        let mut slots = SlotData::new(2);
4239        slots.set(0, SlotValue::Text("email".to_string()));
4240        slots.set(1, SlotValue::Text("form-input".to_string()));
4241
4242        let html = walk_with_slots(
4243            &["input", "type", "class", "slot_type", "slot_class"],
4244            &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
4245            &opcodes,
4246            &slots,
4247        );
4248        assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
4249    }
4250
4251    // -- Test 51: dyn_attr_on_void_tag -------------------------------------
4252
4253    #[test]
4254    fn dyn_attr_on_void_tag() {
4255        // VOID_TAG("input") + DYN_ATTR("type", slot=0)
4256        // slot 0 = Text("checkbox") → <input type="checkbox">
4257        // strings: 0="input", 1="type", 2="slot_type"
4258        let mut opcodes = Vec::new();
4259        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
4260        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4261
4262        let mut slots = SlotData::new(1);
4263        slots.set(0, SlotValue::Text("checkbox".to_string()));
4264
4265        let html = walk_with_slots(
4266            &["input", "type", "slot_type"],
4267            &[(0, 2, 0x01, 0x01, b"text")],
4268            &opcodes,
4269            &slots,
4270        );
4271        assert_eq!(html, r#"<input type="checkbox">"#);
4272    }
4273
4274    // -- walk_island tests -------------------------------------------------
4275
4276    #[test]
4277    fn walk_island_basic() {
4278        // Build IR with an island containing <p>hello</p>
4279        // strings: 0="MyIsland", 1="div", 2="p", 3="hello"
4280        // We need a wrapper div before the island, the island content, and wrapper close.
4281        //
4282        // Opcodes layout:
4283        // OPEN_TAG "div" []       -> offset 0
4284        // ISLAND_START id=0       -> offset X (byte_offset for island table)
4285        // OPEN_TAG "p" []
4286        // TEXT "hello"
4287        // CLOSE_TAG "p"
4288        // ISLAND_END id=0
4289        // CLOSE_TAG "div"
4290
4291        let pre_island = encode_open_tag(1, &[]); // OPEN_TAG "div" -> str_idx=1
4292        let island_start = encode_island_start(0);
4293        let open_p = encode_open_tag(2, &[]); // OPEN_TAG "p" -> str_idx=2
4294        let text_hello = encode_text(3);
4295        let close_p = encode_close_tag(2);
4296        let island_end = encode_island_end(0);
4297        let close_div = encode_close_tag(1);
4298
4299        let byte_offset = pre_island.len() as u32; // island starts after the div open
4300
4301        let mut opcodes = Vec::new();
4302        opcodes.extend_from_slice(&pre_island);
4303        opcodes.extend_from_slice(&island_start);
4304        opcodes.extend_from_slice(&open_p);
4305        opcodes.extend_from_slice(&text_hello);
4306        opcodes.extend_from_slice(&close_p);
4307        opcodes.extend_from_slice(&island_end);
4308        opcodes.extend_from_slice(&close_div);
4309
4310        let data = build_minimal_ir(
4311            &["MyIsland", "div", "p", "hello"],
4312            &[],
4313            &opcodes,
4314            &[(0, 0x01, 0x01, 0, byte_offset, &[])], // island 0, Load, Inline, name="MyIsland"
4315        );
4316        let module = IrModule::parse(&data).unwrap();
4317        let slots = SlotData::new(0);
4318
4319        let html = walk_island(&module, &slots, 0).unwrap();
4320        // The fragment is the island ROOT carrying the full data-forma-* set —
4321        // seeking past ISLAND_START used to drop all of it, so a fragment
4322        // swapped in via outerHTML never hydrated.
4323        assert_eq!(
4324            html,
4325            "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
4326             data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
4327        );
4328        // The f:iN markers live outside the root and survive an outerHTML
4329        // swap, so the fragment must not re-emit them.
4330        assert!(!html.contains("<!--f:i0-->"));
4331        assert!(!html.contains("<!--/f:i0-->"));
4332    }
4333
4334    #[test]
4335    fn walk_island_attrs_match_walk_to_html() {
4336        // Parity: the fragment's root must carry the same data-forma-*
4337        // attribute set that the full-page walk produces for the same island.
4338        let island_start = encode_island_start(0);
4339        let open_div = encode_open_tag(1, &[]);
4340        let dyn_text = encode_dyn_text(0, 0);
4341        let close_div = encode_close_tag(1);
4342        let island_end = encode_island_end(0);
4343
4344        let mut opcodes = Vec::new();
4345        opcodes.extend_from_slice(&island_start);
4346        opcodes.extend_from_slice(&open_div);
4347        opcodes.extend_from_slice(&dyn_text);
4348        opcodes.extend_from_slice(&close_div);
4349        opcodes.extend_from_slice(&island_end);
4350
4351        let data = build_minimal_ir(
4352            &["IslandComp", "div", "greeting"],
4353            &[(0, 2, 0x01, 0x00, &[])],
4354            &opcodes,
4355            &[(0, 0x02, 0x01, 0, 0, &[0])], // trigger=Visible, Inline props
4356        );
4357        let module = IrModule::parse(&data).unwrap();
4358        let mut slots = SlotData::new(1);
4359        slots.set(0, SlotValue::Text("World".to_string()));
4360
4361        let page = walk_to_html(&module, &slots).unwrap();
4362        let fragment = walk_island(&module, &slots, 0).unwrap();
4363
4364        for attr in [
4365            "data-forma-island=\"0\"",
4366            "data-forma-component=\"IslandComp\"",
4367            "data-forma-status=\"pending\"",
4368            "data-forma-hydrate=\"visible\"",
4369            "data-forma-props=",
4370        ] {
4371            assert!(page.contains(attr), "page missing {attr}: {page}");
4372            assert!(
4373                fragment.contains(attr),
4374                "fragment missing {attr}: {fragment}"
4375            );
4376        }
4377    }
4378
4379    #[test]
4380    fn walk_island_script_tag_mode_inlines_props_in_fragment() {
4381        // A ScriptTag island rendered as a fragment must inline its props
4382        // rather than emit a second __forma_islands block: that would
4383        // duplicate a DOM id, and because the injected copy precedes the SSR
4384        // one in document order it would shadow it — every other ScriptTag
4385        // island would then read a truncated shared-props map.
4386        let mut opcodes = Vec::new();
4387        opcodes.extend_from_slice(&encode_island_start(0));
4388        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4389        opcodes.extend_from_slice(&encode_close_tag(1));
4390        opcodes.extend_from_slice(&encode_island_end(0));
4391
4392        let data = build_minimal_ir(
4393            &["IslandComp", "div", "greeting"],
4394            &[(0, 2, 0x01, 0x00, &[])],
4395            &opcodes,
4396            &[(0, 0x01, 0x02, 0, 0, &[0])], // props_mode = ScriptTag
4397        );
4398        let module = IrModule::parse(&data).unwrap();
4399        let mut slots = SlotData::new(1);
4400        slots.set(0, SlotValue::Text("World".to_string()));
4401
4402        let fragment = walk_island(&module, &slots, 0).unwrap();
4403        assert!(
4404            fragment.contains("data-forma-props="),
4405            "ScriptTag island fragment must inline its props: {fragment}"
4406        );
4407        assert!(
4408            !fragment.contains("__forma_islands"),
4409            "fragment must not emit a second islands block: {fragment}"
4410        );
4411    }
4412
4413    #[test]
4414    fn walk_island_nested_script_tag_island_keeps_its_props() {
4415        // A nested ScriptTag island would otherwise push into the shared
4416        // script_tag_props map, which a fragment render drops on return — so
4417        // the nested island would mount with null props.
4418        let mut opcodes = encode_island_start(0);
4419        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4420        let inner_offset = opcodes.len() as u32;
4421        opcodes.extend_from_slice(&encode_island_start(1));
4422        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4423        opcodes.extend_from_slice(&encode_close_tag(1));
4424        opcodes.extend_from_slice(&encode_island_end(1));
4425        opcodes.extend_from_slice(&encode_close_tag(1));
4426        opcodes.extend_from_slice(&encode_island_end(0));
4427
4428        let data = build_minimal_ir(
4429            &["Outer", "div", "greeting", "Inner"],
4430            &[(0, 2, 0x01, 0x00, &[])],
4431            &opcodes,
4432            &[
4433                (0, 0x01, 0x01, 0, 0, &[]),
4434                (1, 0x01, 0x02, 3, inner_offset, &[0]), // nested, ScriptTag
4435            ],
4436        );
4437        let module = IrModule::parse(&data).unwrap();
4438        let mut slots = SlotData::new(1);
4439        slots.set(0, SlotValue::Text("World".into()));
4440
4441        let fragment = walk_island(&module, &slots, 0).unwrap();
4442        assert!(
4443            fragment.contains("data-forma-component=\"Inner\""),
4444            "nested island must render: {fragment}"
4445        );
4446        assert!(
4447            fragment.contains("data-forma-props="),
4448            "nested ScriptTag island must keep its props inline: {fragment}"
4449        );
4450        assert!(!fragment.contains("__forma_islands"), "{fragment}");
4451    }
4452
4453    #[test]
4454    fn walk_island_not_found() {
4455        // Build IR with no islands, then request island_id=5
4456        let opcodes = encode_text(0);
4457        let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
4458        let module = IrModule::parse(&data).unwrap();
4459        let slots = SlotData::new(0);
4460
4461        let result = walk_island(&module, &slots, 5);
4462        assert!(result.is_err());
4463        match result.unwrap_err() {
4464            IrError::IslandNotFound(5) => {} // expected
4465            other => panic!("expected IslandNotFound(5), got {other:?}"),
4466        }
4467    }
4468
4469    #[test]
4470    fn walk_island_with_dyn_text() {
4471        // Island containing DYN_TEXT that references a slot
4472        // strings: 0="IslandComp", 1="div", 2="greeting"
4473        // slot 0: name_str_idx=2 ("greeting"), type=Text
4474
4475        let island_start = encode_island_start(0);
4476        let open_div = encode_open_tag(1, &[]); // str_idx=1 -> "div"
4477        let dyn_text = encode_dyn_text(0, 0); // slot_id=0, marker_id=0
4478        let close_div = encode_close_tag(1);
4479        let island_end = encode_island_end(0);
4480
4481        let byte_offset = 0u32; // island starts at beginning of opcodes
4482
4483        let mut opcodes = Vec::new();
4484        opcodes.extend_from_slice(&island_start);
4485        opcodes.extend_from_slice(&open_div);
4486        opcodes.extend_from_slice(&dyn_text);
4487        opcodes.extend_from_slice(&close_div);
4488        opcodes.extend_from_slice(&island_end);
4489
4490        let data = build_minimal_ir(
4491            &["IslandComp", "div", "greeting"],
4492            &[(0, 2, 0x01, 0x00, &[])], // slot_id=0, name="greeting", Text, Server
4493            &opcodes,
4494            &[(0, 0x01, 0x01, 0, byte_offset, &[0])], // island 0, slot_ids=[0]
4495        );
4496        let module = IrModule::parse(&data).unwrap();
4497
4498        let mut slots = SlotData::new(1);
4499        slots.set(0, SlotValue::Text("World".to_string()));
4500
4501        let html = walk_island(&module, &slots, 0).unwrap();
4502        // Root element keeps its island identity AND its props.
4503        assert_eq!(
4504            html,
4505            "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
4506             data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
4507             data-forma-props=\"{&quot;greeting&quot;:&quot;World&quot;}\">\
4508             <!--f:t0-->World<!--/f:t0--></div>"
4509        );
4510    }
4511
4512    #[test]
4513    fn walk_island_byte_offset_out_of_bounds() {
4514        // Island table entry with byte_offset pointing past the opcode stream.
4515        // This is now rejected at parse time: a byte_offset must point at that
4516        // island's own ISLAND_START opcode, which is both a bounds check and a
4517        // structural one (an in-range offset landing mid-operand on a stray
4518        // 0x0B byte would pass a bare bounds check).
4519        let opcodes = encode_text(0); // just a small opcode stream
4520
4521        let data = build_minimal_ir(
4522            &["BadIsland", "hello"],
4523            &[],
4524            &opcodes,
4525            &[(0, 0x01, 0x01, 0, 9999, &[])], // byte_offset=9999, way past end
4526        );
4527        let err = IrModule::parse(&data).unwrap_err();
4528        assert_eq!(
4529            err,
4530            IrError::IslandOffsetMismatch {
4531                island_id: 0,
4532                byte_offset: 9999
4533            }
4534        );
4535    }
4536
4537    // -- PROP opcode tests -------------------------------------------------
4538
4539    /// Encode a PROP opcode: opcode(1) + src_slot_id(2) + prop_str_idx(4) + target_slot_id(2)
4540    fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4541        let mut buf = vec![0x12]; // Opcode::Prop
4542        buf.extend_from_slice(&src_slot_id.to_le_bytes());
4543        buf.extend_from_slice(&prop_str_idx.to_le_bytes());
4544        buf.extend_from_slice(&target_slot_id.to_le_bytes());
4545        buf
4546    }
4547
4548    // -- Test: PROP extracts object property inside LIST body ----------------
4549
4550    #[test]
4551    fn walk_prop_in_list_body() {
4552        // LIST(slot=0, item_slot=1):
4553        //   PROP(src=1, "name", target=2)
4554        //   DYN_TEXT(slot=2, marker=0)
4555        //
4556        // Slot 0 = Array([Object{name:"Alice"}, Object{name:"Bob"}])
4557        // Strings: 0="items", 1="item", 2="name"
4558        let mut body = Vec::new();
4559        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(item_slot=1, prop="name", target=2)
4560        body.extend_from_slice(&encode_dyn_text(2, 0)); // DYN_TEXT(slot=2)
4561        let opcodes = encode_list(0, 1, &body);
4562
4563        let mut slots = SlotData::new(3);
4564        slots.set(
4565            0,
4566            SlotValue::array(vec![
4567                SlotValue::object(vec![(
4568                    "name".to_string(),
4569                    SlotValue::Text("Alice".to_string()),
4570                )]),
4571                SlotValue::object(vec![(
4572                    "name".to_string(),
4573                    SlotValue::Text("Bob".to_string()),
4574                )]),
4575            ]),
4576        );
4577
4578        let html = walk_with_slots(
4579            &["items", "item", "name"],
4580            &[
4581                (0, 0, 0x04, 0x00, &[]), // slot 0: Array, Server
4582                (1, 1, 0x05, 0x00, &[]), // slot 1: Object, Server
4583                (2, 2, 0x01, 0x00, &[]), // slot 2: Text, Server
4584            ],
4585            &opcodes,
4586            &slots,
4587        );
4588        assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4589        assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4590    }
4591
4592    // -- Test: PROP with multiple properties ---------------------------------
4593
4594    #[test]
4595    fn walk_prop_multiple_properties() {
4596        // LIST(slot=0, item_slot=1):
4597        //   PROP(src=1, "name", target=2)
4598        //   PROP(src=1, "email", target=3)
4599        //   <tr><td>DYN_TEXT(2)</td><td>DYN_TEXT(3)</td></tr>
4600        //
4601        // Strings: 0="rows", 1="row", 2="name", 3="email", 4="tr", 5="td"
4602        let mut body = Vec::new();
4603        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(1, "name", 2)
4604        body.extend_from_slice(&encode_prop(1, 3, 3)); // PROP(1, "email", 3)
4605        body.extend_from_slice(&encode_open_tag(4, &[])); // <tr>
4606        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
4607        body.extend_from_slice(&encode_dyn_text(2, 0)); // name
4608        body.extend_from_slice(&encode_close_tag(5)); // </td>
4609        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
4610        body.extend_from_slice(&encode_dyn_text(3, 1)); // email
4611        body.extend_from_slice(&encode_close_tag(5)); // </td>
4612        body.extend_from_slice(&encode_close_tag(4)); // </tr>
4613        let opcodes = encode_list(0, 1, &body);
4614
4615        let mut slots = SlotData::new(4);
4616        slots.set(
4617            0,
4618            SlotValue::array(vec![
4619                SlotValue::object(vec![
4620                    ("name".to_string(), SlotValue::Text("Alice".to_string())),
4621                    (
4622                        "email".to_string(),
4623                        SlotValue::Text("alice@test.com".to_string()),
4624                    ),
4625                ]),
4626                SlotValue::object(vec![
4627                    ("name".to_string(), SlotValue::Text("Bob".to_string())),
4628                    (
4629                        "email".to_string(),
4630                        SlotValue::Text("bob@test.com".to_string()),
4631                    ),
4632                ]),
4633            ]),
4634        );
4635
4636        let html = walk_with_slots(
4637            &["rows", "row", "name", "email", "tr", "td"],
4638            &[
4639                (0, 0, 0x04, 0x00, &[]),
4640                (1, 1, 0x05, 0x00, &[]),
4641                (2, 2, 0x01, 0x00, &[]),
4642                (3, 3, 0x01, 0x00, &[]),
4643            ],
4644            &opcodes,
4645            &slots,
4646        );
4647        assert!(
4648            html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4649            "should contain Alice td, got: {html}"
4650        );
4651        assert!(
4652            html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4653            "should contain alice email, got: {html}"
4654        );
4655        assert!(
4656            html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4657            "should contain Bob td, got: {html}"
4658        );
4659        assert!(
4660            html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4661            "should contain bob email, got: {html}"
4662        );
4663    }
4664
4665    // -- Test: PROP on non-Object returns Null (empty string for DYN_TEXT) ----
4666
4667    #[test]
4668    fn walk_prop_on_non_object() {
4669        // PROP(src=0, "name", target=1) then DYN_TEXT(1)
4670        // Slot 0 = Text("hello") — not an object
4671        let mut opcodes = Vec::new();
4672        opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4673        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4674
4675        let mut slots = SlotData::new(2);
4676        slots.set(0, SlotValue::Text("hello".to_string()));
4677
4678        let html = walk_with_slots(
4679            &["src", "name"],
4680            &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4681            &opcodes,
4682            &slots,
4683        );
4684        // PROP on non-Object produces Null → DYN_TEXT renders zero-width space (U+200B)
4685        assert!(
4686            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4687            "non-object PROP should produce empty text, got: {html}"
4688        );
4689    }
4690
4691    // -- Test: PROP with missing property returns Null -----------------------
4692
4693    #[test]
4694    fn walk_prop_missing_property() {
4695        // PROP(src=0, "missing", target=1) then DYN_TEXT(1)
4696        // Slot 0 = Object with only "name" key
4697        let mut opcodes = Vec::new();
4698        opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4699        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4700
4701        let mut slots = SlotData::new(2);
4702        slots.set(
4703            0,
4704            SlotValue::object(vec![(
4705                "name".to_string(),
4706                SlotValue::Text("Alice".to_string()),
4707            )]),
4708        );
4709
4710        let html = walk_with_slots(
4711            &["src", "target", "missing"],
4712            &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4713            &opcodes,
4714            &slots,
4715        );
4716        assert!(
4717            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4718            "missing property should produce empty text, got: {html}"
4719        );
4720    }
4721
4722    // -- Recursion depth limit tests ----------------------------------------
4723
4724    #[test]
4725    fn walk_nested_show_if_within_limit() {
4726        // 5 levels of nested SHOW_IF — well within the 64-level limit.
4727        // Each level wraps the next; innermost renders TEXT "deep".
4728        // strings: 0..=4 = "s0".."s4", 5 = "deep"
4729        let inner = encode_text(5);
4730        let level4 = encode_show_if(4, &inner, &[]);
4731        let level3 = encode_show_if(3, &level4, &[]);
4732        let level2 = encode_show_if(2, &level3, &[]);
4733        let level1 = encode_show_if(1, &level2, &[]);
4734        let level0 = encode_show_if(0, &level1, &[]);
4735
4736        let mut slots = SlotData::new(5);
4737        for i in 0..5 {
4738            slots.set(i, SlotValue::Bool(true));
4739        }
4740
4741        let html = walk_with_slots(
4742            &["s0", "s1", "s2", "s3", "s4", "deep"],
4743            &[
4744                (0, 0, 0x02, 0x00, &[]),
4745                (1, 1, 0x02, 0x00, &[]),
4746                (2, 2, 0x02, 0x00, &[]),
4747                (3, 3, 0x02, 0x00, &[]),
4748                (4, 4, 0x02, 0x00, &[]),
4749            ],
4750            &level0,
4751            &slots,
4752        );
4753        assert!(
4754            html.contains("deep"),
4755            "5 levels of nesting should succeed, got: {html}"
4756        );
4757    }
4758
4759    /// Build a one-island ScriptTag-mode page whose single prop is `payload`,
4760    /// and return (full html, the raw script-block body).
4761    fn script_tag_props_html(payload: &str) -> (String, String) {
4762        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
4763        // strings: 0="div", 1="Comp", 2="payload"
4764        let mut opcodes = Vec::new();
4765        opcodes.extend_from_slice(&encode_island_start(0));
4766        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4767        opcodes.extend_from_slice(&encode_close_tag(0));
4768        opcodes.extend_from_slice(&encode_island_end(0));
4769
4770        let mut slots = SlotData::new(1);
4771        slots.set(0, SlotValue::Text(payload.to_string()));
4772
4773        let html = walk_with_islands(
4774            &["div", "Comp", "payload"],
4775            &[(0, 2, 0x01, 0x00, &[])],
4776            &[(0, 0x01, 0x02, 1, 0, &[0])],
4777            &opcodes,
4778            &slots,
4779        );
4780
4781        const OPEN: &str = "type=\"application/json\">";
4782        let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4783        let end = html[start..].find("</script>").expect("script close") + start;
4784        let body = html[start..end].to_string();
4785        (html, body)
4786    }
4787
4788    #[test]
4789    fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4790        // The property that actually makes the escaping total: no bare '<' or
4791        // '>' survives into the data block, so nothing in it can terminate or
4792        // re-open the enclosing <script> element. Escaping only "</" (as this
4793        // used to) is not enough — see the breakout test below.
4794        let (_html, body) = script_tag_props_html("</script>alert(1)");
4795        assert!(
4796            !body.contains('<') && !body.contains('>'),
4797            "islands block must contain no raw angle brackets, got: {body}"
4798        );
4799        assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4800
4801        // ...and the escaping is value-preserving: JSON.parse (here
4802        // serde_json::from_str, which decodes \u003c identically) round-trips
4803        // back to the original prop value.
4804        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4805        assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4806    }
4807
4808    #[test]
4809    fn walk_script_tag_props_escapes_comment_script_breakout() {
4810        // The payload the old "</" replace did NOT handle. `<!--` followed by
4811        // `<script` drives a spec-compliant HTML tokenizer into the
4812        // script-data-double-escaped state, where our own trailing </script>
4813        // is consumed as text instead of ending the element — swallowing the
4814        // whole rest of the page and leaving an inert page.
4815        let payload = "<!--<script>PAYLOAD";
4816        let (html, body) = script_tag_props_html(payload);
4817
4818        assert!(
4819            !html.contains("<!--<script"),
4820            "must not emit the script-data-double-escape trigger, got: {html}"
4821        );
4822        assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4823
4824        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4825        assert_eq!(parsed["0"]["payload"], payload);
4826    }
4827
4828    #[test]
4829    #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4830    fn walk_real_benchmark_ir() {
4831        // Find the benchmark IR file dynamically (hash changes on rebuild)
4832        let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4833        let ir_path = std::fs::read_dir(&dist_dir)
4834            .expect("admin/dist must exist")
4835            .filter_map(|e| e.ok())
4836            .find(|e| {
4837                let name = e.file_name();
4838                let s = name.to_string_lossy();
4839                s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4840            })
4841            .map(|e| e.path())
4842            .expect("platform-benchmark.*.ir must exist in admin/dist");
4843        let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4844        let module = IrModule::parse(&data).expect("IR must parse");
4845
4846        // Walk with default slots
4847        let slots = SlotData::new_from_defaults(&module.slots);
4848        let html = walk_to_html(&module, &slots).expect("walk must succeed");
4849
4850        // Verify island markers exist
4851        assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4852        assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4853        assert!(
4854            html.contains("data-forma-island=\"0\""),
4855            "missing island 0 attr"
4856        );
4857
4858        // The key assertion: island content must include FilterBar's children
4859        // (not just an empty shell div)
4860        assert!(
4861            html.contains("Search"),
4862            "FilterBar must contain 'Search' label text in SSR"
4863        );
4864        assert!(
4865            html.contains("filter-group"),
4866            "FilterBar must contain filter-group divs in SSR"
4867        );
4868        assert!(
4869            html.contains("Sort by"),
4870            "FilterBar must contain 'Sort by' label text in SSR"
4871        );
4872
4873        // PerfPanel content
4874        assert!(
4875            html.contains("Performance"),
4876            "PerfPanel must contain 'Performance' heading in SSR"
4877        );
4878
4879        // BenchmarkDataTable — SHOW_IF may pick either branch depending on slot defaults
4880        // Just check the structure exists
4881        assert!(
4882            html.contains("benchmark-data-table"),
4883            "BenchmarkDataTable root class must exist"
4884        );
4885
4886        // The FilterBar island must be a real, closed region, not just a
4887        // stray opening marker. (This replaced a pair of `eprintln!`s that
4888        // printed the region "for manual inspection" and asserted nothing.)
4889        let start = html
4890            .find("<!--f:i0-->")
4891            .expect("FilterBar island start marker");
4892        let end = html
4893            .find("<!--/f:i0-->")
4894            .expect("FilterBar island end marker");
4895        assert!(end > start, "island end marker must follow its start");
4896    }
4897
4898    // -- Output budget (F-02) -----------------------------------------------
4899
4900    #[test]
4901    fn walk_output_limit_trips_inside_one_opcode() {
4902        // The case a top-of-loop check cannot catch: ONE OpenTag opcode can
4903        // emit hundreds of megabytes, because attr_count goes to u16::MAX and
4904        // every value escapes up to 5x. The budget must be enforced by the
4905        // append itself, not once per iteration.
4906        let big = "&".repeat(4096); // escapes to 5x = 20 KiB each
4907        let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4908        let opcodes = encode_open_tag(0, &attrs);
4909
4910        let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4911        let module = IrModule::parse(&data).unwrap();
4912        let slots = SlotData::new(0);
4913
4914        let limits = WalkLimits {
4915            max_output_bytes: 64 * 1024,
4916            ..WalkLimits::default()
4917        };
4918        let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4919        assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4920
4921        // ...and a generous budget still renders it.
4922        let ok = WalkLimits {
4923            max_output_bytes: 64 * 1024 * 1024,
4924            ..WalkLimits::default()
4925        };
4926        assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4927    }
4928
4929    #[test]
4930    fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4931        // DynText emits a whole slot value in one iteration too.
4932        let opcodes = encode_dyn_text(0, 0);
4933        let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4934        let module = IrModule::parse(&data).unwrap();
4935
4936        let mut slots = SlotData::new(1);
4937        slots.set(0, SlotValue::Text("x".repeat(100_000)));
4938
4939        let limits = WalkLimits {
4940            max_output_bytes: 4096,
4941            ..WalkLimits::default()
4942        };
4943        assert!(matches!(
4944            walk_to_html_with_limits(&module, &slots, limits),
4945            Err(IrError::OutputLimitExceeded { .. })
4946        ));
4947    }
4948
4949    // -- LIST iteration budget (F-08) ---------------------------------------
4950
4951    #[test]
4952    fn walk_list_budget_exceeded() {
4953        // Depth bounds nesting, not volume: two nested LISTs over the same
4954        // 300-element array is 90,000 body walks from a tiny module.
4955        let body = encode_text(2);
4956        let inner = encode_list(0, 1, &body);
4957        let opcodes = encode_list(0, 1, &inner);
4958
4959        let data = build_minimal_ir(
4960            &["items", "item", "x"],
4961            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4962            &opcodes,
4963            &[],
4964        );
4965        let module = IrModule::parse(&data).unwrap();
4966
4967        let mut slots = SlotData::new(2);
4968        slots.set(
4969            0,
4970            SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4971        );
4972
4973        let err = walk_to_html(&module, &slots).unwrap_err();
4974        assert_eq!(
4975            err,
4976            IrError::ListBudgetExceeded {
4977                max: DEFAULT_MAX_LIST_ITERATIONS
4978            }
4979        );
4980        // Deliberately a DIFFERENT error from ListDepthExceeded — an operator
4981        // reading "nested LIST depth exceeded maximum of 4" for this case
4982        // would be chasing the wrong thing.
4983        assert_ne!(
4984            err,
4985            IrError::ListDepthExceeded {
4986                max: MAX_LIST_DEPTH
4987            }
4988        );
4989    }
4990
4991    // -- Offset arithmetic and structural tiling (F-06 / F-07) --------------
4992
4993    /// Corrupt a u32 length field inside an opcode buffer.
4994    fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4995        buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4996    }
4997
4998    #[test]
4999    fn parse_rejects_show_if_then_len_past_the_stream() {
5000        // SHOW_IF: opcode(1) slot_id(2) then_len(4) else_len(4)
5001        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5002        patch_u32(&mut opcodes, 3, 0xFFFF_FF00); // then_len
5003        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5004        assert!(matches!(
5005            IrModule::parse(&data),
5006            Err(IrError::MalformedControlFlow { .. })
5007        ));
5008    }
5009
5010    #[test]
5011    fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
5012        // then_len short by one, so the byte at then_end is a TEXT opcode
5013        // rather than the SHOW_ELSE marker.
5014        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5015        patch_u32(&mut opcodes, 3, 4); // then body is 5 bytes; claim 4
5016        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5017        assert!(matches!(
5018            IrModule::parse(&data),
5019            Err(IrError::MalformedControlFlow { .. })
5020        ));
5021    }
5022
5023    #[test]
5024    fn parse_rejects_list_body_len_past_the_stream() {
5025        // LIST: opcode(1) slot_id(2) item_slot_id(2) body_len(4)
5026        let mut opcodes = encode_list(0, 1, &encode_text(2));
5027        patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
5028        let data = build_minimal_ir(
5029            &["items", "item", "x"],
5030            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5031            &opcodes,
5032            &[],
5033        );
5034        assert!(matches!(
5035            IrModule::parse(&data),
5036            Err(IrError::MalformedControlFlow { .. })
5037        ));
5038    }
5039
5040    #[test]
5041    fn parse_rejects_switch_case_body_len_past_the_stream() {
5042        // The over-long length is in case 0, which does NOT match the slot
5043        // value at render time — a non-matching case must be validated too.
5044        let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
5045        // header 0 body_len is at opcode(1)+slot(2)+count(2)+val_idx(4) = 9
5046        patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
5047        let data = build_minimal_ir(
5048            &["mode", "a", "x", "b"],
5049            &[(0, 0, 0x01, 0x00, &[])],
5050            &opcodes,
5051            &[],
5052        );
5053        assert!(matches!(
5054            IrModule::parse(&data),
5055            Err(IrError::MalformedControlFlow { .. })
5056        ));
5057    }
5058
5059    #[test]
5060    fn parse_rejects_fallback_skip_past_the_stream() {
5061        // TRY_START: opcode(1) fallback_len(4)
5062        let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
5063        patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
5064        let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
5065        assert!(matches!(
5066            IrModule::parse(&data),
5067            Err(IrError::MalformedControlFlow { .. })
5068        ));
5069    }
5070
5071    #[test]
5072    fn parse_accepts_zero_length_else_branch_known_residual() {
5073        // DOCUMENTED RESIDUAL: an under-long length whose orphaned bytes still
5074        // decode as a legal sibling sequence is structurally indistinguishable
5075        // from a legitimate module. No length check can reject it; only module
5076        // integrity (a populated source_hash) would. This test exists so the
5077        // case is not later mistaken for a fixed hole.
5078        let opcodes = encode_show_if(0, &encode_text(1), &[]);
5079        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5080        assert!(IrModule::parse(&data).is_ok());
5081    }
5082
5083    // -- TRY/FALLBACK scoping (F-10) ----------------------------------------
5084
5085    #[test]
5086    fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
5087        // Layout: TRY_START [ SHOW_IF(then = FALLBACK) TEXT("main") ] FALLBACK TEXT("fb")
5088        //
5089        // The FALLBACK inside the then-branch belongs to no TRY_START in its
5090        // own range. The walker's `fallback_stack` is range-local so it cannot
5091        // pop the outer entry and skip the wrong region — but it would still
5092        // fall through and walk the fallback body as if it were main content,
5093        // which is a silently wrong page. Validation rejects the module first.
5094        let then_ops = vec![0x0E]; // stray FALLBACK inside the then-branch
5095        let mut main_ops = encode_show_if(0, &then_ops, &[]);
5096        main_ops.extend_from_slice(&encode_text(1));
5097        let opcodes = encode_try(&main_ops, &encode_text(2));
5098
5099        let data = build_minimal_ir(
5100            &["flag", "main", "fb"],
5101            &[(0, 0, 0x02, 0x00, &[])],
5102            &opcodes,
5103            &[],
5104        );
5105        assert!(matches!(
5106            IrModule::parse(&data).unwrap_err(),
5107            IrError::MalformedControlFlow { .. }
5108        ));
5109    }
5110
5111    #[test]
5112    fn walk_try_without_a_fallback_is_rejected_at_load() {
5113        // A TRY_START whose FALLBACK never arrives ships a page with no
5114        // fallback and no diagnostic.
5115        let mut opcodes = vec![0x0D]; // TRY_START
5116        opcodes.extend_from_slice(&4u32.to_le_bytes()); // fallback_len
5117        opcodes.extend_from_slice(&encode_text(0));
5118
5119        let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
5120        assert!(matches!(
5121            IrModule::parse(&data).unwrap_err(),
5122            IrError::MalformedControlFlow { .. }
5123        ));
5124    }
5125
5126    // -- Pending island / list-key scoping (F-11) ---------------------------
5127
5128    #[test]
5129    fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
5130        // ISLAND_START, SHOW_IF (both branches empty), then the root element.
5131        // Clearing pending state at the end of every walk_range would wipe the
5132        // island attributes here and the island would never hydrate — the
5133        // reason the clear lives on ISLAND_END instead.
5134        let mut opcodes = encode_island_start(0);
5135        opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
5136        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5137        opcodes.extend_from_slice(&encode_close_tag(1));
5138        opcodes.extend_from_slice(&encode_island_end(0));
5139
5140        let slots = SlotData::new(1);
5141        let html = walk_with_islands(
5142            &["Comp", "div", "flag"],
5143            &[(0, 2, 0x02, 0x00, &[])],
5144            &[(0, 0x01, 0x01, 0, 0, &[])],
5145            &opcodes,
5146            &slots,
5147        );
5148        assert!(
5149            html.contains("<div data-forma-island=\"0\""),
5150            "island root must keep its attributes, got: {html}"
5151        );
5152    }
5153
5154    #[test]
5155    fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
5156        let mut body = encode_list_item_key(3);
5157        body.extend_from_slice(&encode_show_if(1, &[], &[]));
5158        body.extend_from_slice(&encode_open_tag(2, &[]));
5159        body.extend_from_slice(&encode_close_tag(2));
5160        let opcodes = encode_list(0, 1, &body);
5161
5162        let mut slots = SlotData::new(2);
5163        slots.set(
5164            0,
5165            SlotValue::array(vec![
5166                SlotValue::Text("a".into()),
5167                SlotValue::Text("b".into()),
5168            ]),
5169        );
5170
5171        let html = walk_with_slots(
5172            &["items", "item", "li", "k"],
5173            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5174            &opcodes,
5175            &slots,
5176        );
5177        assert_eq!(
5178            html.matches("data-forma-key=\"k\"").count(),
5179            2,
5180            "every item element must keep its key, got: {html}"
5181        );
5182    }
5183
5184    #[test]
5185    fn walk_island_attrs_do_not_leak_past_the_island_end() {
5186        // ISLAND_START with no element inside, then an element AFTER the
5187        // island's closing marker. The island's identity attributes must not
5188        // land on it.
5189        let mut opcodes = encode_island_start(0);
5190        opcodes.extend_from_slice(&encode_island_end(0));
5191        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5192        opcodes.extend_from_slice(&encode_close_tag(1));
5193
5194        let slots = SlotData::new(0);
5195        let html = walk_with_islands(
5196            &["Comp", "div"],
5197            &[],
5198            &[(0, 0x01, 0x01, 0, 0, &[])],
5199            &opcodes,
5200            &slots,
5201        );
5202        assert!(
5203            !html.contains("data-forma-island"),
5204            "island attributes must not leak onto an element outside the island, got: {html}"
5205        );
5206    }
5207
5208    #[test]
5209    fn walk_list_key_does_not_leak_past_the_list() {
5210        let body = encode_list_item_key(2); // key, but no element to carry it
5211        let mut opcodes = encode_list(0, 1, &body);
5212        opcodes.extend_from_slice(&encode_open_tag(3, &[]));
5213        opcodes.extend_from_slice(&encode_close_tag(3));
5214
5215        let mut slots = SlotData::new(2);
5216        slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
5217
5218        let html = walk_with_slots(
5219            &["items", "item", "k", "div"],
5220            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5221            &opcodes,
5222            &slots,
5223        );
5224        assert!(
5225            !html.contains("data-forma-key"),
5226            "list key must not leak onto an element after the list, got: {html}"
5227        );
5228    }
5229
5230    // -- Name validation (S13) ----------------------------------------------
5231
5232    #[test]
5233    fn walk_rejects_hostile_tag_name() {
5234        // Tag names are written unescaped, so an IR-authored name is direct
5235        // markup injection.
5236        let opcodes = encode_open_tag(0, &[]);
5237        let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
5238        let module = IrModule::parse(&data).unwrap();
5239        let slots = SlotData::new(0);
5240        assert!(matches!(
5241            walk_to_html(&module, &slots),
5242            Err(IrError::InvalidName(_))
5243        ));
5244    }
5245
5246    #[test]
5247    fn walk_rejects_hostile_close_tag_name() {
5248        let mut opcodes = encode_open_tag(0, &[]);
5249        opcodes.extend_from_slice(&encode_close_tag(1));
5250        let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
5251        let module = IrModule::parse(&data).unwrap();
5252        let slots = SlotData::new(0);
5253        assert!(matches!(
5254            walk_to_html(&module, &slots),
5255            Err(IrError::InvalidName(_))
5256        ));
5257    }
5258
5259    #[test]
5260    fn walk_drops_hostile_attribute_names() {
5261        // Attribute names are skippable, so one bad key costs the attribute
5262        // rather than the whole page.
5263        let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
5264        let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
5265        let module = IrModule::parse(&data).unwrap();
5266        let slots = SlotData::new(0);
5267        let html = walk_to_html(&module, &slots).unwrap();
5268        assert_eq!(html, "<div ok-attr=\"v\">");
5269    }
5270
5271    #[test]
5272    fn walk_drops_hostile_boolean_attribute_name() {
5273        // Same, via the boolean (empty-value) branch.
5274        let opcodes = encode_open_tag(0, &[(1, 2)]);
5275        let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
5276        let module = IrModule::parse(&data).unwrap();
5277        let slots = SlotData::new(0);
5278        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5279    }
5280
5281    #[test]
5282    fn walk_drops_hostile_dyn_attr_name() {
5283        let mut opcodes = encode_open_tag(0, &[]);
5284        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5285        let data = build_minimal_ir(
5286            &["div", "z\"><script", "val"],
5287            &[(0, 2, 0x01, 0x00, &[])],
5288            &opcodes,
5289            &[],
5290        );
5291        let module = IrModule::parse(&data).unwrap();
5292        let mut slots = SlotData::new(1);
5293        slots.set(0, SlotValue::Text("x".into()));
5294        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5295    }
5296
5297    // -- URL / event-handler attribute safety (S14) -------------------------
5298
5299    #[test]
5300    fn walk_drops_javascript_url_in_dyn_attr() {
5301        let mut opcodes = encode_open_tag(0, &[]);
5302        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5303        let data = build_minimal_ir(
5304            &["a", "href", "url"],
5305            &[(0, 2, 0x01, 0x00, &[])],
5306            &opcodes,
5307            &[],
5308        );
5309        let module = IrModule::parse(&data).unwrap();
5310
5311        // Also covers the normalization step: browsers strip control chars
5312        // before resolving the scheme, so `java\tscript:` executes.
5313        for payload in [
5314            "javascript:alert(1)",
5315            "JaVaScRiPt:alert(1)",
5316            "java\tscript:alert(1)",
5317            "  javascript:alert(1)",
5318            "vbscript:msgbox(1)",
5319            "data:text/html,<script>alert(1)</script>",
5320        ] {
5321            let mut slots = SlotData::new(1);
5322            slots.set(0, SlotValue::Text(payload.to_string()));
5323            let html = walk_to_html(&module, &slots).unwrap();
5324            // Dropped entirely, not emitted empty: href="" resolves to the
5325            // current page and would not match the client DOM either.
5326            assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
5327        }
5328    }
5329
5330    #[test]
5331    fn walk_keeps_safe_urls_and_data_images() {
5332        let mut opcodes = encode_open_tag(0, &[]);
5333        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5334        let data = build_minimal_ir(
5335            &["img", "src", "url"],
5336            &[(0, 2, 0x01, 0x00, &[])],
5337            &opcodes,
5338            &[],
5339        );
5340        let module = IrModule::parse(&data).unwrap();
5341
5342        for payload in [
5343            "/about",
5344            "https://example.com/x?a=1",
5345            "data:image/png;base64,AAAA",
5346            // svg+xml is allowed here because <img> is an image context — the
5347            // stock CSP permits img-src data:, so a blanket data: block would
5348            // break legitimate inline images.
5349            "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
5350        ] {
5351            let mut slots = SlotData::new(1);
5352            slots.set(0, SlotValue::Text(payload.to_string()));
5353            let html = walk_to_html(&module, &slots).unwrap();
5354            assert!(html.contains("src="), "payload {payload:?} was dropped");
5355        }
5356    }
5357
5358    #[test]
5359    fn walk_blocks_svg_data_url_outside_image_context() {
5360        let mut opcodes = encode_open_tag(0, &[]);
5361        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5362        let data = build_minimal_ir(
5363            &["a", "href", "url"],
5364            &[(0, 2, 0x01, 0x00, &[])],
5365            &opcodes,
5366            &[],
5367        );
5368        let module = IrModule::parse(&data).unwrap();
5369        let mut slots = SlotData::new(1);
5370        slots.set(
5371            0,
5372            SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
5373        );
5374        // Through a document sink the SVG is parsed as a document and its
5375        // onload fires, so it is blocked here even though <img> allows it.
5376        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5377    }
5378
5379    #[test]
5380    fn walk_drops_event_handler_attributes() {
5381        // Static attribute path...
5382        let opcodes = encode_open_tag(0, &[(1, 2)]);
5383        let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
5384        let module = IrModule::parse(&data).unwrap();
5385        assert_eq!(
5386            walk_to_html(&module, &SlotData::new(0)).unwrap(),
5387            "<div>",
5388            "static on* attribute must be dropped"
5389        );
5390
5391        // ...and the dynamic one.
5392        let mut opcodes = encode_open_tag(0, &[]);
5393        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5394        let data = build_minimal_ir(
5395            &["div", "ONCLICK", "h"],
5396            &[(0, 2, 0x01, 0x00, &[])],
5397            &opcodes,
5398            &[],
5399        );
5400        let module = IrModule::parse(&data).unwrap();
5401        let mut slots = SlotData::new(1);
5402        slots.set(0, SlotValue::Text("alert(1)".into()));
5403        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5404    }
5405
5406    #[test]
5407    fn walk_drops_javascript_url_in_static_attr() {
5408        // Static values come from the string table (template-author level),
5409        // but renderAttr applies identical rules to static props — leaving
5410        // them unchecked would preserve an SSR/CSR divergence.
5411        let opcodes = encode_open_tag(0, &[(1, 2)]);
5412        let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
5413        let module = IrModule::parse(&data).unwrap();
5414        assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
5415    }
5416
5417    #[test]
5418    fn walk_comment_cannot_self_close() {
5419        // A leading ">" or "->" closes an HTML comment immediately per the
5420        // tokenizer, spilling the rest into the document as markup.
5421        for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
5422            let opcodes = encode_comment(0);
5423            let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
5424            let module = IrModule::parse(&data).unwrap();
5425            let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
5426            assert!(
5427                html.starts_with("<!-- "),
5428                "comment must not start with a closing sequence, got: {html}"
5429            );
5430        }
5431    }
5432
5433    // -- Number rendering parity (F-05) -------------------------------------
5434
5435    #[test]
5436    fn walk_switch_matches_on_js_number_text() {
5437        // to_text also drives SWITCH case comparison, and the case strings are
5438        // authored on the JS side — so branch selection follows JS semantics.
5439        let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
5440        let data = build_minimal_ir(
5441            &["n", "1e+21", "big", "1000", "small"],
5442            &[(0, 0, 0x03, 0x00, &[])],
5443            &opcodes,
5444            &[],
5445        );
5446        let module = IrModule::parse(&data).unwrap();
5447
5448        let mut slots = SlotData::new(1);
5449        slots.set(0, SlotValue::Number(1e21));
5450        assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
5451
5452        let mut slots = SlotData::new(1);
5453        slots.set(0, SlotValue::Number(1000.0));
5454        assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
5455    }
5456
5457    // -- Parsing context: text-only parents and form controls (FORMA-002) ---
5458    //
5459    // The tokenizer reads the content of `textarea` and `title` (RCDATA) and
5460    // of `style` and `script` (RAWTEXT, script data) as text, so a marker
5461    // comment there is literal text: it becomes the control's value and what a
5462    // form submits before JavaScript. A bound `value` on a `select` or a
5463    // `textarea` is a property HTML never reads as an attribute. Each test
5464    // asserts exact bytes for the affected element AND for a sibling in normal
5465    // content, so a rule cannot pass by dropping markers everywhere (P3).
5466
5467    /// A small opcode assembler with an interning string table, so these
5468    /// tests read as markup rather than as string indices.
5469    struct Page {
5470        strings: Vec<String>,
5471        ops: Vec<u8>,
5472        slots: Vec<(u8, SlotValue)>,
5473        islands: Vec<(u16, u32, u32)>,
5474    }
5475
5476    impl Page {
5477        fn new() -> Self {
5478            Page {
5479                strings: Vec::new(),
5480                ops: Vec::new(),
5481                slots: Vec::new(),
5482                islands: Vec::new(),
5483            }
5484        }
5485
5486        fn intern(&mut self, s: &str) -> u32 {
5487            let index = match self.strings.iter().position(|x| x == s) {
5488                Some(i) => i,
5489                None => {
5490                    self.strings.push(s.to_string());
5491                    self.strings.len() - 1
5492                }
5493            };
5494            u32::try_from(index).unwrap()
5495        }
5496
5497        /// Declare the next slot holding `value`; ids are assigned in order.
5498        fn slot(&mut self, value: SlotValue) -> u16 {
5499            let type_byte = match &value {
5500                SlotValue::Null | SlotValue::Text(_) => 0x01,
5501                SlotValue::Bool(_) => 0x02,
5502                SlotValue::Number(_) => 0x03,
5503                SlotValue::Array(_) => 0x04,
5504                SlotValue::Object(_) => 0x05,
5505            };
5506            self.slots.push((type_byte, value));
5507            u16::try_from(self.slots.len() - 1).unwrap()
5508        }
5509
5510        fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
5511            let tag = self.intern(tag);
5512            let attrs: Vec<(u32, u32)> = attrs
5513                .iter()
5514                .map(|(k, v)| (self.intern(k), self.intern(v)))
5515                .collect();
5516            self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
5517            self
5518        }
5519
5520        fn close(&mut self, tag: &str) -> &mut Self {
5521            let tag = self.intern(tag);
5522            self.ops.extend_from_slice(&encode_close_tag(tag));
5523            self
5524        }
5525
5526        fn text(&mut self, text: &str) -> &mut Self {
5527            let text = self.intern(text);
5528            self.ops.extend_from_slice(&encode_text(text));
5529            self
5530        }
5531
5532        /// DYN_TEXT whose marker id is its slot id, so ids stay distinct.
5533        fn dyn_text(&mut self, slot: u16) -> &mut Self {
5534            self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
5535            self
5536        }
5537
5538        fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
5539            let name = self.intern(name);
5540            self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
5541            self
5542        }
5543
5544        /// PROP: `target = source[name]`.
5545        fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
5546            let name = self.intern(name);
5547            self.ops
5548                .extend_from_slice(&encode_prop(source, name, target));
5549            self
5550        }
5551
5552        /// Assemble `f`'s opcodes on their own, sharing the string table.
5553        fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
5554            let outer = std::mem::take(&mut self.ops);
5555            f(self);
5556            std::mem::replace(&mut self.ops, outer)
5557        }
5558
5559        fn show_if(
5560            &mut self,
5561            slot: u16,
5562            then_ops: impl FnOnce(&mut Self),
5563            else_ops: impl FnOnce(&mut Self),
5564        ) -> &mut Self {
5565            let then_ops = self.body(then_ops);
5566            let else_ops = self.body(else_ops);
5567            self.ops
5568                .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
5569            self
5570        }
5571
5572        fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5573            let body = self.body(body);
5574            self.ops.extend_from_slice(&encode_list(slot, item, &body));
5575            self
5576        }
5577
5578        /// An island at the top level (its table offset is taken here, so it
5579        /// must not be nested inside a `body`).
5580        fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5581            let name = self.intern(&format!("Island{id}"));
5582            let offset = u32::try_from(self.ops.len()).unwrap();
5583            self.islands.push((id, name, offset));
5584            self.ops.extend_from_slice(&encode_island_start(id));
5585            body(self);
5586            self.ops.extend_from_slice(&encode_island_end(id));
5587            self
5588        }
5589
5590        fn module_and_slots(&self) -> (IrModule, SlotData) {
5591            let mut strings = self.strings.clone();
5592            let mut decls = Vec::new();
5593            for (id, (type_byte, _)) in self.slots.iter().enumerate() {
5594                strings.push(format!("slot{id}"));
5595                let name = u32::try_from(strings.len() - 1).unwrap();
5596                decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
5597            }
5598            let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
5599                .islands
5600                .iter()
5601                .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
5602                .collect();
5603            let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
5604            let data = build_minimal_ir(&strings, &decls, &self.ops, &islands);
5605            let module = IrModule::parse(&data).unwrap();
5606            let mut slots = SlotData::new(self.slots.len());
5607            for (id, (_, value)) in self.slots.iter().enumerate() {
5608                slots.set(u16::try_from(id).unwrap(), value.clone());
5609            }
5610            (module, slots)
5611        }
5612
5613        fn render(&self) -> String {
5614            let (module, slots) = self.module_and_slots();
5615            walk_to_html(&module, &slots).unwrap()
5616        }
5617
5618        fn render_island(&self, id: u16) -> String {
5619            let (module, slots) = self.module_and_slots();
5620            walk_island(&module, &slots, id).unwrap()
5621        }
5622    }
5623
5624    fn text(s: &str) -> SlotValue {
5625        SlotValue::Text(s.to_string())
5626    }
5627
5628    #[test]
5629    fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
5630        // One table over every text-only parent (P2), plus an upper-case tag:
5631        // HTML tag names are case-insensitive. The value is still escaped:
5632        // RCDATA decodes the references back, and in RAWTEXT the escaping is
5633        // what stops a value from closing the element.
5634        for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
5635            let mut page = Page::new();
5636            let inside = page.slot(text("a<b&c"));
5637            let outside = page.slot(text("world"));
5638            page.open("div", &[])
5639                .open(parent, &[])
5640                .dyn_text(inside)
5641                .close(parent)
5642                .open("p", &[])
5643                .dyn_text(outside)
5644                .close("p")
5645                .close("div");
5646            assert_eq!(
5647                page.render(),
5648                format!(
5649                    "<div><{parent}>a&lt;b&amp;c</{parent}>\
5650                     <p><!--f:t1-->world<!--/f:t1--></p></div>"
5651                ),
5652                "inside <{parent}>"
5653            );
5654        }
5655    }
5656
5657    #[test]
5658    fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
5659        // Negative space (P5): the rule is four exact names, not a prefix or
5660        // substring match, and not every element with special parsing.
5661        for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
5662            let mut page = Page::new();
5663            let value = page.slot(text("hello"));
5664            page.open(parent, &[]).dyn_text(value).close(parent);
5665            assert_eq!(
5666                page.render(),
5667                format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
5668                "inside <{parent}>"
5669            );
5670        }
5671    }
5672
5673    #[test]
5674    fn an_empty_value_in_a_text_only_parent_writes_nothing() {
5675        // The zero-width space exists so a marker pair encloses a Text node.
5676        // With no markers it would only become part of the control's value.
5677        let mut page = Page::new();
5678        let inside = page.slot(text(""));
5679        let outside = page.slot(text(""));
5680        page.open("textarea", &[])
5681            .dyn_text(inside)
5682            .close("textarea")
5683            .open("p", &[])
5684            .dyn_text(outside)
5685            .close("p");
5686        assert_eq!(
5687            page.render(),
5688            "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
5689        );
5690    }
5691
5692    #[test]
5693    fn a_textarea_doubles_a_leading_line_feed() {
5694        // The parser drops one LF straight after `<textarea>` (CR and CRLF are
5695        // LF by then). The marker used to shield it; without one, the first
5696        // line of the value would be lost before JavaScript runs.
5697        fn textarea(build: impl FnOnce(&mut Page)) -> String {
5698            let mut page = Page::new();
5699            page.open("textarea", &[]);
5700            build(&mut page);
5701            page.close("textarea");
5702            page.render()
5703        }
5704        let cases: [(&str, String, &str); 5] = [
5705            (
5706                "a dynamic value",
5707                textarea(|p| {
5708                    let v = p.slot(text("\nhello"));
5709                    p.dyn_text(v);
5710                }),
5711                "<textarea>\n\nhello</textarea>",
5712            ),
5713            (
5714                "a CRLF value",
5715                textarea(|p| {
5716                    let v = p.slot(text("\r\nhello"));
5717                    p.dyn_text(v);
5718                }),
5719                "<textarea>\n\r\nhello</textarea>",
5720            ),
5721            (
5722                "an empty value first: the first written byte decides",
5723                textarea(|p| {
5724                    let empty = p.slot(text(""));
5725                    let v = p.slot(text("\nx"));
5726                    p.dyn_text(empty).dyn_text(v);
5727                }),
5728                "<textarea>\n\nx</textarea>",
5729            ),
5730            (
5731                "an LF after other content is kept as is",
5732                textarea(|p| {
5733                    let v = p.slot(text("\nb"));
5734                    p.text("a").dyn_text(v);
5735                }),
5736                "<textarea>a\nb</textarea>",
5737            ),
5738            (
5739                "static text",
5740                textarea(|p| {
5741                    p.text("\nstatic");
5742                }),
5743                "<textarea>\n\nstatic</textarea>",
5744            ),
5745        ];
5746        for (case, html, expected) in cases {
5747            assert_eq!(html, expected, "{case}");
5748        }
5749
5750        // The rule is the textarea's alone: the parser drops no LF in a title.
5751        let mut page = Page::new();
5752        let v = page.slot(text("\nhello"));
5753        page.open("title", &[]).dyn_text(v).close("title");
5754        assert_eq!(page.render(), "<title>\nhello</title>");
5755    }
5756
5757    #[test]
5758    fn no_marker_kind_is_written_inside_a_text_only_parent() {
5759        // SHOW_IF still chooses its branch and LIST still repeats its body;
5760        // only their markers go. The same SHOW_IF in a `p` keeps them.
5761        let mut page = Page::new();
5762        let loading = page.slot(SlotValue::Bool(false));
5763        let name = page.slot(text("Ada"));
5764        let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5765        let tag = page.slot(SlotValue::Null);
5766        page.open("title", &[])
5767            .show_if(
5768                loading,
5769                |p| {
5770                    p.text("Loading");
5771                },
5772                |p| {
5773                    p.dyn_text(name);
5774                },
5775            )
5776            .text(" | ")
5777            .list(tags, tag, |p| {
5778                p.dyn_text(tag).text(";");
5779            })
5780            .close("title")
5781            .open("p", &[])
5782            .show_if(
5783                loading,
5784                |p| {
5785                    p.text("Loading");
5786                },
5787                |p| {
5788                    p.dyn_text(name);
5789                },
5790            )
5791            .close("p");
5792        assert_eq!(
5793            page.render(),
5794            "<title>Ada | x;y;</title>\
5795             <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5796        );
5797
5798        // An island marker would be text too.
5799        let mut page = Page::new();
5800        page.open("textarea", &[])
5801            .island(0, |p| {
5802                p.text("x");
5803            })
5804            .close("textarea")
5805            .island(1, |p| {
5806                p.open("div", &[]).close("div");
5807            });
5808        assert_eq!(
5809            page.render(),
5810            "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5811             data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5812             data-forma-hydrate=\"load\"></div><!--/f:i1-->"
5813        );
5814    }
5815
5816    #[test]
5817    fn a_textarea_value_binding_is_written_as_its_text() {
5818        // `<textarea value>` is a property: HTML ignores the attribute, so the
5819        // server shipped an empty control. The client writes
5820        // `value == null ? '' : String(value)`, and so does the walker.
5821        let cases = [
5822            (text("hello"), "hello"),
5823            (
5824                text("</textarea><script>x</script>"),
5825                "&lt;/textarea&gt;&lt;script&gt;x&lt;/script&gt;",
5826            ),
5827            (text("\nhello"), "\n\nhello"),
5828            (SlotValue::Null, ""),
5829            (SlotValue::Number(0.0), "0"),
5830            (SlotValue::Bool(false), "false"),
5831        ];
5832        for (value, inner) in cases {
5833            let mut page = Page::new();
5834            let v = page.slot(value.clone());
5835            let note = page.slot(text("note"));
5836            page.open("textarea", &[("name", "t")])
5837                .dyn_attr("value", v)
5838                .close("textarea")
5839                .open("p", &[])
5840                .dyn_text(note)
5841                .close("p");
5842            assert_eq!(
5843                page.render(),
5844                format!(
5845                    "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5846                ),
5847                "value {value:?}"
5848            );
5849        }
5850
5851        // The static form, `<textarea value="hello">`, is the same property.
5852        let mut page = Page::new();
5853        page.open("textarea", &[("name", "t"), ("value", "hello")])
5854            .close("textarea");
5855        assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5856    }
5857
5858    #[test]
5859    fn a_textarea_value_binding_wins_over_its_children() {
5860        // The live control shows the bound value whatever its children say,
5861        // so the server writes the value and not the children.
5862        let mut page = Page::new();
5863        let v = page.slot(text("bound"));
5864        let child = page.slot(text("child"));
5865        page.open("textarea", &[])
5866            .dyn_attr("value", v)
5867            .dyn_text(child)
5868            .text(" default")
5869            .close("textarea")
5870            .open("p", &[])
5871            .dyn_text(child)
5872            .close("p");
5873        assert_eq!(
5874            page.render(),
5875            "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5876        );
5877    }
5878
5879    /// `<select name="c" value={value}>` over options a, b and c, each label
5880    /// distinct from its value so a match on the wrong one shows.
5881    fn select_page(value: SlotValue) -> String {
5882        let mut page = Page::new();
5883        let v = page.slot(value);
5884        page.open("select", &[("name", "c")]).dyn_attr("value", v);
5885        for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5886            page.open("option", &[("value", value)])
5887                .text(label)
5888                .close("option");
5889        }
5890        page.close("select");
5891        page.render()
5892    }
5893
5894    fn option(value: &str, label: &str, selected: bool) -> String {
5895        let selected = if selected { " selected" } else { "" };
5896        format!("<option value=\"{value}\"{selected}>{label}</option>")
5897    }
5898
5899    #[test]
5900    fn a_select_value_binding_selects_the_matching_option() {
5901        // HTML ignores `value` on a select, so the server used to ship the
5902        // first option selected, and a form submitted it before JavaScript.
5903        for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5904            let expected = format!(
5905                "<select name=\"c\">{}{}{}</select>",
5906                option("a", "Apple", chosen == "a"),
5907                option("b", "Banana", chosen == "b"),
5908                option("c", "Cherry", chosen == "c"),
5909            );
5910            assert_eq!(select_page(text(value)), expected, "value {value}");
5911        }
5912    }
5913
5914    #[test]
5915    fn a_select_value_binding_compares_as_the_client_string() {
5916        // `String(2)` is "2"; `null` is '' (the placeholder option).
5917        for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5918            let mut page = Page::new();
5919            let v = page.slot(value.clone());
5920            page.open("select", &[]).dyn_attr("value", v);
5921            for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5922                page.open("option", &[("value", value)])
5923                    .text(label)
5924                    .close("option");
5925            }
5926            page.close("select");
5927            // An empty static attribute is written bare (`emit_open_tag`).
5928            let placeholder = if chosen.is_empty() {
5929                "<option value selected>Choose</option>"
5930            } else {
5931                "<option value>Choose</option>"
5932            };
5933            let expected = format!(
5934                "<select>{placeholder}{}{}</select>",
5935                option("1", "One", chosen == "1"),
5936                option("2", "Two", chosen == "2"),
5937            );
5938            assert_eq!(page.render(), expected, "value {value:?}");
5939        }
5940    }
5941
5942    #[test]
5943    fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5944        // An option without `value` is valued by its text, stripped and
5945        // collapsed: `<option> Dark \n green </option>` is "Dark green".
5946        let render = |value: &str| {
5947            let mut page = Page::new();
5948            let v = page.slot(text(value));
5949            let label = page.slot(text("Blue"));
5950            page.open("select", &[])
5951                .dyn_attr("value", v)
5952                .open("option", &[])
5953                .text("Red")
5954                .close("option")
5955                .open("option", &[])
5956                .text(" Dark \n  green ")
5957                .close("option")
5958                .open("option", &[])
5959                .dyn_text(label)
5960                .close("option")
5961                .close("select");
5962            page.render()
5963        };
5964        assert_eq!(
5965            render("Dark green"),
5966            "<select><option>Red</option><option selected> Dark \n  green </option>\
5967             <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
5968        );
5969        assert_eq!(
5970            render("Blue"),
5971            "<select><option>Red</option><option> Dark \n  green </option>\
5972             <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
5973        );
5974    }
5975
5976    #[test]
5977    fn a_select_value_binding_reaches_options_rendered_by_a_list() {
5978        // A combination (P2): list rows, PROP, a dynamic attribute and the
5979        // select. Labels differ from values, so an option that lost its
5980        // `value` and fell back to its text would match nothing. Three rows,
5981        // then 45 with the match past row 32 (TESTING.md P4's depth floor).
5982        let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
5983            .iter()
5984            .map(|(id, label)| (id.to_string(), label.to_string()))
5985            .collect();
5986        let long: Vec<(String, String)> = (0..45)
5987            .map(|i| (format!("r{i}"), format!("Row {i}")))
5988            .collect();
5989        let cases: [(&[(String, String)], &str, usize); 4] = [
5990            (&short, "a", 0),
5991            (&short, "c", 2),
5992            (&long, "r40", 40),
5993            (&long, "r44", 44),
5994        ];
5995        for (rows_in, value, chosen) in cases {
5996            let mut page = Page::new();
5997            let v = page.slot(text(value));
5998            let rows = rows_in
5999                .iter()
6000                .map(|(id, label)| {
6001                    SlotValue::object(vec![
6002                        ("id".to_string(), text(id)),
6003                        ("label".to_string(), text(label)),
6004                    ])
6005                })
6006                .collect();
6007            let items = page.slot(SlotValue::array(rows));
6008            let item = page.slot(SlotValue::Null);
6009            let id = page.slot(SlotValue::Null);
6010            let label = page.slot(SlotValue::Null);
6011            page.open("select", &[])
6012                .dyn_attr("value", v)
6013                .list(items, item, |p| {
6014                    p.prop(item, "id", id)
6015                        .prop(item, "label", label)
6016                        .open("option", &[])
6017                        .dyn_attr("value", id)
6018                        .dyn_text(label)
6019                        .close("option");
6020                })
6021                .close("select");
6022            let expected: String = rows_in
6023                .iter()
6024                .enumerate()
6025                .map(|(i, (id, label))| {
6026                    let selected = if i == chosen { " selected" } else { "" };
6027                    format!(
6028                        "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
6029                    )
6030                })
6031                .collect();
6032            assert_eq!(
6033                page.render(),
6034                format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
6035                "value {value}"
6036            );
6037        }
6038    }
6039
6040    #[test]
6041    fn a_select_binding_ends_with_its_select() {
6042        // A `datalist` option after a bound select is a suggestion, not a
6043        // choice: the binding must not reach it. The select has no option
6044        // valued "b", so its single match is still unspent when the datalist
6045        // opens; only ending the binding at `</select>` keeps it off.
6046        let mut page = Page::new();
6047        let v = page.slot(text("b"));
6048        page.open("select", &[])
6049            .dyn_attr("value", v)
6050            .open("option", &[("value", "a")])
6051            .text("A")
6052            .close("option")
6053            .close("select")
6054            .open("datalist", &[])
6055            .open("option", &[("value", "b")])
6056            .text("B")
6057            .close("option")
6058            .close("datalist");
6059        assert_eq!(
6060            page.render(),
6061            format!(
6062                "<select>{}</select><datalist>{}</datalist>",
6063                option("a", "A", false),
6064                option("b", "B", false)
6065            )
6066        );
6067    }
6068
6069    #[test]
6070    fn an_options_first_value_attribute_is_its_value() {
6071        // `<option value="a" value="b">`: the parser keeps the first of two
6072        // same-named attributes, so the option's value is "a".
6073        for (bound, selected) in [("a", " selected"), ("b", "")] {
6074            let mut page = Page::new();
6075            let v = page.slot(text(bound));
6076            let second = page.slot(text("b"));
6077            page.open("select", &[])
6078                .dyn_attr("value", v)
6079                .open("option", &[("value", "a")])
6080                .dyn_attr("value", second)
6081                .text("Only")
6082                .close("option")
6083                .close("select");
6084            assert_eq!(
6085                page.render(),
6086                format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
6087                "bound to {bound}"
6088            );
6089        }
6090    }
6091
6092    #[test]
6093    fn multiple_counts_only_when_the_attribute_is_written() {
6094        // A `DYN_ATTR multiple` that is false writes no attribute, so the
6095        // select is single and an array value is `String(array)`, "a,c",
6096        // which matches none of these options. True writes it, and the
6097        // array then selects each member.
6098        for (multiple, attr, chosen) in [(false, "", false), (true, " multiple", true)] {
6099            let mut page = Page::new();
6100            let flag = page.slot(SlotValue::Bool(multiple));
6101            let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6102            page.open("select", &[])
6103                .dyn_attr("multiple", flag)
6104                .dyn_attr("value", v);
6105            for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6106                page.open("option", &[("value", value)])
6107                    .text(label)
6108                    .close("option");
6109            }
6110            page.close("select");
6111            assert_eq!(
6112                page.render(),
6113                format!(
6114                    "<select{attr}>{}{}{}</select>",
6115                    option("a", "Apple", chosen),
6116                    option("b", "Banana", false),
6117                    option("c", "Cherry", chosen)
6118                ),
6119                "multiple {multiple}"
6120            );
6121        }
6122    }
6123
6124    #[test]
6125    fn a_null_member_of_a_multiple_value_compares_as_null() {
6126        // The client builds its set with `value.map(String)`, and
6127        // `String(null)` is "null", not '' as it is for a text hole.
6128        let mut page = Page::new();
6129        let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
6130        page.open("select", &[("multiple", "")])
6131            .dyn_attr("value", v);
6132        for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
6133            page.open("option", &[("value", value)])
6134                .text(label)
6135                .close("option");
6136        }
6137        page.close("select");
6138        // The middle option's static value cannot be empty (it would be
6139        // written bare), so it is `x`; the empty-string case is the
6140        // placeholder in `a_select_value_binding_compares_as_the_client_string`.
6141        assert_eq!(
6142            page.render(),
6143            format!(
6144                "<select multiple>{}{}{}</select>",
6145                option("null", "Null", true),
6146                option("x", "Empty", false),
6147                option("b", "Banana", true)
6148            )
6149        );
6150    }
6151
6152    #[test]
6153    fn an_option_ends_at_the_next_option_or_optgroup() {
6154        // `</option>` is optional: the parser ends an option at the next
6155        // `<option>` or `<optgroup>`, and an option valued by its text is
6156        // valued by the text up to there.
6157        let mut page = Page::new();
6158        let v = page.slot(text("A"));
6159        page.open("select", &[])
6160            .dyn_attr("value", v)
6161            .open("option", &[])
6162            .text("A")
6163            .open("option", &[])
6164            .text("B")
6165            .close("select");
6166        assert_eq!(
6167            page.render(),
6168            "<select><option selected>A<option>B</select>",
6169            "ended by <option>"
6170        );
6171
6172        let mut page = Page::new();
6173        let v = page.slot(text("A"));
6174        page.open("select", &[])
6175            .dyn_attr("value", v)
6176            .open("option", &[])
6177            .text("A")
6178            .open("optgroup", &[("label", "g")])
6179            .open("option", &[("value", "x")])
6180            .text("X")
6181            .close("option")
6182            .close("optgroup")
6183            .close("select");
6184        assert_eq!(
6185            page.render(),
6186            "<select><option selected>A<optgroup label=\"g\">\
6187             <option value=\"x\">X</option></optgroup></select>",
6188            "ended by <optgroup>"
6189        );
6190    }
6191
6192    #[test]
6193    fn a_single_select_selects_only_the_first_matching_option() {
6194        // `select.value = v` selects the first option with that value. Two
6195        // `selected` options would let the parser keep the last instead.
6196        let mut page = Page::new();
6197        let v = page.slot(text("b"));
6198        page.open("select", &[])
6199            .dyn_attr("value", v)
6200            .open("option", &[("value", "b")])
6201            .text("First")
6202            .close("option")
6203            .open("option", &[("value", "b")])
6204            .text("Second")
6205            .close("option")
6206            .close("select");
6207        assert_eq!(
6208            page.render(),
6209            format!(
6210                "<select>{}{}</select>",
6211                option("b", "First", true),
6212                option("b", "Second", false)
6213            )
6214        );
6215    }
6216
6217    #[test]
6218    fn a_multiple_select_selects_every_option_in_an_array_value() {
6219        // The client selects each option whose value is in the array, for a
6220        // `multiple` select only. A single select takes `String(array)`,
6221        // "a,c", which matches none of these options.
6222        let render = |attrs: &[(&str, &str)]| {
6223            let mut page = Page::new();
6224            let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6225            page.open("select", attrs).dyn_attr("value", v);
6226            for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6227                page.open("option", &[("value", value)])
6228                    .text(label)
6229                    .close("option");
6230            }
6231            page.close("select");
6232            page.render()
6233        };
6234        assert_eq!(
6235            render(&[("multiple", "")]),
6236            format!(
6237                "<select multiple>{}{}{}</select>",
6238                option("a", "Apple", true),
6239                option("b", "Banana", false),
6240                option("c", "Cherry", true)
6241            )
6242        );
6243        assert_eq!(
6244            render(&[]),
6245            format!(
6246                "<select>{}{}{}</select>",
6247                option("a", "Apple", false),
6248                option("b", "Banana", false),
6249                option("c", "Cherry", false)
6250            )
6251        );
6252    }
6253
6254    #[test]
6255    fn a_bound_select_overrides_authored_selected_on_its_options() {
6256        // The binding decides, as it does on the client. An unbound select
6257        // keeps what the author wrote, static and dynamic.
6258        let render = |bound: bool| {
6259            let mut page = Page::new();
6260            let v = page.slot(text("a"));
6261            let on = page.slot(SlotValue::Bool(true));
6262            page.open("select", &[]);
6263            if bound {
6264                page.dyn_attr("value", v);
6265            }
6266            page.open("option", &[("value", "a")])
6267                .text("A")
6268                .close("option")
6269                .open("option", &[("value", "b"), ("selected", "")])
6270                .text("B")
6271                .close("option")
6272                .open("option", &[("value", "c")])
6273                .dyn_attr("selected", on)
6274                .text("C")
6275                .close("option")
6276                .close("select");
6277            page.render()
6278        };
6279        assert_eq!(
6280            render(true),
6281            format!(
6282                "<select>{}{}{}</select>",
6283                option("a", "A", true),
6284                option("b", "B", false),
6285                option("c", "C", false)
6286            )
6287        );
6288        assert_eq!(
6289            render(false),
6290            format!(
6291                "<select>{}{}{}</select>",
6292                option("a", "A", false),
6293                option("b", "B", true),
6294                option("c", "C", true)
6295            )
6296        );
6297    }
6298
6299    #[test]
6300    fn a_static_select_value_attribute_selects_its_option() {
6301        let mut page = Page::new();
6302        page.open("select", &[("value", "b")])
6303            .open("option", &[("value", "a")])
6304            .text("A")
6305            .close("option")
6306            .open("option", &[("value", "b")])
6307            .text("B")
6308            .close("option")
6309            .close("select");
6310        assert_eq!(
6311            page.render(),
6312            format!(
6313                "<select>{}{}</select>",
6314                option("a", "A", false),
6315                option("b", "B", true)
6316            )
6317        );
6318    }
6319
6320    #[test]
6321    fn the_context_rules_hold_in_an_island_fragment() {
6322        // `walk_island` is the WASM re-render path; it must write what the
6323        // full page writes for the same island (P2).
6324        let mut page = Page::new();
6325        let body = page.slot(text("hello"));
6326        let choice = page.slot(text("b"));
6327        page.island(0, |p| {
6328            p.open("form", &[])
6329                .open("textarea", &[])
6330                .dyn_text(body)
6331                .close("textarea")
6332                .open("select", &[])
6333                .dyn_attr("value", choice)
6334                .open("option", &[("value", "a")])
6335                .text("A")
6336                .close("option")
6337                .open("option", &[("value", "b")])
6338                .text("B")
6339                .close("option")
6340                .close("select")
6341                .close("form");
6342        });
6343        let inner = format!(
6344            "<textarea>hello</textarea><select>{}{}</select>",
6345            option("a", "A", false),
6346            option("b", "B", true)
6347        );
6348        let full = page.render();
6349        let fragment = page.render_island(0);
6350        for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
6351            assert!(html.contains(&inner), "{path}: {html}");
6352        }
6353        assert!(
6354            full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
6355            "{full}"
6356        );
6357    }
6358
6359    // -- Counted walk (FORMA-120) -------------------------------------------
6360
6361    /// A "Branding" island: `<div>` + the tenant's name + one `<i>` per colour.
6362    ///
6363    /// Strings: 0 div, 1 Branding, 2 i, 3 name, 4 colors, 5 color.
6364    /// Slots: 0 name (text), 1 colors (array), 2 color (the row's item).
6365    fn branding_module() -> IrModule {
6366        let mut row = encode_open_tag(2, &[]);
6367        row.extend_from_slice(&encode_dyn_text(2, 1));
6368        row.extend_from_slice(&encode_close_tag(2));
6369        let mut ops = encode_island_start(0);
6370        ops.extend_from_slice(&encode_open_tag(0, &[]));
6371        ops.extend_from_slice(&encode_dyn_text(0, 0));
6372        ops.extend_from_slice(&encode_list(1, 2, &row));
6373        ops.extend_from_slice(&encode_close_tag(0));
6374        ops.extend_from_slice(&encode_island_end(0));
6375        let data = build_minimal_ir(
6376            &["div", "Branding", "i", "name", "colors", "color"],
6377            &[
6378                (0, 3, 0x01, 0x00, &[]),
6379                (1, 4, 0x04, 0x00, &[]),
6380                (2, 5, 0x01, 0x00, &[]),
6381            ],
6382            &ops,
6383            &[(0, 0x01, 0x01, 1, 0, &[])],
6384        );
6385        IrModule::parse(&data).unwrap()
6386    }
6387
6388    fn branding(name: &str, colors: usize) -> SlotData {
6389        let mut slots = SlotData::new(3);
6390        slots.set(0, SlotValue::Text(name.to_string()));
6391        slots.set(
6392            1,
6393            SlotValue::array(
6394                (0..colors)
6395                    .map(|i| SlotValue::Text(format!("c{i}")))
6396                    .collect(),
6397            ),
6398        );
6399        slots
6400    }
6401
6402    #[test]
6403    fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
6404        let module = branding_module();
6405        let slots = branding("Acme", 3);
6406        let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
6407
6408        assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
6409        assert_eq!(out.counts.bytes, out.html.len());
6410        // div + the name's text + 3 x (i + its text) = 8 nodes; markers and
6411        // comments are not nodes.
6412        assert_eq!(out.counts.nodes, 8);
6413        assert_eq!(out.counts.list_iterations, 3);
6414    }
6415
6416    #[test]
6417    fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
6418        let module = branding_module();
6419
6420        // The list budget: 5 rows allowed, 9 asked for. The list is refused
6421        // before its first row (FORMA-122): the div and the name were written.
6422        let limits = WalkLimits {
6423            max_list_iterations: 5,
6424            ..WalkLimits::default()
6425        };
6426        let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
6427        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
6428        assert_eq!(failure.counts.list_iterations, 0);
6429        assert_eq!(failure.counts.nodes, 2);
6430        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6431        assert!(failure.counts.bytes > 0);
6432
6433        // The output budget, set to stop the walk part-way down the list: it
6434        // stops inside the limit, never past it, and says how far it got.
6435        let limits = WalkLimits {
6436            max_output_bytes: 400,
6437            ..WalkLimits::default()
6438        };
6439        let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
6440        assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
6441        let counts = failure.counts;
6442        assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
6443        assert!(
6444            counts.list_iterations > 1 && counts.list_iterations < 50,
6445            "{counts:?}"
6446        );
6447        assert!(
6448            counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
6449            "{counts:?}"
6450        );
6451        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6452    }
6453
6454    #[test]
6455    fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
6456        // A TEXT naming string 99 parses and fails in the walk, at page level.
6457        let mut ops = encode_open_tag(0, &[]);
6458        ops.extend_from_slice(&encode_text(99));
6459        let data = build_minimal_ir(&["p"], &[], &ops, &[]);
6460        let module = IrModule::parse(&data).unwrap();
6461        let failure =
6462            walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6463        assert_eq!(
6464            failure.error,
6465            IrError::StringIndexOutOfBounds { index: 99, len: 1 }
6466        );
6467        assert!(failure.component_path.is_empty());
6468        assert_eq!(failure.counts.nodes, 1);
6469    }
6470
6471    #[test]
6472    fn counted_walk_names_every_island_around_a_failure_outermost_first() {
6473        // Outer "Shell" > inner "Card", the failure inside "Card", and a
6474        // closed sibling island before it that must not appear in the path.
6475        // Strings: 0 div, 1 Shell, 2 Card, 3 Done, 4 x.
6476        let mut ops = encode_island_start(0); // Shell, at byte 0
6477        ops.extend_from_slice(&encode_open_tag(0, &[]));
6478        let done_at = ops.len() as u32;
6479        ops.extend_from_slice(&encode_island_start(2)); // Done
6480        ops.extend_from_slice(&encode_open_tag(0, &[]));
6481        ops.extend_from_slice(&encode_close_tag(0));
6482        ops.extend_from_slice(&encode_island_end(2));
6483        let card_at = ops.len() as u32;
6484        ops.extend_from_slice(&encode_island_start(1)); // Card
6485        ops.extend_from_slice(&encode_open_tag(0, &[]));
6486        ops.extend_from_slice(&encode_text(99)); // fails here
6487        ops.extend_from_slice(&encode_close_tag(0));
6488        ops.extend_from_slice(&encode_island_end(1));
6489        ops.extend_from_slice(&encode_close_tag(0));
6490        ops.extend_from_slice(&encode_island_end(0));
6491        let data = build_minimal_ir(
6492            &["div", "Shell", "Card", "Done", "x"],
6493            &[],
6494            &ops,
6495            &[
6496                (0, 0x01, 0x01, 1, 0, &[]),
6497                (1, 0x01, 0x01, 2, card_at, &[]),
6498                (2, 0x01, 0x01, 3, done_at, &[]),
6499            ],
6500        );
6501        let module = IrModule::parse(&data).unwrap();
6502        let failure =
6503            walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6504        assert_eq!(
6505            failure.component_path,
6506            vec!["Shell".to_string(), "Card".to_string()]
6507        );
6508    }
6509
6510    #[test]
6511    fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
6512        // With the trigger compiled in (this crate's tests), the uncaught
6513        // 0.2.1 path renders the value as text; only the counted walk, which
6514        // catches the panic, ever takes it.
6515        let module = branding_module();
6516        let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
6517        let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
6518        assert!(html.contains("forma-test-fault"), "{html}");
6519        let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6520        assert!(matches!(failure.error, IrError::Panicked { .. }));
6521    }
6522
6523    #[test]
6524    fn counted_walk_catches_a_panic_and_names_its_island() {
6525        // SR44's stand-in: a component that panics on one tenant's data. The
6526        // trigger exists only in test builds and under the internal
6527        // `__fault-injection` feature.
6528        let module = branding_module();
6529        let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
6530        let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6531        match &failure.error {
6532            IrError::Panicked { message } => {
6533                assert!(message.contains("stand-in"), "{message}")
6534            }
6535            other => panic!("expected Panicked, got {other:?}"),
6536        }
6537        assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6538        // ...and the next walk, on the same module, renders.
6539        let out =
6540            walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
6541        assert!(out.html.contains("Acme"));
6542    }
6543
6544    // -- A list's length checked before its first row (FORMA-122, DR-13) ------
6545
6546    /// `<div>` around one LIST per entry of `lists`, each over its own slot
6547    /// (slot 2k, item slot 2k+1) and writing `<i>` + the item per row; a list
6548    /// may nest an inner LIST over the item slot's array.
6549    fn lists_module(lists: usize, nested: bool) -> IrModule {
6550        let mut ops = encode_open_tag(0, &[]);
6551        let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
6552        for k in 0..lists as u16 {
6553            let (list_slot, item_slot) = (2 * k, 2 * k + 1);
6554            let mut row = encode_open_tag(1, &[]);
6555            if nested {
6556                // the inner list iterates the row's item, itself an array,
6557                // binding its rows to slot 2*lists (shared, rebound per row)
6558                let inner_item = 2 * lists as u16;
6559                let mut inner_row = encode_open_tag(1, &[]);
6560                inner_row.extend_from_slice(&encode_close_tag(1));
6561                row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
6562            }
6563            row.extend_from_slice(&encode_close_tag(1));
6564            ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
6565            slots.push((list_slot, 2, 0x04, 0x00, &[]));
6566            slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
6567        }
6568        if nested {
6569            slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
6570        }
6571        ops.extend_from_slice(&encode_close_tag(0));
6572        let data = build_minimal_ir(&["div", "i", "items", "item"], &slots, &ops, &[]);
6573        IrModule::parse(&data).unwrap()
6574    }
6575
6576    fn rows(n: usize) -> SlotValue {
6577        SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
6578    }
6579
6580    fn limits(max_list_iterations: usize) -> WalkLimits {
6581        WalkLimits {
6582            max_list_iterations,
6583            ..WalkLimits::default()
6584        }
6585    }
6586
6587    #[test]
6588    fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
6589        // SR38: one row too many, and not one row is walked.
6590        let module = lists_module(1, false);
6591        let mut slots = SlotData::new(2);
6592        slots.set(0, rows(51));
6593        let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6594        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6595        assert_eq!(failure.counts.list_iterations, 0);
6596        assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
6597
6598        // The negative space: exactly the budget renders, every row.
6599        slots.set(0, rows(50));
6600        let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6601        assert_eq!(out.counts.list_iterations, 50);
6602        assert_eq!(out.html.matches("<i>").count(), 50);
6603    }
6604
6605    #[test]
6606    fn a_list_exactly_at_the_remaining_budget_still_renders() {
6607        // The budget is per render: a first list spends 20 of 50 rows, so a
6608        // second of exactly 30 renders and one of 31 fails before its first.
6609        let module = lists_module(2, false);
6610        let mut slots = SlotData::new(4);
6611        slots.set(0, rows(20));
6612        slots.set(2, rows(30));
6613        let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6614        assert_eq!(out.counts.list_iterations, 50);
6615        assert_eq!(out.html.matches("<i>").count(), 50);
6616
6617        slots.set(2, rows(31));
6618        let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6619        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6620        assert_eq!(
6621            failure.counts.list_iterations, 20,
6622            "the first list, and no more"
6623        );
6624        assert_eq!(failure.counts.nodes, 1 + 20);
6625    }
6626
6627    #[test]
6628    fn a_nested_list_is_checked_against_what_its_parents_left() {
6629        // 3 outer rows of 20 inner rows each, under 59: the outer list fits
6630        // (3 <= 59); rows and inner rows spend 1 + 20 + 1 + 20 + 1 = 43; the
6631        // third inner list (20 > 16 left) fails at its entry. At 0.2.1 the
6632        // walk ran 16 of its rows first.
6633        let module = lists_module(1, true);
6634        let mut slots = SlotData::new(3);
6635        slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
6636        let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
6637        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
6638        assert_eq!(failure.counts.list_iterations, 43);
6639
6640        // In all, 3 outer and 60 inner rows: a budget of 63 renders, 62 does not.
6641        let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
6642        assert_eq!(out.counts.list_iterations, 63);
6643        assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
6644    }
6645
6646    #[test]
6647    fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
6648        // 3 outer rows of 11 inner rows under 24: every list fits on entry
6649        // (3 <= 24, 11 <= 23, 11 <= 11), and the inner rows spend the rest, so
6650        // the outer list's third row is the one refused, by the per-row check.
6651        let module = lists_module(1, true);
6652        let mut slots = SlotData::new(3);
6653        slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
6654        let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
6655        assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
6656        assert_eq!(failure.counts.list_iterations, 24);
6657    }
6658}