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