Skip to main content

forma_ir/
walker.rs

1//! Static HTML walker — walks the IR opcode stream to produce HTML output.
2//!
3//! This is the core SSR rendering engine. It takes a parsed `IrModule` plus
4//! populated `SlotData`, walks the opcode stream linearly, and produces an
5//! HTML string suitable for embedding in the page.
6
7#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
10use crate::parser::IrModule;
11use crate::slot::{SlotData, SlotValue};
12
13// ---------------------------------------------------------------------------
14// Walk state
15// ---------------------------------------------------------------------------
16
17/// Pending island info to be injected on the next OPEN_TAG or VOID_TAG.
18struct IslandPending {
19    id: u16,
20    component_name: String,
21    inline_props: Option<String>,
22    trigger: IslandTrigger,
23}
24
25/// Serialized-JSON size above which `PropsMode::Inline` props are spilled into
26/// the shared `__forma_islands` block instead of a `data-forma-props`
27/// attribute.
28///
29/// Matches the client's documented guidance ("inline for small props, < 1KB;
30/// the script block for larger props"). Measured on the *JSON*, before HTML
31/// attribute escaping — a string-heavy payload inflates roughly 1.8x on the
32/// wire because every `"` becomes `&quot;`, so the attribute this bounds is
33/// closer to 2 KiB in the worst case.
34///
35/// The spill needs no client change: `loadIslandProps`
36/// (`@getforma/core`, src/dom/activate.ts) reads `data-forma-props` first and
37/// falls back to the shared block, and the walker emits exactly one channel
38/// per island so that precedence never has two answers to choose between.
39///
40/// A *fragment* render ([`walk_island`]) never spills — it cannot legally emit
41/// a second `__forma_islands` block — so an over-size fragment keeps its props
42/// inline.
43///
44/// Verified by: crates/forma-ir/src/walker.rs > "inline_props_over_the_ceiling_spill_to_the_shared_block"
45/// Verified by: crates/forma-ir/src/walker.rs > "island_fragment_keeps_over_size_props_inline"
46pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
47
48/// Default cap on the HTML a single walk may produce.
49///
50/// A structurally valid module can otherwise force an arbitrarily large
51/// `String` allocation inside one request handler, and Rust answers allocation
52/// failure with `handle_alloc_error` → process abort, which kills every
53/// in-flight request. Raise it via [`WalkLimits`] for an app with a
54/// legitimately large data-driven page rather than accepting a silent
55/// degradation.
56pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
57
58/// Default cap on total LIST item iterations per render, across all depths.
59///
60/// `MAX_LIST_DEPTH` bounds nesting but not volume: four LISTs bound to the
61/// same array give n^4 body walks from one small module plus one large
62/// request array.
63pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
64
65/// Resource budgets for a single walk.
66///
67/// Both are per-render totals, not per-nesting-level.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct WalkLimits {
70    /// Maximum bytes of HTML the walk may emit.
71    pub max_output_bytes: usize,
72    /// Maximum total LIST item iterations across all nesting depths.
73    pub max_list_iterations: usize,
74}
75
76impl Default for WalkLimits {
77    fn default() -> Self {
78        WalkLimits {
79            max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
80            max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
81        }
82    }
83}
84
85/// Mutable state threaded through recursive `walk_range` calls.
86///
87/// This avoids adding extra parameters every time we need new walker state
88/// (e.g., list depth, output budget).
89struct WalkState {
90    /// Current nesting depth for LIST opcodes.
91    list_depth: u8,
92    /// Pending island info to inject on the next OPEN_TAG/VOID_TAG.
93    pending_island: Option<IslandPending>,
94    /// Pending list-item key to inject on the next OPEN_TAG/VOID_TAG.
95    pending_list_key: Option<String>,
96    /// Accumulated props for ScriptTag mode, keyed by island id.
97    script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
98    /// True when an OpenTag or VoidTag has been opened but the `>` has not
99    /// been written yet. DYN_ATTR opcodes may still append attributes.
100    pending_tag_close: bool,
101    /// Tag name of the element currently being opened — needed to decide
102    /// whether a `data:image/svg+xml` URL is in an image context.
103    pending_tag_name: String,
104    /// Output-size budget. Lives on the state (not as a `walk_range`
105    /// parameter) so every recursive call sharing the same `out` enforces the
106    /// same cumulative budget by construction.
107    max_output_bytes: usize,
108    /// Running total of LIST item iterations, counted across all depths and
109    /// never decremented.
110    list_iterations: usize,
111    /// Cap for `list_iterations`.
112    max_list_iterations: usize,
113    /// Undo log for slot writes made inside a LIST body.
114    ///
115    /// A list row must render from its own scope: the item binding and any
116    /// PROP the body extracts from it are visible to that row and to nothing
117    /// else. The loop used to buy that by deep-cloning the whole `SlotData`
118    /// per row, which costs O(rows x slots) — the walk was measurably
119    /// quadratic in row count. Instead each write swaps the new value in and
120    /// pushes the displaced one here; the row's `Drop`-equivalent pops back to
121    /// the mark it took on entry, restoring exactly the state the caller had.
122    ///
123    /// Entries are strictly LIFO across nesting: an inner list unwinds its own
124    /// entries before its enclosing row unwinds. The `Vec` keeps its capacity
125    /// between rows, so steady-state pushes do not allocate.
126    slot_journal: Vec<(u16, SlotValue)>,
127    /// When true, `PropsMode::ScriptTag` islands inline their props on the
128    /// root instead of accumulating into the shared `__forma_islands` block.
129    /// Set for fragment renders, where a shared block would be dropped on
130    /// return (or, if emitted, would duplicate the page's own block).
131    force_inline_props: bool,
132}
133
134impl WalkState {
135    fn new(limits: WalkLimits) -> Self {
136        WalkState {
137            list_depth: 0,
138            pending_island: None,
139            pending_list_key: None,
140            script_tag_props: std::collections::BTreeMap::new(),
141            pending_tag_close: false,
142            pending_tag_name: String::new(),
143            max_output_bytes: limits.max_output_bytes,
144            list_iterations: 0,
145            max_list_iterations: limits.max_list_iterations,
146            slot_journal: Vec::new(),
147            force_inline_props: false,
148        }
149    }
150
151    /// Record a slot write so the enclosing LIST row can undo it.
152    ///
153    /// Outside a list there is nothing to undo — a PROP at depth 0 writes to
154    /// the walk's own `SlotData` copy for the rest of the page, which is what
155    /// the clone-per-row implementation did too.
156    fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
157        if self.list_depth > 0 {
158            self.slot_journal.push((slot_id, previous));
159        }
160    }
161
162    /// Undo every journalled write back to `mark`, restoring `slots`.
163    fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
164        while self.slot_journal.len() > mark {
165            // `len() > mark >= 0`, so the pop cannot be None.
166            if let Some((slot_id, previous)) = self.slot_journal.pop() {
167                slots.set(slot_id, previous);
168            }
169        }
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Dev-mode slot source check
175// ---------------------------------------------------------------------------
176
177/// Log a warning when a server-sourced slot has no value at render time.
178///
179/// This is a dev-mode diagnostic: if a slot is declared as `SlotSource::Server`
180/// but the handler never populated it (still `SlotValue::Null`), the page
181/// handler likely has a bug. The warning is informational and does not affect
182/// output.
183#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
184fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
185    // Gated as a whole: with `tracing` off there is nothing to report, so the
186    // lookup should not run at all. (It is an O(1) index lookup now either
187    // way, but the source should say what it means.)
188    #[cfg(feature = "tracing")]
189    if let Some(entry) = module.slot_entry(slot_id) {
190        if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
191            tracing::warn!(
192                slot_id = slot_id,
193                "Server-sourced slot has no value at render time — handler may have a bug"
194            );
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Public API
201// ---------------------------------------------------------------------------
202
203/// Walk the IR opcode stream and produce an HTML string.
204///
205/// This is the main entry point for SSR rendering. It processes every opcode
206/// in the module's bytecode section, looking up strings in the string table
207/// and slot values in `slots`.
208pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
209    walk_to_html_with_limits(module, slots, WalkLimits::default())
210}
211
212/// Walk the IR opcode stream with explicit resource budgets.
213///
214/// Use this when the default 8 MiB output cap or 50,000-iteration LIST budget
215/// is wrong for the page — raising the limit is the right answer for a
216/// legitimately large list, rather than letting the render fail and silently
217/// degrade to a client-mounted page.
218pub fn walk_to_html_with_limits(
219    module: &IrModule,
220    slots: &SlotData,
221    limits: WalkLimits,
222) -> Result<String, IrError> {
223    let ops = &module.opcodes;
224    let len = ops.len();
225    // Cap the pre-reservation: `len * 4` is a fine estimate for a real page
226    // (measured modules are tens of bytes to low KiB) but a 4x reservation on
227    // an attacker-sized section is a free amplifier, and `len * 4` can itself
228    // overflow on wasm32 where `usize` is 32 bits. Beyond the cap, String's
229    // amortized doubling costs O(log n) reallocs.
230    let mut out = String::with_capacity(len.saturating_mul(4).min(1 << 20));
231    let mut state = WalkState::new(limits);
232    let mut slots_mut = slots.clone();
233    walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
234
235    // Emit script tag for ScriptTag props mode islands
236    if !state.script_tag_props.is_empty() {
237        let json =
238            serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
239        push_checked(
240            &mut out,
241            "<script id=\"__forma_islands\" type=\"application/json\">",
242            state.max_output_bytes,
243        )?;
244        push_json_script_body(&mut out, &json, state.max_output_bytes)?;
245        push_checked(&mut out, "</script>", state.max_output_bytes)?;
246    }
247
248    Ok(out)
249}
250
251/// Append a JSON document as the body of a `<script type="application/json">`
252/// data block, escaped so it cannot terminate or re-open the enclosing
253/// element.
254///
255/// Escaping `<` is total: `<` cannot appear in JSON outside a string literal,
256/// and `<` is a valid JSON string escape, so `JSON.parse` yields
257/// byte-identical *values*. It simultaneously kills `</script`, `<!--` and
258/// `<script`. The previous `</` → `<\/` replace only covered the first of
259/// those: a payload containing `<!--` followed later by `<script` drives a
260/// spec-compliant tokenizer into the *script-data-double-escaped* state, in
261/// which our own trailing `</script>` no longer closes the element and the
262/// entire rest of the page is swallowed as script text.
263///
264/// `>` is escaped for symmetry and U+2028/U+2029 as defence in depth — those
265/// two are not needed for a `JSON.parse(textContent)` consumer, only if the
266/// payload is ever inlined as JS source instead of as a data block.
267///
268/// HYDRATION CONTRACT: the `__forma_islands` payload is JSON with `<`/`>`
269/// escaped as `<`/`>`. Consumers MUST decode it with `JSON.parse`
270/// (which undoes the escapes) and MUST NOT string-match the raw script text.
271fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
272    for ch in json.chars() {
273        match ch {
274            '<' => push_checked(out, "\\u003c", limit)?,
275            '>' => push_checked(out, "\\u003e", limit)?,
276            '\u{2028}' => push_checked(out, "\\u2028", limit)?,
277            '\u{2029}' => push_checked(out, "\\u2029", limit)?,
278            _ => {
279                if out.len() >= limit {
280                    return Err(IrError::OutputLimitExceeded { limit });
281                }
282                out.push(ch);
283            }
284        }
285    }
286    Ok(())
287}
288
289/// Walk a single island's content and produce an HTML string.
290///
291/// This is the fragment rendering entry point for WASM re-renders. It finds
292/// the island by `island_id` in the module's island table, seeks to its
293/// `byte_offset` in the opcode stream, and walks from after the ISLAND_START
294/// opcode until the matching ISLAND_END opcode.
295///
296/// Returns the island ROOT element carrying the full `data-forma-*` attribute
297/// set (`data-forma-island`, `-component`, `-status`, `-hydrate`, and
298/// `-props` when the island has any). Clients MUST swap it in with
299/// `replaceWith`/`outerHTML` and MUST NOT use `innerHTML` — that would discard
300/// exactly the attributes the runtime selects on.
301///
302/// The `<!--f:iN-->` / `<!--/f:iN-->` marker comments are deliberately NOT
303/// emitted: they live outside the root element and survive an `outerHTML`
304/// swap, so re-emitting them would duplicate them.
305///
306/// Props are always inlined on the root as `data-forma-props`, even for an
307/// island declared `PropsMode::ScriptTag`. A fragment must be self-contained:
308/// emitting a second `__forma_islands` block would duplicate a DOM id and,
309/// because the injected copy precedes the SSR one in document order, would
310/// shadow it for every other ScriptTag island on the page. The client already
311/// reads `data-forma-props` first, so this needs no JS change.
312pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
313    walk_island_with_limits(module, slots, island_id, WalkLimits::default())
314}
315
316/// [`walk_island`] with explicit resource budgets.
317pub fn walk_island_with_limits(
318    module: &IrModule,
319    slots: &SlotData,
320    island_id: u16,
321    limits: WalkLimits,
322) -> Result<String, IrError> {
323    // 1. Find the island entry by id
324    let entry = module
325        .island_entry(island_id)
326        .ok_or(IrError::IslandNotFound(island_id))?;
327
328    let byte_offset = entry.byte_offset as usize;
329    let ops = &module.opcodes;
330
331    // 2. Validate byte_offset is in bounds. Saturating arithmetic: byte_offset
332    //    is u32-wide and `usize` is 32 bits on wasm32.
333    if byte_offset >= ops.len() {
334        return Err(IrError::BufferTooShort {
335            expected: byte_offset.saturating_add(1),
336            actual: ops.len(),
337        });
338    }
339
340    // 3. Verify we're at an ISLAND_START opcode
341    let opcode = Opcode::from_byte(ops[byte_offset])?;
342    if opcode != Opcode::IslandStart {
343        return Err(IrError::InvalidOpcode(ops[byte_offset]));
344    }
345
346    // 4. Skip ISLAND_START opcode(1) + island_id(2) = 3 bytes
347    let content_start = byte_offset.saturating_add(3);
348    if content_start > ops.len() {
349        return Err(IrError::BufferTooShort {
350            expected: content_start,
351            actual: ops.len(),
352        });
353    }
354
355    // 5. Replicate the ISLAND_START handler: seeking past the opcode means it
356    //    never runs, so without this the island root comes back with none of
357    //    its data-forma-* attributes and would never hydrate.
358    let mut out = String::with_capacity(256);
359    let mut state = WalkState::new(limits);
360    // Applies to nested islands inside this one as well, so a ScriptTag island
361    // nested in the fragment keeps its props instead of losing them on return.
362    state.force_inline_props = true;
363    let mut slots_mut = slots.clone();
364
365    let component_name = module.strings.get(entry.name_str_idx)?.to_string();
366    let inline_props = match entry.props_mode {
367        // ScriptTag is deliberately treated as Inline here — see the doc
368        // comment: a fragment must carry its own props.
369        PropsMode::Inline | PropsMode::ScriptTag => {
370            if entry.slot_ids.is_empty() {
371                None
372            } else {
373                let props = build_island_props(module, &slots_mut, &entry.slot_ids);
374                Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
375            }
376        }
377        PropsMode::Deferred => None,
378    };
379    state.pending_island = Some(IslandPending {
380        id: island_id,
381        component_name,
382        inline_props,
383        trigger: entry.trigger,
384    });
385
386    // 6. Walk until the matching ISLAND_END, which stops the range.
387    walk_range_until_island_end(
388        module,
389        &mut slots_mut,
390        ops,
391        content_start,
392        island_id,
393        &mut out,
394        &mut state,
395        0,
396    )?;
397
398    Ok(out)
399}
400
401// ---------------------------------------------------------------------------
402// Internal: recursive sub-range walker
403// ---------------------------------------------------------------------------
404
405/// Maximum nesting depth for LIST opcodes. Prevents stack overflow from
406/// deeply nested or malicious input.
407const MAX_LIST_DEPTH: u8 = 4;
408
409/// Maximum recursion depth for control-flow opcodes (SHOW_IF, SWITCH, LIST,
410/// TRY/FALLBACK). Prevents stack overflow from deeply nested or malicious IR.
411const MAX_RECURSION_DEPTH: usize = 64;
412
413/// Controls how the walker decides when to stop processing opcodes.
414enum WalkMode {
415    /// Walk a fixed byte range `ops[start..end]`.  Used for the top-level
416    /// full-page walk **and** for all recursive sub-range calls inside
417    /// control-flow opcodes (ShowIf, Switch, List).
418    Range { end: usize },
419    /// Walk until we encounter an ISLAND_END opcode whose `island_id` matches
420    /// `target_island_id`.  Used by `walk_island()` for fragment rendering.
421    UntilIslandEnd { target_island_id: u16 },
422}
423
424/// Walk a sub-range of the opcode stream `ops[start..end]`, appending HTML to `out`.
425///
426/// This is structured as a sub-range function so that control-flow opcodes
427/// (ShowIf, List, Switch) can recurse into their body ranges.
428///
429/// `state` carries mutable walker context (list depth, output budget, etc.).
430#[allow(clippy::too_many_arguments)]
431fn walk_range(
432    module: &IrModule,
433    slots: &mut SlotData,
434    ops: &[u8],
435    start: usize,
436    end: usize,
437    out: &mut String,
438    state: &mut WalkState,
439    depth: usize,
440) -> Result<(), IrError> {
441    walk_range_impl(
442        module,
443        slots,
444        ops,
445        start,
446        WalkMode::Range { end },
447        out,
448        state,
449        depth,
450    )
451}
452
453/// Walk the opcode stream starting at `start` until we encounter an ISLAND_END
454/// with the matching `target_island_id`. Appends HTML output to `out`.
455///
456/// This is used by `walk_island()` to render just one island's content.
457/// It shares the same opcode interpretation as `walk_range` but has a different
458/// termination condition: it stops at ISLAND_END instead of a fixed end position.
459#[allow(clippy::too_many_arguments)]
460fn walk_range_until_island_end(
461    module: &IrModule,
462    slots: &mut SlotData,
463    ops: &[u8],
464    start: usize,
465    target_island_id: u16,
466    out: &mut String,
467    state: &mut WalkState,
468    depth: usize,
469) -> Result<(), IrError> {
470    walk_range_impl(
471        module,
472        slots,
473        ops,
474        start,
475        WalkMode::UntilIslandEnd { target_island_id },
476        out,
477        state,
478        depth,
479    )
480}
481
482/// Shared implementation for both `walk_range` and `walk_range_until_island_end`.
483///
484/// The `mode` parameter controls the termination condition:
485/// - `WalkMode::Range { end }` — stop when `pos >= end` (with bounds check)
486/// - `WalkMode::UntilIslandEnd { target_island_id }` — stop when we hit an
487///   ISLAND_END opcode matching the target id
488///
489/// All opcode handling is identical regardless of mode. The only differences
490/// are:
491/// 1. The loop termination condition
492/// 2. How ISLAND_END is handled (in Range mode it emits the comment marker
493///    and continues; in UntilIslandEnd mode a match causes an early return)
494/// 3. What happens when the loop ends without an explicit stop (Range returns
495///    Ok, UntilIslandEnd returns an error)
496#[allow(clippy::too_many_arguments)]
497fn walk_range_impl(
498    module: &IrModule,
499    slots: &mut SlotData,
500    ops: &[u8],
501    start: usize,
502    mode: WalkMode,
503    out: &mut String,
504    state: &mut WalkState,
505    depth: usize,
506) -> Result<(), IrError> {
507    if depth > MAX_RECURSION_DEPTH {
508        return Err(IrError::RecursionLimitExceeded);
509    }
510
511    let strings = &module.strings;
512    let mut pos = start;
513    let limit = state.max_output_bytes;
514    // Range-local, not shared on WalkState: a TRY_START and its FALLBACK are
515    // emitted in the same opcode range by construction, so a FALLBACK reached
516    // inside a nested range must never pop an entry pushed by an outer
517    // TRY_START and skip that many bytes of the wrong region. A stray FALLBACK
518    // then finds an empty stack and is a no-op, mirroring how a stray
519    // SHOW_ELSE is already tolerated. This also removes the unbounded growth
520    // when a TRY_START is walked but its FALLBACK never is.
521    let mut fallback_stack: Vec<usize> = Vec::new();
522
523    // The loop bound depends on the mode:
524    // - Range: stop at `end`, but also guard against `pos >= ops.len()`
525    // - UntilIslandEnd: stop at `ops.len()` (or earlier via ISLAND_END match)
526    let loop_bound = match mode {
527        WalkMode::Range { end } => end,
528        WalkMode::UntilIslandEnd { .. } => usize::MAX, // guarded by ops.len() check below
529    };
530
531    // Upper bound every derived offset (branch/body ends) must respect. In
532    // Range mode that is the enclosing region; on the island path there is no
533    // `end`, so the opcode stream itself is the bound.
534    let region_limit = match mode {
535        WalkMode::Range { end } => end.min(ops.len()),
536        WalkMode::UntilIslandEnd { .. } => ops.len(),
537    };
538
539    while pos < loop_bound {
540        // Cheap redundancy: every append also goes through a checked push, so
541        // a single opcode cannot blow past the budget between iterations.
542        if out.len() > limit {
543            return Err(IrError::OutputLimitExceeded { limit });
544        }
545
546        // In Range mode, `end` could exceed ops.len()` for a malformed stream.
547        // In UntilIslandEnd mode, loop_bound is MAX so we always need this check.
548        if pos >= ops.len() {
549            match mode {
550                WalkMode::Range { .. } => {
551                    return Err(IrError::BufferTooShort {
552                        expected: pos + 1,
553                        actual: ops.len(),
554                    });
555                }
556                WalkMode::UntilIslandEnd { .. } => break, // fall through to IslandNotFound error
557            }
558        }
559
560        let opcode = Opcode::from_byte(ops[pos])?;
561        pos += 1; // advance past the opcode byte
562
563        // Flush pending tag close before any opcode that is NOT DYN_ATTR.
564        // DYN_ATTR needs the tag still open so it can append attributes.
565        if state.pending_tag_close && opcode != Opcode::DynAttr {
566            push_checked(out, ">", limit)?;
567            state.pending_tag_close = false;
568        }
569
570        match opcode {
571            // ----- Fully implemented opcodes -----
572            Opcode::OpenTag | Opcode::VoidTag => {
573                let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
574                let tag = strings.get(tag_str_idx)?;
575                emit_open_tag(out, tag, &attrs, state, limit)?;
576                pos = new_pos;
577            }
578
579            Opcode::CloseTag => {
580                let str_idx = read_u32(ops, pos)?;
581                pos += 4;
582                let tag = strings.get(str_idx)?;
583                // Names are written to the output stream unescaped, so an
584                // IR-authored name is markup injection unless it is validated.
585                if !is_valid_name(tag) {
586                    return Err(IrError::InvalidName(tag.to_string()));
587                }
588                push_checked(out, "</", limit)?;
589                push_checked(out, tag, limit)?;
590                push_checked(out, ">", limit)?;
591            }
592
593            Opcode::Text => {
594                let str_idx = read_u32(ops, pos)?;
595                pos += 4;
596                let text = strings.get(str_idx)?;
597                push_escaped_html(out, text, limit)?;
598            }
599
600            Opcode::DynText => {
601                // slot_id(u16) + marker_id(u16)
602                let slot_id = read_u16(ops, pos)?;
603                pos += 2;
604                let marker_id = read_u16(ops, pos)?;
605                pos += 2;
606
607                check_slot_source(module, slot_id, slots.get(slot_id));
608                let value = slots.get(slot_id).to_text();
609
610                push_checked(out, "<!--f:t", limit)?;
611                push_u16(out, marker_id, limit)?;
612                push_checked(out, "-->", limit)?;
613                if value.is_empty() {
614                    // Always emit at least a zero-width space so the browser
615                    // creates a Text node between the markers. Without this,
616                    // <!--f:t0--><!--/f:t0--> produces no Text node in the DOM
617                    // and the hydration walker has nothing to bind the reactive
618                    // effect to.
619                    push_checked(out, "\u{200B}", limit)?;
620                } else {
621                    push_escaped_html(out, &value, limit)?;
622                }
623                push_checked(out, "<!--/f:t", limit)?;
624                push_u16(out, marker_id, limit)?;
625                push_checked(out, "-->", limit)?;
626            }
627
628            Opcode::DynAttr => {
629                // attr_str_idx(u32) + slot_id(u16) = 6 bytes
630                let attr_str_idx = read_u32(ops, pos)?;
631                let slot_id = read_u16(ops, pos + 4)?;
632                pos += 6;
633                check_slot_source(module, slot_id, slots.get(slot_id));
634
635                if state.pending_tag_close {
636                    let attr_name = strings.get(attr_str_idx)?;
637                    // `text` outlives the borrow only for the Valued case; it
638                    // is declared here so the &str can point into it.
639                    let text;
640                    let value = match slots.get(slot_id) {
641                        // An HTML boolean attribute is ON whenever it is
642                        // present, so the old `disabled="false"` meant
643                        // DISABLED. Absent for false/null, bare for true —
644                        // byte-for-byte what `renderAttr` in @getforma/core
645                        // produces for the same value.
646                        SlotValue::Bool(false) | SlotValue::Null => None,
647                        SlotValue::Bool(true) => Some(AttrValue::Bare),
648                        other => {
649                            text = other.to_text();
650                            // An empty string is a value, not an absence:
651                            // `renderAttr` emits `value=""`, and dropping the
652                            // attribute instead is a hydration mismatch on the
653                            // common `value=""` / `alt=""` case.
654                            Some(AttrValue::Valued(text.as_str()))
655                        }
656                    };
657                    if let Some(value) = value {
658                        // `pending_tag_name` is the element this attribute is
659                        // being written to — it decides whether a
660                        // data:image/svg+xml value is in an image context.
661                        let tag = std::mem::take(&mut state.pending_tag_name);
662                        emit_attr(out, &tag, attr_name, value, limit)?;
663                        state.pending_tag_name = tag;
664                    }
665                }
666                // If not inside a tag opening (shouldn't happen in well-formed IR), skip silently.
667            }
668
669            Opcode::IslandStart => {
670                // island_id(u16)
671                let island_id = read_u16(ops, pos)?;
672                pos += 2;
673
674                // Only one element can carry a `data-forma-island` set. If an
675                // island's body starts another island before opening a tag of
676                // its own, the outer island's attributes are silently
677                // discarded and it never hydrates, never disposes, and cannot
678                // be found by `activateIslands`. Fail the render instead: the
679                // compiler owes every island a shell element.
680                if let Some(outer) = &state.pending_island {
681                    return Err(IrError::NestedIslandRoot {
682                        outer: outer.id,
683                        inner: island_id,
684                    });
685                }
686
687                push_checked(out, "<!--f:i", limit)?;
688                push_u16(out, island_id, limit)?;
689                push_checked(out, "-->", limit)?;
690
691                // Look up island entry from the island table (O(1) index).
692                // `IrModule::validate` rejects an ISLAND_START naming an
693                // island the table does not declare, so the `else` below is
694                // reachable only for a module that skipped validation.
695                // Verified by: crates/forma-ir/src/parser.rs > "validate_rejects_island_start_missing_from_table"
696                if let Some(entry) = module.island_entry(island_id) {
697                    let component_name = strings.get(entry.name_str_idx)?.to_string();
698
699                    // Build props JSON from the island's slot_ids
700                    let inline_props = match entry.props_mode {
701                        PropsMode::Inline => {
702                            if entry.slot_ids.is_empty() {
703                                None
704                            } else {
705                                let props = build_island_props(module, slots, &entry.slot_ids);
706                                let json = serde_json::to_string(&props)
707                                    .unwrap_or_else(|_| "{}".to_string());
708                                if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
709                                {
710                                    // Spill: the payload goes into the shared
711                                    // block instead of a multi-kilobyte
712                                    // attribute. Exactly one channel is used,
713                                    // so the client's inline-wins precedence
714                                    // never has two answers to choose between.
715                                    state
716                                        .script_tag_props
717                                        .insert(island_id, serde_json::Value::Object(props));
718                                    None
719                                } else {
720                                    Some(json)
721                                }
722                            }
723                        }
724                        PropsMode::ScriptTag if state.force_inline_props => {
725                            // Fragment render: keep this island self-contained
726                            // rather than pushing into a shared block that the
727                            // fragment cannot legally emit.
728                            if entry.slot_ids.is_empty() {
729                                None
730                            } else {
731                                let props = build_island_props(module, slots, &entry.slot_ids);
732                                Some(
733                                    serde_json::to_string(&props)
734                                        .unwrap_or_else(|_| "{}".to_string()),
735                                )
736                            }
737                        }
738                        PropsMode::ScriptTag => {
739                            // Accumulate for script tag emission after walk
740                            if !entry.slot_ids.is_empty() {
741                                let props = build_island_props(module, slots, &entry.slot_ids);
742                                state
743                                    .script_tag_props
744                                    .insert(island_id, serde_json::Value::Object(props));
745                            }
746                            None
747                        }
748                        PropsMode::Deferred => None,
749                    };
750
751                    state.pending_island = Some(IslandPending {
752                        id: island_id,
753                        component_name,
754                        inline_props,
755                        trigger: entry.trigger,
756                    });
757                } else {
758                    #[cfg(feature = "tracing")]
759                    tracing::warn!(
760                        island_id = island_id,
761                        "ISLAND_START names an island that is not in the island table — \
762                         markers emitted but no data-forma-* attributes, so it will not hydrate \
763                         (module was not validated)"
764                    );
765                }
766            }
767
768            Opcode::IslandEnd => {
769                // island_id(u16)
770                let island_id = read_u16(ops, pos)?;
771                pos += 2;
772
773                // The island is over: anything still pending belongs to no
774                // element inside it. Without this, an ISLAND_START not
775                // followed by a tag leaks its identity attributes onto
776                // whatever element comes next — possibly one emitted after the
777                // island's own closing marker.
778                if state.pending_island.take().is_some() {
779                    #[cfg(feature = "tracing")]
780                    tracing::warn!(
781                        island_id = island_id,
782                        "ISLAND_START was not followed by an element — island attributes dropped, \
783                         this island will not hydrate (compiler should emit a shell element)"
784                    );
785                }
786
787                // In UntilIslandEnd mode, a matching island_id means we are done.
788                if let WalkMode::UntilIslandEnd { target_island_id } = mode {
789                    if island_id == target_island_id {
790                        // Flush any pending tag close before returning.
791                        if state.pending_tag_close {
792                            push_checked(out, ">", limit)?;
793                            state.pending_tag_close = false;
794                        }
795                        return Ok(());
796                    }
797                }
798
799                // Either Range mode, or a nested (non-matching) island end —
800                // emit the closing marker comment and continue.
801                push_checked(out, "<!--/f:i", limit)?;
802                push_u16(out, island_id, limit)?;
803                push_checked(out, "-->", limit)?;
804            }
805
806            Opcode::Comment => {
807                // str_idx(u32)
808                let str_idx = read_u32(ops, pos)?;
809                pos += 4;
810                let text = strings.get(str_idx)?;
811                push_checked(out, "<!--", limit)?;
812                // A leading ">" or "->" closes an HTML comment immediately per
813                // the tokenizer, spilling the rest of the text into the
814                // document as markup. A single leading space makes that
815                // impossible and cannot collide with the hydration markers,
816                // which are emitted directly and never through this opcode.
817                if text.starts_with('>') || text.starts_with("->") {
818                    push_checked(out, " ", limit)?;
819                }
820                push_checked(out, &text.replace("--", "&#45;&#45;"), limit)?;
821                push_checked(out, "-->", limit)?;
822            }
823
824            // ----- Control-flow opcodes (recurse via walk_range) -----
825            Opcode::ShowIf => {
826                // slot_id(2) + then_len(4) + else_len(4) = 10 bytes header
827                let slot_id = read_u16(ops, pos)?;
828                let then_len = read_u32(ops, pos + 2)? as usize;
829                let else_len = read_u32(ops, pos + 6)? as usize;
830                pos += 10;
831
832                check_slot_source(module, slot_id, slots.get(slot_id));
833                let condition = slots.get(slot_id).as_bool();
834                let then_start = pos;
835                // Checked, and bounded by the enclosing region: a corrupt
836                // length would otherwise silently relocate content across the
837                // hydration markers, and plain `+` can wrap on wasm32.
838                let then_end = bounded_offset(pos, then_len, region_limit)?;
839                // The SHOW_ELSE marker byte must itself be inside the region
840                // (`<` not `<=`), or a SHOW_IF at the tail of a sub-range would
841                // read a byte its parent never granted.
842                if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
843                    return Err(IrError::InvalidSection);
844                }
845                let else_start = then_end + 1; // skip SHOW_ELSE byte
846                let else_end = bounded_offset(else_start, else_len, region_limit)?;
847
848                // Emit conditional marker
849                push_checked(out, "<!--f:s", limit)?;
850                push_u16(out, slot_id, limit)?;
851                push_checked(out, "-->", limit)?;
852
853                if condition {
854                    walk_range(
855                        module,
856                        slots,
857                        ops,
858                        then_start,
859                        then_end,
860                        out,
861                        state,
862                        depth + 1,
863                    )?;
864                } else {
865                    walk_range(
866                        module,
867                        slots,
868                        ops,
869                        else_start,
870                        else_end,
871                        out,
872                        state,
873                        depth + 1,
874                    )?;
875                }
876
877                push_checked(out, "<!--/f:s", limit)?;
878                push_u16(out, slot_id, limit)?;
879                push_checked(out, "-->", limit)?;
880
881                // Advance pos past both branches + SHOW_ELSE marker byte
882                pos = else_end;
883            }
884
885            Opcode::ShowElse => {
886                // SHOW_ELSE should never be reached during normal walking — it is
887                // skipped over by the SHOW_IF handler. If we reach it, the opcode
888                // stream is malformed, but we tolerate it gracefully by doing nothing.
889            }
890
891            // LIMITATION, not an oversight: SWITCH emits no `<!--f:…-->`
892            // marker pair, because the hydration wire contract has no switch
893            // marker — it defines exactly `f:tN` (text), `f:sN` (conditional),
894            // `f:lN` (list) and `f:iN` (island), and those bytes are shared
895            // with @getforma/core and ksx Studio. A switched region is
896            // therefore server-rendered only: the client cannot locate it to
897            // re-render when the slot changes. Adding one is a coordinated
898            // change to both renderers and the client walker, not a
899            // walker-local fix. SWITCH also has no default case in the opcode
900            // layout; a non-matching value renders nothing.
901            Opcode::Switch => {
902                let slot_id = read_u16(ops, pos)?;
903                let case_count = read_u16(ops, pos + 2)? as usize;
904                pos += 4;
905
906                // Read case headers: [(val_str_idx, body_len)]
907                // Bound the reservation by the bytes actually available — a
908                // valid stream still gets a single exact allocation.
909                let mut cases =
910                    Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
911                for _ in 0..case_count {
912                    let val_str_idx = read_u32(ops, pos)?;
913                    let body_len = read_u32(ops, pos + 4)? as usize;
914                    cases.push((val_str_idx, body_len));
915                    pos += 8;
916                }
917
918                // Now pos points to start of first case body
919                check_slot_source(module, slot_id, slots.get(slot_id));
920                let slot_text = slots.get(slot_id).to_text();
921                let mut body_pos = pos;
922                // SWITCH is first-match-wins, like the `switch` it compiles
923                // from. Without this, a module with two cases carrying the
924                // same value rendered BOTH bodies concatenated.
925                let mut matched = false;
926
927                for (val_str_idx, body_len) in &cases {
928                    // Hoisted above the match test so a non-matching case is
929                    // validated too — otherwise a bad length in an unselected
930                    // case still corrupts `body_pos` for its siblings.
931                    let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
932                    let case_val = strings.get(*val_str_idx)?;
933                    if !matched && case_val == slot_text {
934                        matched = true;
935                        walk_range(
936                            module,
937                            slots,
938                            ops,
939                            body_pos,
940                            case_end,
941                            out,
942                            state,
943                            depth + 1,
944                        )?;
945                    }
946                    body_pos = case_end;
947                }
948
949                pos = body_pos; // past all case bodies
950            }
951
952            Opcode::List => {
953                // slot_id(2) + item_slot_id(2) + body_len(4) = 8 bytes header
954                let slot_id = read_u16(ops, pos)?;
955                let item_slot_id = read_u16(ops, pos + 2)?;
956                let body_len = read_u32(ops, pos + 4)? as usize;
957                pos += 8;
958
959                check_slot_source(module, slot_id, slots.get(slot_id));
960
961                let body_start = pos;
962                let body_end = bounded_offset(pos, body_len, region_limit)?;
963
964                // Emit list markers
965                let list_marker_id = slot_id;
966                push_checked(out, "<!--f:l", limit)?;
967                push_u16(out, list_marker_id, limit)?;
968                push_checked(out, "-->", limit)?;
969
970                // Take a cheap handle on the array to release the borrow on
971                // `slots` before the loop mutates it. The payload is
972                // Arc-backed, so this is a refcount bump — and holding it
973                // means a body that writes to the list's own slot cannot
974                // change what we are iterating.
975                let items = match slots.get(slot_id) {
976                    SlotValue::Array(a) => Some(a.clone()),
977                    _ => None,
978                };
979
980                if let Some(items) = items.filter(|a| !a.is_empty()) {
981                    state.list_depth += 1;
982                    if state.list_depth > MAX_LIST_DEPTH {
983                        return Err(IrError::ListDepthExceeded {
984                            max: MAX_LIST_DEPTH,
985                        });
986                    }
987                    for item in items.iter() {
988                        // Total across all depths, never decremented: depth
989                        // alone does not bound volume, and four nested LISTs
990                        // over the same array give n^4 body walks.
991                        state.list_iterations += 1;
992                        if state.list_iterations > state.max_list_iterations {
993                            return Err(IrError::ListBudgetExceeded {
994                                max: state.max_list_iterations,
995                            });
996                        }
997                        // Scoped binding instead of a per-row `SlotData`
998                        // clone: swap the item in, walk, swap back. Same
999                        // isolation between rows, without copying every other
1000                        // slot on the page once per row.
1001                        let mark = state.slot_journal.len();
1002                        let previous = slots.replace(item_slot_id, item.clone());
1003                        state.slot_journal.push((item_slot_id, previous));
1004
1005                        let walked = walk_range(
1006                            module,
1007                            slots,
1008                            ops,
1009                            body_start,
1010                            body_end,
1011                            out,
1012                            state,
1013                            depth + 1,
1014                        );
1015                        // Unwind before propagating: an aborted walk must
1016                        // still hand `slots` back exactly as it was, because
1017                        // an enclosing row is about to unwind on top of it.
1018                        state.unwind_slot_journal(slots, mark);
1019                        walked?;
1020
1021                        // A LIST_ITEM_KEY that never met an element belongs to
1022                        // this item only — do not let it leak onto whatever
1023                        // element the next item (or the code after the list)
1024                        // happens to open.
1025                        state.pending_list_key = None;
1026                    }
1027                    state.list_depth -= 1;
1028                }
1029                state.pending_list_key = None;
1030
1031                push_checked(out, "<!--/f:l", limit)?;
1032                push_u16(out, list_marker_id, limit)?;
1033                push_checked(out, "-->", limit)?;
1034
1035                pos = body_end;
1036            }
1037
1038            Opcode::TryStart => {
1039                // fallback_len(4) = 4 bytes header
1040                let fallback_len = read_u32(ops, pos)? as usize;
1041                pos += 4;
1042                // Push fallback_len so FALLBACK knows how many bytes to skip.
1043                // Main body follows inline and is walked by the outer loop.
1044                fallback_stack.push(fallback_len);
1045            }
1046
1047            Opcode::Fallback => {
1048                // Phase 2: always render main body, skip fallback.
1049                // Pop the fallback_len pushed by the preceding TRY_START.
1050                if let Some(skip) = fallback_stack.pop() {
1051                    pos = bounded_offset(pos, skip, region_limit)?;
1052                }
1053            }
1054
1055            Opcode::Preload => {
1056                if pos >= ops.len() {
1057                    return Err(IrError::BufferTooShort {
1058                        expected: pos + 1,
1059                        actual: ops.len(),
1060                    });
1061                }
1062                let resource_type = ops[pos];
1063                let url_str_idx = read_u32(ops, pos + 1)?;
1064                let url = strings.get(url_str_idx)?;
1065                pos += 5;
1066
1067                let (as_val, type_attr) = match resource_type {
1068                    1 => ("font", " type=\"font/woff2\" crossorigin"),
1069                    2 => ("style", ""),
1070                    3 => ("script", ""),
1071                    4 => ("image", ""),
1072                    _ => ("fetch", ""),
1073                };
1074                // NOTE: this tag carries no CSP nonce. Under a
1075                // `script-src 'strict-dynamic'` policy an `as="script"`
1076                // preload hint is dropped by the browser (a degradation, not a
1077                // breakage — the real module tag is nonced). Threading a nonce
1078                // in here would change shared emitted HTML and needs the
1079                // matching JS-walker change; the compiler emits no PRELOAD
1080                // opcode today, so it is latent.
1081                push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
1082                push_escaped_attr(out, url, limit)?;
1083                push_checked(out, "\" as=\"", limit)?;
1084                push_checked(out, as_val, limit)?;
1085                push_checked(out, "\"", limit)?;
1086                push_checked(out, type_attr, limit)?;
1087                push_checked(out, ">\n", limit)?;
1088            }
1089
1090            Opcode::ListItemKey => {
1091                // key_str_idx(u32) — 4 bytes (simplified format, no source byte)
1092                let key_str_idx = read_u32(ops, pos)?;
1093                pos += 4;
1094                let key_val = strings.get(key_str_idx)?;
1095                state.pending_list_key = Some(key_val.to_string());
1096            }
1097
1098            Opcode::Prop => {
1099                // src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16) = 8 bytes
1100                let src_slot_id = read_u16(ops, pos)?;
1101                let prop_str_idx = read_u32(ops, pos + 2)?;
1102                let target_slot_id = read_u16(ops, pos + 6)?;
1103                pos += 8;
1104
1105                let prop_name = strings.get(prop_str_idx)?;
1106                let value = slots.get(src_slot_id).get_property(prop_name);
1107                // Mutate `slots` directly and journal the displaced value, so
1108                // the enclosing LIST row restores it when the row ends. A PROP
1109                // outside any list writes for the rest of the page, which is
1110                // what it did when each row got its own clone.
1111                let previous = slots.replace(target_slot_id, value);
1112                state.journal_write(target_slot_id, previous);
1113            }
1114        }
1115    }
1116
1117    // Post-loop handling depends on the mode:
1118    match mode {
1119        WalkMode::Range { .. } => {
1120            // Flush any pending tag close at the end of the opcode range.
1121            //
1122            // NOTE: `pending_island` / `pending_list_key` are deliberately NOT
1123            // cleared here. This function runs for every nested control-flow
1124            // body with the same state, so clearing at range end would wipe
1125            // the island attributes of a legitimate
1126            // ISLAND_START → SHOW_IF → OPEN_TAG stream (the island root would
1127            // silently never hydrate). They are cleared at their owning
1128            // boundaries instead: ISLAND_END and the LIST item loop.
1129            if state.pending_tag_close {
1130                push_checked(out, ">", limit)?;
1131                state.pending_tag_close = false;
1132            }
1133            Ok(())
1134        }
1135        WalkMode::UntilIslandEnd { target_island_id } => {
1136            // If we reach the end of the opcode stream without finding ISLAND_END,
1137            // that means the IR is malformed. Return an error.
1138            Err(IrError::IslandNotFound(target_island_id))
1139        }
1140    }
1141}
1142
1143// ---------------------------------------------------------------------------
1144// Island props helpers
1145// ---------------------------------------------------------------------------
1146
1147/// Build a JSON object from the island's slot_ids, looking up names from
1148/// the slot table and values from the current SlotData.
1149///
1150/// DUPLICATE-NAME RULE: two of the island's slots can carry the same name (the
1151/// compiler derives some names per-element, not per-module). The first one in
1152/// `slot_ids` order wins, matching [`IrModule::slot_id_by_name`] and
1153/// [`SlotData::from_json`]; a plain `insert` made the LAST one win here, so the
1154/// island's props disagreed with every other name lookup in the crate and one
1155/// of the two values vanished with no diagnostic.
1156///
1157/// Verified by: crates/forma-ir/src/walker.rs > "island_props_keep_the_first_slot_for_a_duplicated_name"
1158fn build_island_props(
1159    module: &IrModule,
1160    slots: &SlotData,
1161    slot_ids: &[u16],
1162) -> serde_json::Map<String, serde_json::Value> {
1163    let mut map = serde_json::Map::new();
1164    for &slot_id in slot_ids {
1165        // Look up slot name from the slot table (O(1) index)
1166        if let Some(entry) = module.slot_entry(slot_id) {
1167            if let Ok(name) = module.strings.get(entry.name_str_idx) {
1168                if map.contains_key(name) {
1169                    #[cfg(feature = "tracing")]
1170                    tracing::warn!(
1171                        slot_id = slot_id,
1172                        name = name,
1173                        "Island declares two slots with the same name — keeping the first, \
1174                         the later value is not reachable from the props object"
1175                    );
1176                    continue;
1177                }
1178                let value = slots.get(slot_id).to_json();
1179                map.insert(name.to_string(), value);
1180            }
1181        } else {
1182            // `IrModule::validate` rejects this at parse time; keep the branch
1183            // observable in case a module reaches the walker unvalidated,
1184            // since a silently dropped prop means the island's SSR props
1185            // disagree with what the client expects.
1186            #[cfg(feature = "tracing")]
1187            tracing::warn!(
1188                slot_id = slot_id,
1189                "Island references a slot id that is not in the slot table — prop dropped"
1190            );
1191        }
1192    }
1193    map
1194}
1195
1196// ---------------------------------------------------------------------------
1197// Offset arithmetic
1198// ---------------------------------------------------------------------------
1199
1200/// `base + len`, rejected if it overflows or leaves the enclosing region.
1201///
1202/// Plain `+` on `usize` cannot wrap on 64-bit but can on wasm32, where
1203/// `usize` is 32 bits — and this crate ships a WASM build whose `render()` /
1204/// `render_island()` entry points take arbitrary bytes fetched at runtime.
1205fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1206    base.checked_add(len)
1207        .filter(|e| *e <= limit)
1208        .ok_or(IrError::InvalidSection)
1209}
1210
1211// ---------------------------------------------------------------------------
1212// Name and URL safety
1213// ---------------------------------------------------------------------------
1214
1215/// True if `name` is safe to emit verbatim as a tag or attribute name.
1216///
1217/// Names are written to the output stream unescaped (only *values* go through
1218/// the escapers), so a string-table entry used as a tag name — `div onload=x`
1219/// — or as an attribute key — `x"><script` — is direct markup injection. The
1220/// accepted class is `[A-Za-z_:][-A-Za-z0-9_:.]*`, byte-identical to
1221/// formajs's `isSafeAttrName`/`isSafeTagName`, and covers every valid HTML,
1222/// SVG and custom-element name (`data-*`, `aria-*`, `xlink:href`, `viewBox`,
1223/// dotted component names).
1224fn is_valid_name(name: &str) -> bool {
1225    let mut chars = name.chars();
1226    match chars.next() {
1227        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1228        _ => return false,
1229    }
1230    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1231}
1232
1233/// True for any `on…` event-handler attribute name (case-insensitive).
1234fn is_event_handler_attr(name: &str) -> bool {
1235    name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1236}
1237
1238/// Attributes whose value the browser resolves as a URL.
1239///
1240/// Mirrors formajs's `URL_ATTRS` exactly. `srcset` is deliberately absent (a
1241/// comma-separated candidate list that a single scheme test would misparse,
1242/// and always fetched in image mode) and so is `cite`; adding either would
1243/// make the Rust walker stricter than the JS renderer, which is itself a
1244/// hydration divergence.
1245const URL_ATTRS: [&str; 8] = [
1246    "href",
1247    "src",
1248    "action",
1249    "formaction",
1250    "xlink:href",
1251    "poster",
1252    "background",
1253    "data",
1254];
1255
1256/// Tags whose URL-bearing attributes are always fetched as an image or media
1257/// resource and never parsed as a document — the only places a
1258/// `data:image/svg+xml` value is safe. Mirrors formajs's `IMAGE_CONTEXT_TAGS`.
1259const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1260    "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1261    "td", "th",
1262];
1263
1264/// True if `name` is a URL-bearing attribute (case-insensitive).
1265fn is_url_attr(name: &str) -> bool {
1266    URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1267}
1268
1269/// True if writing `value` to a URL attribute on `<tag>` would create an XSS
1270/// sink.
1271///
1272/// Browsers strip ASCII whitespace and C0/C1 control characters out of a URL
1273/// before resolving its scheme, so `java\tscript:alert(1)` executes even
1274/// though a naive prefix test does not match it. The value is normalized the
1275/// same way before testing — byte-identical to formajs's `isDangerousUrl`, so
1276/// the server emits exactly what the client would have produced.
1277fn is_dangerous_url(value: &str, tag: &str) -> bool {
1278    let normalized: String = value
1279        .chars()
1280        .filter(|c| {
1281            let n = *c as u32;
1282            !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1283        })
1284        .collect();
1285    let lower = normalized.to_ascii_lowercase();
1286
1287    if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1288        return true;
1289    }
1290    if lower.starts_with("data:text/html") {
1291        return true;
1292    }
1293    // `data:image/svg+xml` is a *conditional* hazard: through an image sink it
1294    // decodes in image mode where script never runs; through a document sink
1295    // an `onload=` inside it fires. It is not blanket-blocked because the
1296    // stock CSP explicitly permits `img-src data:`.
1297    if lower.starts_with("data:image/svg+xml") {
1298        return !IMAGE_CONTEXT_TAGS
1299            .iter()
1300            .any(|t| tag.eq_ignore_ascii_case(t));
1301    }
1302    false
1303}
1304
1305/// How an attribute's value is written out.
1306///
1307/// The distinction exists because `key` and `key=""` are the same DOM but
1308/// `key` and `key="false"` are not: an HTML boolean attribute is ON whenever
1309/// it is present, whatever its value, so `disabled="false"` disables the
1310/// control. See [`AttrValue::from_slot`].
1311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312enum AttrValue<'a> {
1313    /// Write the bare name, no `="…"`.
1314    Bare,
1315    /// Write `key="escaped"`, including when `value` is empty.
1316    Valued(&'a str),
1317}
1318
1319/// Emit one attribute, dropping it entirely if it is unsafe.
1320///
1321/// Dropping (rather than emitting an empty value) is what the client does:
1322/// `renderAttr` returns null and hydration calls `removeAttribute`. An
1323/// SSR-emitted `href=""` resolves to the current page — a real navigation —
1324/// and would not match the client DOM.
1325fn emit_attr(
1326    out: &mut String,
1327    tag: &str,
1328    key: &str,
1329    val: AttrValue<'_>,
1330    limit: usize,
1331) -> Result<(), IrError> {
1332    if !is_valid_name(key) {
1333        // Skip silently (matching the walker's existing convention for
1334        // malformed attribute operands) rather than failing the whole page
1335        // for one bad key.
1336        #[cfg(feature = "tracing")]
1337        tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1338        return Ok(());
1339    }
1340    if is_event_handler_attr(key) {
1341        #[cfg(feature = "tracing")]
1342        tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1343        return Ok(());
1344    }
1345    if let AttrValue::Valued(v) = val {
1346        if is_url_attr(key) && is_dangerous_url(v, tag) {
1347            #[cfg(feature = "tracing")]
1348            tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1349            return Ok(());
1350        }
1351    }
1352
1353    push_checked(out, " ", limit)?;
1354    push_checked(out, key, limit)?;
1355    if let AttrValue::Valued(v) = val {
1356        push_checked(out, "=\"", limit)?;
1357        push_escaped_attr(out, v, limit)?;
1358        push_checked(out, "\"", limit)?;
1359    }
1360    Ok(())
1361}
1362
1363/// Emit `<tag ...attrs`, plus any pending island / list-item attributes.
1364///
1365/// Shared by OPEN_TAG and VOID_TAG, which differ only in whether a matching
1366/// CLOSE_TAG follows. The `>` is deliberately not written yet: DYN_ATTR
1367/// opcodes may still append attributes.
1368fn emit_open_tag(
1369    out: &mut String,
1370    tag: &str,
1371    attrs: &[(&str, &str)],
1372    state: &mut WalkState,
1373    limit: usize,
1374) -> Result<(), IrError> {
1375    if !is_valid_name(tag) {
1376        return Err(IrError::InvalidName(tag.to_string()));
1377    }
1378    push_checked(out, "<", limit)?;
1379    push_checked(out, tag, limit)?;
1380
1381    for (key, val) in attrs {
1382        // A static attribute is authored text, never a boolean. An empty value
1383        // stays the bare name: `key` and `key=""` parse to the same DOM, and
1384        // keeping the shorthand leaves already-compiled modules byte-stable.
1385        let value = if val.is_empty() {
1386            AttrValue::Bare
1387        } else {
1388            AttrValue::Valued(val)
1389        };
1390        emit_attr(out, tag, key, value, limit)?;
1391    }
1392
1393    // Inject pending island attributes
1394    if let Some(island) = state.pending_island.take() {
1395        push_checked(out, " data-forma-island=\"", limit)?;
1396        push_u16(out, island.id, limit)?;
1397        push_checked(out, "\" data-forma-component=\"", limit)?;
1398        push_escaped_attr(out, &island.component_name, limit)?;
1399        push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1400        let trigger_str = match island.trigger {
1401            IslandTrigger::Load => "load",
1402            IslandTrigger::Visible => "visible",
1403            IslandTrigger::Interaction => "interaction",
1404            IslandTrigger::Idle => "idle",
1405        };
1406        push_checked(out, " data-forma-hydrate=\"", limit)?;
1407        push_checked(out, trigger_str, limit)?;
1408        push_checked(out, "\"", limit)?;
1409        if let Some(ref props_json) = island.inline_props {
1410            push_checked(out, " data-forma-props=\"", limit)?;
1411            push_escaped_attr(out, props_json, limit)?;
1412            push_checked(out, "\"", limit)?;
1413        }
1414    }
1415
1416    // Inject pending list-item key
1417    if let Some(key) = state.pending_list_key.take() {
1418        push_checked(out, " data-forma-key=\"", limit)?;
1419        push_escaped_attr(out, &key, limit)?;
1420        push_checked(out, "\"", limit)?;
1421    }
1422
1423    // Don't close the tag yet — DYN_ATTR opcodes may follow.
1424    state.pending_tag_close = true;
1425    state.pending_tag_name.clear();
1426    state.pending_tag_name.push_str(tag);
1427    Ok(())
1428}
1429
1430// ---------------------------------------------------------------------------
1431// Byte-reading helpers
1432// ---------------------------------------------------------------------------
1433
1434/// Read a little-endian u16 from `data` at `pos`.
1435pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
1436    if pos + 2 > data.len() {
1437        return Err(IrError::BufferTooShort {
1438            expected: pos + 2,
1439            actual: data.len(),
1440        });
1441    }
1442    Ok(u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()))
1443}
1444
1445/// Read a little-endian u32 from `data` at `pos`.
1446pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
1447    if pos + 4 > data.len() {
1448        return Err(IrError::BufferTooShort {
1449            expected: pos + 4,
1450            actual: data.len(),
1451        });
1452    }
1453    Ok(u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()))
1454}
1455
1456/// Read an OPEN_TAG or VOID_TAG operand block:
1457/// `str_idx(u32) + attr_count(u16) + [(key_str_idx(u32), val_str_idx(u32))] x attr_count`
1458///
1459/// Returns `(tag_str_idx, vec of (key, val) string refs, new position)`.
1460pub(crate) fn read_tag_with_attrs<'a>(
1461    ops: &[u8],
1462    pos: usize,
1463    strings: &'a crate::parser::StringTable,
1464) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
1465    let tag_str_idx = read_u32(ops, pos)?;
1466    let attr_count = read_u16(ops, pos + 4)? as usize;
1467    let mut cursor = pos + 6; // past str_idx(4) + attr_count(2)
1468
1469    // `attr_count` is u16-bounded but each pair costs 8 bytes on disk and a
1470    // fat-pointer pair in memory — bound the reservation by what is actually
1471    // left. A valid stream still gets one exact allocation.
1472    let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
1473    for _ in 0..attr_count {
1474        let key_idx = read_u32(ops, cursor)?;
1475        let val_idx = read_u32(ops, cursor + 4)?;
1476        let key = strings.get(key_idx)?;
1477        let val = strings.get(val_idx)?;
1478        attrs.push((key, val));
1479        cursor += 8;
1480    }
1481
1482    Ok((tag_str_idx, attrs, cursor))
1483}
1484
1485// ---------------------------------------------------------------------------
1486// Budgeted output helpers
1487// ---------------------------------------------------------------------------
1488
1489/// Longest escape sequence any of the escapers can emit for one character
1490/// (`&amp;`, `&quot;`, `<`).
1491const MAX_ESCAPE_EXPANSION: usize = 6;
1492
1493/// Append `s`, refusing to exceed the walk's output budget.
1494///
1495/// Every append routes through here (or one of the escaping variants below)
1496/// because a *single* opcode can emit gigabytes: OPEN_TAG accepts up to 65,535
1497/// attributes, each with a 65,535-character value that escapes 5x, so a check
1498/// only at the top of the opcode loop never runs in time.
1499fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
1500    if out.len().saturating_add(s.len()) > limit {
1501        return Err(IrError::OutputLimitExceeded { limit });
1502    }
1503    out.push_str(s);
1504    Ok(())
1505}
1506
1507/// Append a `u16` in decimal, within the output budget, without allocating.
1508///
1509/// Marker ids are the hottest thing the walker writes — every DYN_TEXT emits
1510/// its id twice, every SHOW_IF and LIST likewise, and a list body repeats all
1511/// of that once per row. `to_string()` heap-allocated for each one.
1512fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
1513    // u16::MAX is 65535: five digits, never more.
1514    let mut buf = [0u8; 5];
1515    let mut remaining = value;
1516    let mut start = buf.len();
1517    loop {
1518        start -= 1;
1519        buf[start] = b'0' + (remaining % 10) as u8;
1520        remaining /= 10;
1521        if remaining == 0 {
1522            break;
1523        }
1524    }
1525    let digits = core::str::from_utf8(&buf[start..]).expect("ASCII digits are valid UTF-8");
1526    push_checked(out, digits, limit)
1527}
1528
1529/// Escape text for HTML content (`& < >`), within the output budget.
1530fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
1531    // Fast path: even worst-case expansion fits, so no per-character checks.
1532    if out
1533        .len()
1534        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
1535        <= limit
1536    {
1537        for ch in text.chars() {
1538            match ch {
1539                '&' => out.push_str("&amp;"),
1540                '<' => out.push_str("&lt;"),
1541                '>' => out.push_str("&gt;"),
1542                _ => out.push(ch),
1543            }
1544        }
1545        return Ok(());
1546    }
1547    for ch in text.chars() {
1548        match ch {
1549            '&' => push_checked(out, "&amp;", limit)?,
1550            '<' => push_checked(out, "&lt;", limit)?,
1551            '>' => push_checked(out, "&gt;", limit)?,
1552            _ => {
1553                if out.len() >= limit {
1554                    return Err(IrError::OutputLimitExceeded { limit });
1555                }
1556                out.push(ch);
1557            }
1558        }
1559    }
1560    Ok(())
1561}
1562
1563/// Escape text for HTML attribute values (`& " < >`), within the output budget.
1564fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
1565    if out
1566        .len()
1567        .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
1568        <= limit
1569    {
1570        for ch in text.chars() {
1571            match ch {
1572                '&' => out.push_str("&amp;"),
1573                '"' => out.push_str("&quot;"),
1574                '<' => out.push_str("&lt;"),
1575                '>' => out.push_str("&gt;"),
1576                _ => out.push(ch),
1577            }
1578        }
1579        return Ok(());
1580    }
1581    for ch in text.chars() {
1582        match ch {
1583            '&' => push_checked(out, "&amp;", limit)?,
1584            '"' => push_checked(out, "&quot;", limit)?,
1585            '<' => push_checked(out, "&lt;", limit)?,
1586            '>' => push_checked(out, "&gt;", limit)?,
1587            _ => {
1588                if out.len() >= limit {
1589                    return Err(IrError::OutputLimitExceeded { limit });
1590                }
1591                out.push(ch);
1592            }
1593        }
1594    }
1595    Ok(())
1596}
1597
1598// ---------------------------------------------------------------------------
1599// Tests
1600// ---------------------------------------------------------------------------
1601
1602#[cfg(test)]
1603mod tests {
1604    use super::*;
1605    use crate::parser::test_helpers::{
1606        build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
1607        encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
1608    };
1609    use crate::slot::{SlotData, SlotValue};
1610
1611    // -- Opcode encoding helpers local to walker tests ----------------------
1612
1613    /// Encode a DYN_TEXT opcode: opcode(1) + slot_id(2) + marker_id(2)
1614    fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
1615        let mut buf = Vec::new();
1616        buf.push(0x05); // Opcode::DynText
1617        buf.extend_from_slice(&slot_id.to_le_bytes());
1618        buf.extend_from_slice(&marker_id.to_le_bytes());
1619        buf
1620    }
1621
1622    /// Encode an ISLAND_START opcode: opcode(1) + island_id(2)
1623    fn encode_island_start(island_id: u16) -> Vec<u8> {
1624        let mut buf = vec![0x0B];
1625        buf.extend_from_slice(&island_id.to_le_bytes());
1626        buf
1627    }
1628
1629    /// Encode an ISLAND_END opcode: opcode(1) + island_id(2)
1630    fn encode_island_end(island_id: u16) -> Vec<u8> {
1631        let mut buf = vec![0x0C];
1632        buf.extend_from_slice(&island_id.to_le_bytes());
1633        buf
1634    }
1635
1636    /// Encode a COMMENT opcode: opcode(1) + str_idx(4)
1637    fn encode_comment(str_idx: u32) -> Vec<u8> {
1638        let mut buf = vec![0x10];
1639        buf.extend_from_slice(&str_idx.to_le_bytes());
1640        buf
1641    }
1642
1643    /// Helper: build an IrModule, walk it with empty slots, return the HTML.
1644    fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
1645        let data = build_minimal_ir(strings, &[], opcodes, &[]);
1646        let module = IrModule::parse(&data).unwrap();
1647        let slots = SlotData::new(0);
1648        walk_to_html(&module, &slots).unwrap()
1649    }
1650
1651    /// Helper: build an IrModule with slot declarations, walk with given SlotData.
1652    fn walk_with_slots(
1653        strings: &[&str],
1654        slot_decls: &[(u16, u32, u8, u8, &[u8])],
1655        opcodes: &[u8],
1656        slots: &SlotData,
1657    ) -> String {
1658        let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
1659        let module = IrModule::parse(&data).unwrap();
1660        walk_to_html(&module, slots).unwrap()
1661    }
1662
1663    /// Helper: build an IrModule with slots AND islands, walk with given SlotData.
1664    fn walk_with_islands(
1665        strings: &[&str],
1666        slot_decls: &[(u16, u32, u8, u8, &[u8])],
1667        island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
1668        opcodes: &[u8],
1669        slots: &SlotData,
1670    ) -> String {
1671        let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
1672        let module = IrModule::parse(&data).unwrap();
1673        walk_to_html(&module, slots).unwrap()
1674    }
1675
1676    /// Encode a LIST_ITEM_KEY opcode: opcode(1) + key_str_idx(4)
1677    fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
1678        let mut buf = vec![0x11]; // Opcode::ListItemKey
1679        buf.extend_from_slice(&key_str_idx.to_le_bytes());
1680        buf
1681    }
1682
1683    /// Encode a DYN_ATTR opcode: opcode(1) + attr_str_idx(4) + slot_id(2)
1684    fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
1685        let mut bytes = vec![0x06]; // Opcode::DynAttr
1686        bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
1687        bytes.extend_from_slice(&slot_id.to_le_bytes());
1688        bytes
1689    }
1690
1691    // -- Test 1: walk_static_div -------------------------------------------
1692
1693    #[test]
1694    fn walk_static_div() {
1695        // <div class="container">Hello</div>
1696        // strings: 0="div", 1="class", 2="container", 3="Hello"
1697        let mut opcodes = Vec::new();
1698        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
1699        opcodes.extend_from_slice(&encode_text(3));
1700        opcodes.extend_from_slice(&encode_close_tag(0));
1701
1702        let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
1703        assert_eq!(html, r#"<div class="container">Hello</div>"#);
1704    }
1705
1706    // -- Test 2: walk_void_tag ---------------------------------------------
1707
1708    #[test]
1709    fn walk_void_tag() {
1710        // <input type="email">
1711        // strings: 0="input", 1="type", 2="email"
1712        let opcodes = encode_void_tag(0, &[(1, 2)]);
1713
1714        let html = walk_static(&["input", "type", "email"], &opcodes);
1715        assert_eq!(html, r#"<input type="email">"#);
1716    }
1717
1718    // -- Test 3: walk_nested_elements --------------------------------------
1719
1720    #[test]
1721    fn walk_nested_elements() {
1722        // <div><span>Hi</span></div>
1723        // strings: 0="div", 1="span", 2="Hi"
1724        let mut opcodes = Vec::new();
1725        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
1726        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
1727        opcodes.extend_from_slice(&encode_text(2));
1728        opcodes.extend_from_slice(&encode_close_tag(1));
1729        opcodes.extend_from_slice(&encode_close_tag(0));
1730
1731        let html = walk_static(&["div", "span", "Hi"], &opcodes);
1732        assert_eq!(html, "<div><span>Hi</span></div>");
1733    }
1734
1735    // -- Test 4: walk_text_escaping ----------------------------------------
1736
1737    #[test]
1738    fn walk_text_escaping() {
1739        // TEXT with "<script>alert('xss')</script>"
1740        let opcodes = encode_text(0);
1741
1742        let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
1743        assert_eq!(html, "&lt;script&gt;alert('xss')&lt;/script&gt;");
1744    }
1745
1746    // -- Test 5: walk_attr_escaping ----------------------------------------
1747
1748    #[test]
1749    fn walk_attr_escaping() {
1750        // OPEN_TAG with attr value containing a double quote
1751        // strings: 0="div", 1="title", 2=r#"a"b"#
1752        let opcodes = encode_open_tag(0, &[(1, 2)]);
1753
1754        let html = walk_static(&["div", "title", "a\"b"], &opcodes);
1755        assert_eq!(html, r#"<div title="a&quot;b">"#);
1756    }
1757
1758    // -- Test 6: walk_empty_element ----------------------------------------
1759
1760    #[test]
1761    fn walk_empty_element() {
1762        // <div></div> (no children)
1763        let mut opcodes = Vec::new();
1764        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
1765        opcodes.extend_from_slice(&encode_close_tag(0));
1766
1767        let html = walk_static(&["div"], &opcodes);
1768        assert_eq!(html, "<div></div>");
1769    }
1770
1771    // -- Test 7: walk_dyn_text ---------------------------------------------
1772
1773    #[test]
1774    fn walk_dyn_text() {
1775        // DYN_TEXT with slot data produces marker comments + value
1776        // strings: 0="greeting" (slot name)
1777        // slot decl: slot_id=0, name_str_idx=0, type=Text(0x01)
1778        let opcodes = encode_dyn_text(0, 0);
1779
1780        let mut slots = SlotData::new(1);
1781        slots.set(0, SlotValue::Text("World".to_string()));
1782
1783        let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1784        assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
1785    }
1786
1787    #[test]
1788    fn walk_dyn_text_null_emits_zwsp() {
1789        // DYN_TEXT with null slot value must emit a zero-width space between
1790        // markers so the browser creates a Text node for hydration to bind to.
1791        let opcodes = encode_dyn_text(0, 0);
1792        let slots = SlotData::new(1); // slot 0 defaults to Null
1793
1794        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1795        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
1796    }
1797
1798    #[test]
1799    fn walk_dyn_text_empty_string_emits_zwsp() {
1800        // DYN_TEXT with explicit empty string also needs the ZWSP placeholder.
1801        let opcodes = encode_dyn_text(0, 0);
1802        let mut slots = SlotData::new(1);
1803        slots.set(0, SlotValue::Text(String::new()));
1804
1805        let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1806        assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
1807    }
1808
1809    // -- Test 8: walk_dyn_text_escaping ------------------------------------
1810
1811    #[test]
1812    fn walk_dyn_text_escaping() {
1813        // DYN_TEXT value with HTML chars is escaped
1814        let opcodes = encode_dyn_text(0, 1);
1815
1816        let mut slots = SlotData::new(1);
1817        slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
1818
1819        let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1820        assert_eq!(html, "<!--f:t1-->&lt;b&gt;bold&lt;/b&gt;<!--/f:t1-->");
1821    }
1822
1823    // -- Test 9: walk_island_markers ---------------------------------------
1824
1825    #[test]
1826    fn walk_island_markers() {
1827        // ISLAND_START(0) + TEXT content + ISLAND_END(0). The island body
1828        // opens no element, so the island contributes markers only — its
1829        // attributes are dropped at ISLAND_END with a warning.
1830        let mut opcodes = Vec::new();
1831        opcodes.extend_from_slice(&encode_island_start(0));
1832        opcodes.extend_from_slice(&encode_text(0));
1833        opcodes.extend_from_slice(&encode_island_end(0));
1834
1835        let slots = SlotData::new(0);
1836        let html = walk_with_islands(
1837            &["Hello"],
1838            &[],
1839            &[(0, 0x01, 0x01, 0, 0, &[])],
1840            &opcodes,
1841            &slots,
1842        );
1843        assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
1844    }
1845
1846    // -- Test 10: walk_comment ---------------------------------------------
1847
1848    #[test]
1849    fn walk_comment() {
1850        let opcodes = encode_comment(0);
1851
1852        let html = walk_static(&["This is a comment"], &opcodes);
1853        assert_eq!(html, "<!--This is a comment-->");
1854    }
1855
1856    // -- Test 11: walk_boolean_attr ----------------------------------------
1857
1858    #[test]
1859    fn walk_boolean_attr() {
1860        // <input disabled> — attribute with empty string value renders as valueless
1861        // strings: 0="input", 1="disabled", 2="" (empty string for boolean attr)
1862        let opcodes = encode_void_tag(0, &[(1, 2)]);
1863
1864        let html = walk_static(&["input", "disabled", ""], &opcodes);
1865        assert_eq!(html, "<input disabled>");
1866    }
1867
1868    // -- DYN_ATTR value semantics (W2 / F15) --------------------------------
1869
1870    /// Helper: `<input DYN_ATTR(disabled, slot 0)>` with slot 0 set to `value`.
1871    fn walk_dyn_attr_with(value: SlotValue) -> String {
1872        let mut opcodes = encode_void_tag(0, &[]);
1873        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
1874        let mut slots = SlotData::new(1);
1875        slots.set(0, value);
1876        walk_with_slots(
1877            &["input", "disabled"],
1878            &[(0, 1, 0x02, 0x00, &[])],
1879            &opcodes,
1880            &slots,
1881        )
1882    }
1883
1884    #[test]
1885    fn dyn_attr_bool_matches_the_client_renderer() {
1886        // An HTML boolean attribute is ON whenever it is PRESENT, whatever its
1887        // value — `disabled="false"` disables the control. The walker used to
1888        // emit exactly that, so SSR shipped a disabled/hidden/checked control
1889        // that only un-broke itself after hydration, and never at all for a
1890        // visible/idle/interaction island the user had not reached, or with JS
1891        // off. These are byte-for-byte what `renderAttr` in @getforma/core
1892        // produces for the same values.
1893        assert_eq!(
1894            walk_dyn_attr_with(SlotValue::Bool(true)),
1895            "<input disabled>",
1896            "true must render the bare attribute"
1897        );
1898        assert_eq!(
1899            walk_dyn_attr_with(SlotValue::Bool(false)),
1900            "<input>",
1901            "false must omit the attribute, not write disabled=\"false\""
1902        );
1903        assert_eq!(
1904            walk_dyn_attr_with(SlotValue::Null),
1905            "<input>",
1906            "null must omit the attribute"
1907        );
1908    }
1909
1910    #[test]
1911    fn dyn_attr_empty_string_is_a_value_not_an_absence() {
1912        // `renderAttr(key, "")` emits `key=""`, and the client hydrating the
1913        // same slot calls setAttribute(key, "") — dropping the attribute here
1914        // is a real DOM difference on the common `value=""` / `alt=""` case.
1915        assert_eq!(
1916            walk_dyn_attr_with(SlotValue::Text(String::new())),
1917            "<input disabled=\"\">"
1918        );
1919        assert_eq!(
1920            walk_dyn_attr_with(SlotValue::Text("yes".into())),
1921            "<input disabled=\"yes\">"
1922        );
1923        // Numbers stringify, they do not vanish: 0 is a value.
1924        assert_eq!(
1925            walk_dyn_attr_with(SlotValue::Number(0.0)),
1926            "<input disabled=\"0\">"
1927        );
1928    }
1929
1930    // -- Test 12: walk_multiple_attrs --------------------------------------
1931
1932    #[test]
1933    fn walk_multiple_attrs() {
1934        // <div id="main" class="container" data-x="42"></div>
1935        // strings: 0="div", 1="id", 2="main", 3="class", 4="container", 5="data-x", 6="42"
1936        let mut opcodes = Vec::new();
1937        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
1938        opcodes.extend_from_slice(&encode_close_tag(0));
1939
1940        let html = walk_static(
1941            &["div", "id", "main", "class", "container", "data-x", "42"],
1942            &opcodes,
1943        );
1944        assert_eq!(
1945            html,
1946            r#"<div id="main" class="container" data-x="42"></div>"#
1947        );
1948    }
1949
1950    // -- Test 13: walk_show_if_true_branch ---------------------------------
1951
1952    #[test]
1953    fn walk_show_if_true_branch() {
1954        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
1955        // slot 0 = Bool(true) → render "Yes"
1956        // strings: 0="visible", 1="Yes", 2="No"
1957        let then_ops = encode_text(1); // TEXT "Yes"
1958        let else_ops = encode_text(2); // TEXT "No"
1959        let opcodes = encode_show_if(0, &then_ops, &else_ops);
1960
1961        let mut slots = SlotData::new(1);
1962        slots.set(0, SlotValue::Bool(true));
1963
1964        let html = walk_with_slots(
1965            &["visible", "Yes", "No"],
1966            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="visible", type=Bool
1967            &opcodes,
1968            &slots,
1969        );
1970        assert!(html.contains("Yes"), "should contain then-branch text");
1971        assert!(!html.contains("No"), "should NOT contain else-branch text");
1972        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
1973        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
1974    }
1975
1976    // -- Test 14: walk_show_if_false_branch --------------------------------
1977
1978    #[test]
1979    fn walk_show_if_false_branch() {
1980        // SHOW_IF(slot=0): then=TEXT("Yes"), else=TEXT("No")
1981        // slot 0 = Bool(false) → render "No"
1982        let then_ops = encode_text(1);
1983        let else_ops = encode_text(2);
1984        let opcodes = encode_show_if(0, &then_ops, &else_ops);
1985
1986        let mut slots = SlotData::new(1);
1987        slots.set(0, SlotValue::Bool(false));
1988
1989        let html = walk_with_slots(
1990            &["visible", "Yes", "No"],
1991            &[(0, 0, 0x02, 0x00, &[])],
1992            &opcodes,
1993            &slots,
1994        );
1995        assert!(html.contains("No"), "should contain else-branch text");
1996        assert!(!html.contains("Yes"), "should NOT contain then-branch text");
1997        assert!(html.contains("<!--f:s0-->"), "should have opening marker");
1998        assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
1999    }
2000
2001    // -- Test 15: walk_show_if_truthy_text ---------------------------------
2002
2003    #[test]
2004    fn walk_show_if_truthy_text() {
2005        // slot 0 = Text("hello") → truthy → render then branch
2006        let then_ops = encode_text(1);
2007        let else_ops = encode_text(2);
2008        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2009
2010        let mut slots = SlotData::new(1);
2011        slots.set(0, SlotValue::Text("hello".to_string()));
2012
2013        let html = walk_with_slots(
2014            &["greeting", "Shown", "Hidden"],
2015            &[(0, 0, 0x01, 0x00, &[])], // type=Text
2016            &opcodes,
2017            &slots,
2018        );
2019        assert!(
2020            html.contains("Shown"),
2021            "truthy text should render then branch"
2022        );
2023        assert!(
2024            !html.contains("Hidden"),
2025            "truthy text should NOT render else branch"
2026        );
2027    }
2028
2029    // -- Test 16: walk_show_if_falsy_null ----------------------------------
2030
2031    #[test]
2032    fn walk_show_if_falsy_null() {
2033        // slot 0 = Null (default) → falsy → render else branch
2034        let then_ops = encode_text(1);
2035        let else_ops = encode_text(2);
2036        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2037
2038        let slots = SlotData::new(1); // slot 0 is Null by default
2039
2040        let html = walk_with_slots(
2041            &["maybe", "Present", "Missing"],
2042            &[(0, 0, 0x01, 0x00, &[])],
2043            &opcodes,
2044            &slots,
2045        );
2046        assert!(html.contains("Missing"), "null should render else branch");
2047        assert!(
2048            !html.contains("Present"),
2049            "null should NOT render then branch"
2050        );
2051    }
2052
2053    // -- Test 17: walk_show_if_no_else ------------------------------------
2054
2055    #[test]
2056    fn walk_show_if_no_else() {
2057        // SHOW_IF with empty else branch. When false, nothing rendered between markers.
2058        let then_ops = encode_text(1);
2059        let else_ops: Vec<u8> = vec![]; // empty else
2060        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2061
2062        let slots = SlotData::new(1); // Null → false
2063
2064        let html = walk_with_slots(
2065            &["flag", "Content"],
2066            &[(0, 0, 0x02, 0x00, &[])],
2067            &opcodes,
2068            &slots,
2069        );
2070        assert_eq!(
2071            html, "<!--f:s0--><!--/f:s0-->",
2072            "empty else should produce only markers"
2073        );
2074    }
2075
2076    // -- Test 18: walk_show_if_nested -------------------------------------
2077
2078    #[test]
2079    fn walk_show_if_nested() {
2080        // Outer SHOW_IF(slot=0, true) → then branch contains inner SHOW_IF(slot=1, false)
2081        // Inner then=TEXT("inner-yes"), inner else=TEXT("inner-no")
2082        // strings: 0="outer", 1="inner", 2="outer-yes", 3="outer-no", 4="inner-yes", 5="inner-no"
2083        let inner_then = encode_text(4);
2084        let inner_else = encode_text(5);
2085        let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2086
2087        let outer_then = inner_show_if; // outer then = inner SHOW_IF
2088        let outer_else = encode_text(3);
2089        let opcodes = encode_show_if(0, &outer_then, &outer_else);
2090
2091        let mut slots = SlotData::new(2);
2092        slots.set(0, SlotValue::Bool(true)); // outer true
2093        slots.set(1, SlotValue::Bool(false)); // inner false
2094
2095        let html = walk_with_slots(
2096            &[
2097                "outer",
2098                "inner",
2099                "outer-yes",
2100                "outer-no",
2101                "inner-yes",
2102                "inner-no",
2103            ],
2104            &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2105            &opcodes,
2106            &slots,
2107        );
2108        // Outer is true → walk inner. Inner is false → render "inner-no"
2109        assert!(
2110            html.contains("inner-no"),
2111            "nested: inner false should render inner else"
2112        );
2113        assert!(
2114            !html.contains("inner-yes"),
2115            "nested: inner true branch should not render"
2116        );
2117        assert!(
2118            !html.contains("outer-no"),
2119            "nested: outer else should not render"
2120        );
2121        // Verify correct nesting of markers
2122        assert!(
2123            html.contains("<!--f:s0-->"),
2124            "should have outer opening marker"
2125        );
2126        assert!(
2127            html.contains("<!--/f:s0-->"),
2128            "should have outer closing marker"
2129        );
2130        assert!(
2131            html.contains("<!--f:s1-->"),
2132            "should have inner opening marker"
2133        );
2134        assert!(
2135            html.contains("<!--/f:s1-->"),
2136            "should have inner closing marker"
2137        );
2138    }
2139
2140    // -- Test 19: walk_show_if_with_elements ------------------------------
2141
2142    #[test]
2143    fn walk_show_if_with_elements() {
2144        // Then branch contains OPEN_TAG("span") + TEXT("Hello") + CLOSE_TAG("span")
2145        // strings: 0="flag", 1="span", 2="Hello", 3="Bye"
2146        let mut then_ops = Vec::new();
2147        then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2148        then_ops.extend_from_slice(&encode_text(2));
2149        then_ops.extend_from_slice(&encode_close_tag(1));
2150
2151        let else_ops = encode_text(3);
2152        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2153
2154        let mut slots = SlotData::new(1);
2155        slots.set(0, SlotValue::Bool(true));
2156
2157        let html = walk_with_slots(
2158            &["flag", "span", "Hello", "Bye"],
2159            &[(0, 0, 0x02, 0x00, &[])],
2160            &opcodes,
2161            &slots,
2162        );
2163        assert_eq!(
2164            html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2165            "then branch should produce full HTML element"
2166        );
2167    }
2168
2169    // -- Test 20: walk_list_renders_items -----------------------------------
2170
2171    #[test]
2172    fn walk_list_renders_items() {
2173        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2174        // slot 0 = Array([Text("Alice"), Text("Bob")])
2175        // strings: 0="items", 1="item"
2176        let body = encode_dyn_text(1, 0);
2177        let opcodes = encode_list(0, 1, &body);
2178
2179        let mut slots = SlotData::new(2);
2180        slots.set(
2181            0,
2182            SlotValue::array(vec![
2183                SlotValue::Text("Alice".to_string()),
2184                SlotValue::Text("Bob".to_string()),
2185            ]),
2186        );
2187
2188        let html = walk_with_slots(
2189            &["items", "item"],
2190            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
2191            &opcodes,
2192            &slots,
2193        );
2194        assert!(html.contains("Alice"), "should contain first item");
2195        assert!(html.contains("Bob"), "should contain second item");
2196        assert!(
2197            html.starts_with("<!--f:l0-->"),
2198            "should start with list open marker"
2199        );
2200        assert!(
2201            html.ends_with("<!--/f:l0-->"),
2202            "should end with list close marker"
2203        );
2204    }
2205
2206    // -- Test 21: walk_list_empty_array ------------------------------------
2207
2208    #[test]
2209    fn walk_list_empty_array() {
2210        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2211        // slot 0 = Array([])
2212        let body = encode_dyn_text(1, 0);
2213        let opcodes = encode_list(0, 1, &body);
2214
2215        let mut slots = SlotData::new(2);
2216        slots.set(0, SlotValue::array(vec![]));
2217
2218        let html = walk_with_slots(
2219            &["items", "item"],
2220            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2221            &opcodes,
2222            &slots,
2223        );
2224        assert_eq!(
2225            html, "<!--f:l0--><!--/f:l0-->",
2226            "empty array should produce only markers"
2227        );
2228    }
2229
2230    // -- Test 22: walk_list_with_elements ----------------------------------
2231
2232    #[test]
2233    fn walk_list_with_elements() {
2234        // LIST(slot=0, item_slot=1): body = <li>DYN_TEXT(slot=1)</li>
2235        // slot 0 = Array([Text("A"), Text("B")])
2236        // strings: 0="items", 1="item", 2="li"
2237        let mut body = Vec::new();
2238        body.extend_from_slice(&encode_open_tag(2, &[]));
2239        body.extend_from_slice(&encode_dyn_text(1, 0));
2240        body.extend_from_slice(&encode_close_tag(2));
2241
2242        let opcodes = encode_list(0, 1, &body);
2243
2244        let mut slots = SlotData::new(2);
2245        slots.set(
2246            0,
2247            SlotValue::array(vec![
2248                SlotValue::Text("A".to_string()),
2249                SlotValue::Text("B".to_string()),
2250            ]),
2251        );
2252
2253        let html = walk_with_slots(
2254            &["items", "item", "li"],
2255            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2256            &opcodes,
2257            &slots,
2258        );
2259        assert!(
2260            html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2261            "should render li elements with content, got: {html}"
2262        );
2263    }
2264
2265    // -- Test 23: walk_list_null_slot --------------------------------------
2266
2267    #[test]
2268    fn walk_list_null_slot() {
2269        // LIST(slot=0, item_slot=1): body = DYN_TEXT(slot=1, marker=0)
2270        // slot 0 = Null (not Array) → no iteration, empty markers
2271        let body = encode_dyn_text(1, 0);
2272        let opcodes = encode_list(0, 1, &body);
2273
2274        let slots = SlotData::new(2); // slot 0 remains Null
2275
2276        let html = walk_with_slots(
2277            &["items", "item"],
2278            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2279            &opcodes,
2280            &slots,
2281        );
2282        assert_eq!(
2283            html, "<!--f:l0--><!--/f:l0-->",
2284            "null slot should produce only markers"
2285        );
2286    }
2287
2288    // -- Test 24: walk_list_nested -----------------------------------------
2289
2290    #[test]
2291    fn walk_list_nested() {
2292        // Outer LIST(slot=0, item_slot=1): each item is an Array
2293        // Inner LIST(slot=1, item_slot=2): each sub-item is Text
2294        // Body of inner: DYN_TEXT(slot=2, marker=0)
2295        // strings: 0="outer", 1="inner", 2="item"
2296        let inner_body = encode_dyn_text(2, 0);
2297        let inner_list = encode_list(1, 2, &inner_body);
2298        let opcodes = encode_list(0, 1, &inner_list);
2299
2300        let mut slots = SlotData::new(3);
2301        slots.set(
2302            0,
2303            SlotValue::array(vec![
2304                SlotValue::array(vec![
2305                    SlotValue::Text("a".to_string()),
2306                    SlotValue::Text("b".to_string()),
2307                ]),
2308                SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2309            ]),
2310        );
2311
2312        let html = walk_with_slots(
2313            &["outer", "inner", "item"],
2314            &[
2315                (0, 0, 0x04, 0x00, &[]),
2316                (1, 1, 0x04, 0x00, &[]),
2317                (2, 2, 0x01, 0x00, &[]),
2318            ],
2319            &opcodes,
2320            &slots,
2321        );
2322        // Verify all items rendered
2323        assert!(html.contains("a"), "should contain nested item 'a'");
2324        assert!(html.contains("b"), "should contain nested item 'b'");
2325        assert!(html.contains("c"), "should contain nested item 'c'");
2326        // Verify outer list markers
2327        assert!(
2328            html.starts_with("<!--f:l0-->"),
2329            "should have outer list open marker"
2330        );
2331        assert!(
2332            html.ends_with("<!--/f:l0-->"),
2333            "should have outer list close marker"
2334        );
2335        // Verify inner list markers present
2336        assert!(
2337            html.contains("<!--f:l1-->"),
2338            "should have inner list open marker"
2339        );
2340        assert!(
2341            html.contains("<!--/f:l1-->"),
2342            "should have inner list close marker"
2343        );
2344    }
2345
2346    // -- Test 25: walk_list_depth_exceeded ---------------------------------
2347
2348    #[test]
2349    fn walk_list_depth_exceeded() {
2350        // Nest 5 lists deep (exceeds MAX_LIST_DEPTH of 4)
2351        // Innermost body is a DYN_TEXT
2352        // strings: 0..=5 = "s0".."s5"
2353        let innermost_body = encode_dyn_text(5, 0);
2354        // Build from inside out: level 5 (innermost) → level 1 (outermost)
2355        let level5 = encode_list(4, 5, &innermost_body);
2356        let level4 = encode_list(3, 4, &level5);
2357        let level3 = encode_list(2, 3, &level4);
2358        let level2 = encode_list(1, 2, &level3);
2359        let level1 = encode_list(0, 1, &level2);
2360
2361        let mut slots = SlotData::new(6);
2362        // Each level needs an array with at least one item to trigger iteration
2363        slots.set(
2364            0,
2365            SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
2366                SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
2367                    "deep".to_string(),
2368                )])]),
2369            ])])]),
2370        );
2371        // Inner slots get shadowed during iteration
2372
2373        let data = build_minimal_ir(
2374            &["s0", "s1", "s2", "s3", "s4", "s5"],
2375            &[
2376                (0, 0, 0x04, 0x00, &[]),
2377                (1, 1, 0x04, 0x00, &[]),
2378                (2, 2, 0x04, 0x00, &[]),
2379                (3, 3, 0x04, 0x00, &[]),
2380                (4, 4, 0x04, 0x00, &[]),
2381                (5, 5, 0x01, 0x00, &[]),
2382            ],
2383            &level1,
2384            &[],
2385        );
2386        let module = IrModule::parse(&data).unwrap();
2387        let result = walk_to_html(&module, &slots);
2388        assert!(result.is_err(), "should fail with depth exceeded");
2389        match result.unwrap_err() {
2390            IrError::ListDepthExceeded { max: 4 } => {}
2391            other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
2392        }
2393    }
2394
2395    // -- Test 26: walk_switch_matching_case --------------------------------
2396
2397    #[test]
2398    fn walk_switch_matching_case() {
2399        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
2400        // slot 0 = Text("about") → render "About"
2401        // strings: 0="page", 1="home", 2="Home", 3="about", 4="About"
2402        let case_home_body = encode_text(2); // TEXT "Home"
2403        let case_about_body = encode_text(4); // TEXT "About"
2404        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2405
2406        let mut slots = SlotData::new(1);
2407        slots.set(0, SlotValue::Text("about".to_string()));
2408
2409        let html = walk_with_slots(
2410            &["page", "home", "Home", "about", "About"],
2411            &[(0, 0, 0x01, 0x00, &[])],
2412            &opcodes,
2413            &slots,
2414        );
2415        assert!(html.contains("About"), "should contain matching case text");
2416        assert!(
2417            !html.contains("Home"),
2418            "should NOT contain non-matching case text"
2419        );
2420    }
2421
2422    // -- Test 27: walk_switch_no_match ------------------------------------
2423
2424    #[test]
2425    fn walk_switch_no_match() {
2426        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
2427        // slot 0 = Text("missing") → no match, empty output
2428        // strings: 0="page", 1="home", 2="Home", 3="about", 4="About"
2429        let case_home_body = encode_text(2);
2430        let case_about_body = encode_text(4);
2431        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2432
2433        let mut slots = SlotData::new(1);
2434        slots.set(0, SlotValue::Text("missing".to_string()));
2435
2436        let html = walk_with_slots(
2437            &["page", "home", "Home", "about", "About"],
2438            &[(0, 0, 0x01, 0x00, &[])],
2439            &opcodes,
2440            &slots,
2441        );
2442        assert!(
2443            html.is_empty(),
2444            "no match should produce empty output, got: {html}"
2445        );
2446    }
2447
2448    // -- Test 28: walk_switch_first_case ----------------------------------
2449
2450    #[test]
2451    fn walk_switch_first_case() {
2452        // SWITCH(slot=0): cases [("home", TEXT "Home"), ("about", TEXT "About")]
2453        // slot 0 = Text("home") → matches first case, render "Home"
2454        let case_home_body = encode_text(2);
2455        let case_about_body = encode_text(4);
2456        let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2457
2458        let mut slots = SlotData::new(1);
2459        slots.set(0, SlotValue::Text("home".to_string()));
2460
2461        let html = walk_with_slots(
2462            &["page", "home", "Home", "about", "About"],
2463            &[(0, 0, 0x01, 0x00, &[])],
2464            &opcodes,
2465            &slots,
2466        );
2467        assert!(html.contains("Home"), "should contain first case text");
2468        assert!(
2469            !html.contains("About"),
2470            "should NOT contain second case text"
2471        );
2472    }
2473
2474    #[test]
2475    fn walk_switch_stops_at_the_first_match() {
2476        // Two cases carrying the SAME value. Without a break the walker
2477        // rendered both bodies concatenated — a `switch` statement does not.
2478        let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
2479
2480        let mut slots = SlotData::new(1);
2481        slots.set(0, SlotValue::Text("home".to_string()));
2482
2483        let html = walk_with_slots(
2484            &["page", "home", "FIRST", "SECOND"],
2485            &[(0, 0, 0x01, 0x00, &[])],
2486            &opcodes,
2487            &slots,
2488        );
2489        assert_eq!(html, "FIRST");
2490    }
2491
2492    // -- Test 29: walk_switch_with_elements -------------------------------
2493
2494    #[test]
2495    fn walk_switch_with_elements() {
2496        // SWITCH(slot=0): matching case body = <span>Active</span>
2497        // strings: 0="page", 1="active", 2="span", 3="Active", 4="inactive", 5="Inactive"
2498        let mut active_body = Vec::new();
2499        active_body.extend_from_slice(&encode_open_tag(2, &[]));
2500        active_body.extend_from_slice(&encode_text(3));
2501        active_body.extend_from_slice(&encode_close_tag(2));
2502
2503        let inactive_body = encode_text(5);
2504        let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
2505
2506        let mut slots = SlotData::new(1);
2507        slots.set(0, SlotValue::Text("active".to_string()));
2508
2509        let html = walk_with_slots(
2510            &["page", "active", "span", "Active", "inactive", "Inactive"],
2511            &[(0, 0, 0x01, 0x00, &[])],
2512            &opcodes,
2513            &slots,
2514        );
2515        assert_eq!(
2516            html, "<span>Active</span>",
2517            "should produce full HTML element from matching case"
2518        );
2519    }
2520
2521    // -- Test 30: walk_try_renders_main -----------------------------------
2522
2523    #[test]
2524    fn walk_try_renders_main() {
2525        // TRY_START with main body TEXT "Main" + FALLBACK TEXT "Fallback"
2526        // strings: 0="Main", 1="Fallback"
2527        let main_ops = encode_text(0);
2528        let fallback_ops = encode_text(1);
2529        let opcodes = encode_try(&main_ops, &fallback_ops);
2530
2531        let html = walk_static(&["Main", "Fallback"], &opcodes);
2532        assert!(html.contains("Main"), "should contain main body text");
2533        assert!(
2534            !html.contains("Fallback"),
2535            "should NOT contain fallback text"
2536        );
2537    }
2538
2539    // -- Test 31: walk_preload_font ---------------------------------------
2540
2541    #[test]
2542    fn walk_preload_font() {
2543        // PRELOAD resource_type=1 (font), url="/_assets/dm-mono.woff2"
2544        // strings: 0="/_assets/dm-mono.woff2"
2545        let opcodes = encode_preload(1, 0);
2546
2547        let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
2548        assert!(
2549            html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
2550            "should produce preload link for font, got: {html}"
2551        );
2552    }
2553
2554    // -- Test 32: walk_preload_style --------------------------------------
2555
2556    #[test]
2557    fn walk_preload_style() {
2558        // PRELOAD resource_type=2 (style), url="/_assets/styles.css"
2559        // strings: 0="/_assets/styles.css"
2560        let opcodes = encode_preload(2, 0);
2561
2562        let html = walk_static(&["/_assets/styles.css"], &opcodes);
2563        assert!(
2564            html.contains(r#"as="style""#),
2565            "should contain as=\"style\", got: {html}"
2566        );
2567        assert!(
2568            html.contains(r#"href="/_assets/styles.css""#),
2569            "should contain correct href, got: {html}"
2570        );
2571    }
2572
2573    // =========================================================================
2574    // Hydration-roundtrip tests — verify walker produces exact expected HTML
2575    // from programmatically-built IR. These tests complement the TS-side
2576    // roundtrip tests (ir-roundtrip.test.ts) to ensure the binary contract
2577    // between the TS emitter and the Rust walker is correct.
2578    // =========================================================================
2579
2580    // -- Roundtrip 1: Full page skeleton ------------------------------------
2581
2582    #[test]
2583    fn roundtrip_page_structure() {
2584        // Build IR for a realistic page skeleton:
2585        // <html lang="en"><head><meta charset="utf-8"><title>Test</title></head>
2586        // <body><div id="app"></div></body></html>
2587        //
2588        // strings: 0="html", 1="lang", 2="en", 3="head", 4="meta",
2589        //          5="charset", 6="utf-8", 7="title", 8="Test",
2590        //          9="body", 10="div", 11="id", 12="app"
2591        let mut opcodes = Vec::new();
2592        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <html lang="en">
2593        opcodes.extend_from_slice(&encode_open_tag(3, &[])); // <head>
2594        opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); // <meta charset="utf-8">
2595        opcodes.extend_from_slice(&encode_open_tag(7, &[])); // <title>
2596        opcodes.extend_from_slice(&encode_text(8)); // Test
2597        opcodes.extend_from_slice(&encode_close_tag(7)); // </title>
2598        opcodes.extend_from_slice(&encode_close_tag(3)); // </head>
2599        opcodes.extend_from_slice(&encode_open_tag(9, &[])); // <body>
2600        opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); // <div id="app">
2601        opcodes.extend_from_slice(&encode_close_tag(10)); // </div>
2602        opcodes.extend_from_slice(&encode_close_tag(9)); // </body>
2603        opcodes.extend_from_slice(&encode_close_tag(0)); // </html>
2604
2605        let ir = build_minimal_ir(
2606            &[
2607                "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
2608                "div", "id", "app",
2609            ],
2610            &[],
2611            &opcodes,
2612            &[],
2613        );
2614        let module = IrModule::parse(&ir).unwrap();
2615        let slots = SlotData::new(0);
2616        let html = walk_to_html(&module, &slots).unwrap();
2617        assert_eq!(
2618            html,
2619            r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
2620        );
2621    }
2622
2623    // -- Roundtrip 2: Dynamic greeting with DYN_TEXT ------------------------
2624
2625    #[test]
2626    fn roundtrip_dynamic_greeting() {
2627        // Build IR for: <div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>
2628        // with slot 0 = Text("Hello, World")
2629        //
2630        // strings: 0="div", 1="class", 2="greeting", 3="content"
2631        // slot decl: slot_id=0, name_str_idx=3 ("content"), type=Text
2632        let mut opcodes = Vec::new();
2633        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <div class="greeting">
2634        opcodes.extend_from_slice(&encode_dyn_text(0, 0)); // DYN_TEXT slot=0 marker=0
2635        opcodes.extend_from_slice(&encode_close_tag(0)); // </div>
2636
2637        let ir = build_minimal_ir(
2638            &["div", "class", "greeting", "content"],
2639            &[(0, 3, 0x01, 0x00, &[])], // slot 0, name="content", type=Text
2640            &opcodes,
2641            &[],
2642        );
2643        let module = IrModule::parse(&ir).unwrap();
2644        let mut slots = SlotData::new(1);
2645        slots.set(0, SlotValue::Text("Hello, World".into()));
2646        let html = walk_to_html(&module, &slots).unwrap();
2647        assert_eq!(
2648            html,
2649            r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
2650        );
2651    }
2652
2653    // -- Roundtrip 3: Conditional content (SHOW_IF both branches) -----------
2654
2655    #[test]
2656    fn roundtrip_conditional_content() {
2657        // SHOW_IF slot=0
2658        //   then: <span class="badge">Logged In</span>
2659        //   else: <span class="badge">Guest</span>
2660        //
2661        // strings: 0="auth", 1="span", 2="class", 3="badge",
2662        //          4="Logged In", 5="Guest"
2663        let mut then_ops = Vec::new();
2664        then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
2665        then_ops.extend_from_slice(&encode_text(4));
2666        then_ops.extend_from_slice(&encode_close_tag(1));
2667
2668        let mut else_ops = Vec::new();
2669        else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
2670        else_ops.extend_from_slice(&encode_text(5));
2671        else_ops.extend_from_slice(&encode_close_tag(1));
2672
2673        let opcodes = encode_show_if(0, &then_ops, &else_ops);
2674
2675        let ir = build_minimal_ir(
2676            &["auth", "span", "class", "badge", "Logged In", "Guest"],
2677            &[(0, 0, 0x02, 0x00, &[])], // slot 0, name="auth", type=Bool
2678            &opcodes,
2679            &[],
2680        );
2681
2682        // Test true branch
2683        {
2684            let module = IrModule::parse(&ir).unwrap();
2685            let mut slots = SlotData::new(1);
2686            slots.set(0, SlotValue::Bool(true));
2687            let html = walk_to_html(&module, &slots).unwrap();
2688            assert_eq!(
2689                html,
2690                r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
2691            );
2692        }
2693
2694        // Test false branch
2695        {
2696            let module = IrModule::parse(&ir).unwrap();
2697            let mut slots = SlotData::new(1);
2698            slots.set(0, SlotValue::Bool(false));
2699            let html = walk_to_html(&module, &slots).unwrap();
2700            assert_eq!(
2701                html,
2702                r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
2703            );
2704        }
2705    }
2706
2707    // -- Roundtrip 4: List rendering ----------------------------------------
2708
2709    #[test]
2710    fn roundtrip_list_rendering() {
2711        // LIST slot=0 item=1 → <li><!--f:t0-->{item}<!--/f:t0--></li>
2712        // Items: ["Alice", "Bob", "Charlie"]
2713        //
2714        // strings: 0="items", 1="item", 2="li"
2715        // slot decls: slot 0=Array, slot 1=Text (item)
2716        let mut body = Vec::new();
2717        body.extend_from_slice(&encode_open_tag(2, &[])); // <li>
2718        body.extend_from_slice(&encode_dyn_text(1, 0)); // DYN_TEXT slot=1 marker=0
2719        body.extend_from_slice(&encode_close_tag(2)); // </li>
2720
2721        let opcodes = encode_list(0, 1, &body);
2722
2723        let ir = build_minimal_ir(
2724            &["items", "item", "li"],
2725            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], // slot 0=Array, slot 1=Text
2726            &opcodes,
2727            &[],
2728        );
2729        let module = IrModule::parse(&ir).unwrap();
2730        let mut slots = SlotData::new(2);
2731        slots.set(
2732            0,
2733            SlotValue::array(vec![
2734                SlotValue::Text("Alice".into()),
2735                SlotValue::Text("Bob".into()),
2736                SlotValue::Text("Charlie".into()),
2737            ]),
2738        );
2739        let html = walk_to_html(&module, &slots).unwrap();
2740        assert_eq!(
2741            html,
2742            "<!--f:l0-->\
2743             <li><!--f:t0-->Alice<!--/f:t0--></li>\
2744             <li><!--f:t0-->Bob<!--/f:t0--></li>\
2745             <li><!--f:t0-->Charlie<!--/f:t0--></li>\
2746             <!--/f:l0-->"
2747        );
2748    }
2749
2750    // -- Roundtrip 5: Switch view -------------------------------------------
2751
2752    #[test]
2753    fn roundtrip_switch_view() {
2754        // SWITCH slot=0, cases: "home" → <h1>Home</h1>, "about" → <h1>About</h1>
2755        // slot = "about"
2756        //
2757        // strings: 0="page", 1="home", 2="h1", 3="Home", 4="about", 5="About"
2758        let mut home_body = Vec::new();
2759        home_body.extend_from_slice(&encode_open_tag(2, &[]));
2760        home_body.extend_from_slice(&encode_text(3));
2761        home_body.extend_from_slice(&encode_close_tag(2));
2762
2763        let mut about_body = Vec::new();
2764        about_body.extend_from_slice(&encode_open_tag(2, &[]));
2765        about_body.extend_from_slice(&encode_text(5));
2766        about_body.extend_from_slice(&encode_close_tag(2));
2767
2768        let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
2769
2770        let ir = build_minimal_ir(
2771            &["page", "home", "h1", "Home", "about", "About"],
2772            &[(0, 0, 0x01, 0x00, &[])], // slot 0, name="page", type=Text
2773            &opcodes,
2774            &[],
2775        );
2776        let module = IrModule::parse(&ir).unwrap();
2777        let mut slots = SlotData::new(1);
2778        slots.set(0, SlotValue::Text("about".into()));
2779        let html = walk_to_html(&module, &slots).unwrap();
2780        assert_eq!(html, "<h1>About</h1>");
2781    }
2782
2783    // -- Roundtrip 6: Nested realistic structure ----------------------------
2784
2785    #[test]
2786    fn roundtrip_nested_structure() {
2787        // Realistic page fragment: nav with conditional auth + list of links
2788        //
2789        // <nav class="main-nav">
2790        //   <!--f:s0-->  (SHOW_IF: logged in?)
2791        //     <span>Welcome</span>
2792        //   <!--/f:s0-->
2793        //   <ul>
2794        //     <!--f:l1-->  (LIST: nav items)
2795        //       <li><!--f:t0-->{item}<!--/f:t0--></li>
2796        //     <!--/f:l1-->
2797        //   </ul>
2798        // </nav>
2799        //
2800        // strings: 0="nav", 1="class", 2="main-nav", 3="auth",
2801        //          4="span", 5="Welcome", 6="items", 7="item",
2802        //          8="ul", 9="li"
2803        //
2804        // slots: 0=Bool (auth), 1=Array (items), 2=Text (item shadow)
2805
2806        // SHOW_IF then body: <span>Welcome</span>
2807        let mut then_ops = Vec::new();
2808        then_ops.extend_from_slice(&encode_open_tag(4, &[]));
2809        then_ops.extend_from_slice(&encode_text(5));
2810        then_ops.extend_from_slice(&encode_close_tag(4));
2811
2812        let show_if_ops = encode_show_if(0, &then_ops, &[]);
2813
2814        // LIST body: <li>DYN_TEXT(slot=2)</li>
2815        let mut list_body = Vec::new();
2816        list_body.extend_from_slice(&encode_open_tag(9, &[]));
2817        list_body.extend_from_slice(&encode_dyn_text(2, 0));
2818        list_body.extend_from_slice(&encode_close_tag(9));
2819
2820        let list_ops = encode_list(1, 2, &list_body);
2821
2822        // Full opcode stream
2823        let mut opcodes = Vec::new();
2824        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); // <nav class="main-nav">
2825        opcodes.extend_from_slice(&show_if_ops); // SHOW_IF auth
2826        opcodes.extend_from_slice(&encode_open_tag(8, &[])); // <ul>
2827        opcodes.extend_from_slice(&list_ops); // LIST items
2828        opcodes.extend_from_slice(&encode_close_tag(8)); // </ul>
2829        opcodes.extend_from_slice(&encode_close_tag(0)); // </nav>
2830
2831        let ir = build_minimal_ir(
2832            &[
2833                "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
2834            ],
2835            &[
2836                (0, 3, 0x02, 0x00, &[]), // slot 0 = Bool (auth)
2837                (1, 6, 0x04, 0x00, &[]), // slot 1 = Array (items)
2838                (2, 7, 0x01, 0x00, &[]), // slot 2 = Text (item)
2839            ],
2840            &opcodes,
2841            &[],
2842        );
2843        let module = IrModule::parse(&ir).unwrap();
2844        let mut slots = SlotData::new(3);
2845        slots.set(0, SlotValue::Bool(true));
2846        slots.set(
2847            1,
2848            SlotValue::array(vec![
2849                SlotValue::Text("Home".into()),
2850                SlotValue::Text("About".into()),
2851            ]),
2852        );
2853
2854        let html = walk_to_html(&module, &slots).unwrap();
2855        assert_eq!(
2856            html,
2857            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>"#
2858        );
2859    }
2860
2861    // =========================================================================
2862    // Island attribute injection tests
2863    // =========================================================================
2864
2865    // -- Test 33: walk_island_attrs_on_open_tag --------------------------------
2866
2867    #[test]
2868    fn walk_island_attrs_on_open_tag() {
2869        // ISLAND_START(0) + OPEN_TAG("div") + TEXT("Hello") + CLOSE_TAG("div") + ISLAND_END(0)
2870        // With island table entry: id=0, trigger=Load, props=Inline, name="AuthForm", no slots
2871        // strings: 0="div", 1="Hello", 2="AuthForm"
2872        let mut opcodes = Vec::new();
2873        opcodes.extend_from_slice(&encode_island_start(0));
2874        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2875        opcodes.extend_from_slice(&encode_text(1));
2876        opcodes.extend_from_slice(&encode_close_tag(0));
2877        opcodes.extend_from_slice(&encode_island_end(0));
2878
2879        let slots = SlotData::new(0);
2880        let html = walk_with_islands(
2881            &["div", "Hello", "AuthForm"],
2882            &[],
2883            // island: id=0, trigger=Load(0x01), props=Inline(0x01), name_str_idx=2, byte_offset=0, no slot_ids
2884            &[(0, 0x01, 0x01, 2, 0, &[])],
2885            &opcodes,
2886            &slots,
2887        );
2888        assert_eq!(
2889            html,
2890            r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
2891        );
2892    }
2893
2894    // -- Test 34: walk_island_attrs_with_existing_attrs -----------------------
2895
2896    #[test]
2897    fn walk_island_attrs_with_existing_attrs() {
2898        // ISLAND_START(0) + OPEN_TAG("div" class="form") + CLOSE_TAG + ISLAND_END(0)
2899        // strings: 0="div", 1="class", 2="form", 3="AuthForm"
2900        let mut opcodes = Vec::new();
2901        opcodes.extend_from_slice(&encode_island_start(0));
2902        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2903        opcodes.extend_from_slice(&encode_close_tag(0));
2904        opcodes.extend_from_slice(&encode_island_end(0));
2905
2906        let slots = SlotData::new(0);
2907        let html = walk_with_islands(
2908            &["div", "class", "form", "AuthForm"],
2909            &[],
2910            &[(0, 0x01, 0x01, 3, 0, &[])],
2911            &opcodes,
2912            &slots,
2913        );
2914        assert_eq!(
2915            html,
2916            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-->"#
2917        );
2918    }
2919
2920    // -- Test 35: walk_island_attrs_on_void_tag -------------------------------
2921
2922    #[test]
2923    fn walk_island_attrs_on_void_tag() {
2924        // ISLAND_START(0) + VOID_TAG("input") + ISLAND_END(0)
2925        // strings: 0="input", 1="Widget"
2926        let mut opcodes = Vec::new();
2927        opcodes.extend_from_slice(&encode_island_start(0));
2928        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
2929        opcodes.extend_from_slice(&encode_island_end(0));
2930
2931        let slots = SlotData::new(0);
2932        let html = walk_with_islands(
2933            &["input", "Widget"],
2934            &[],
2935            &[(0, 0x01, 0x01, 1, 0, &[])],
2936            &opcodes,
2937            &slots,
2938        );
2939        assert_eq!(
2940            html,
2941            r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
2942        );
2943    }
2944
2945    // -- Test 36: walk_inline_props_attribute ---------------------------------
2946
2947    #[test]
2948    fn walk_inline_props_attribute() {
2949        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
2950        // Island entry has slots [0, 1], PropsMode::Inline
2951        // Slot 0 = "title" (Text), Slot 1 = "count" (Number)
2952        // strings: 0="div", 1="MyComponent", 2="title", 3="count"
2953        let mut opcodes = Vec::new();
2954        opcodes.extend_from_slice(&encode_island_start(0));
2955        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2956        opcodes.extend_from_slice(&encode_close_tag(0));
2957        opcodes.extend_from_slice(&encode_island_end(0));
2958
2959        let mut slots = SlotData::new(2);
2960        slots.set(0, SlotValue::Text("Hello".to_string()));
2961        slots.set(1, SlotValue::Number(42.0));
2962
2963        let html = walk_with_islands(
2964            &["div", "MyComponent", "title", "count"],
2965            &[
2966                (0, 2, 0x01, 0x00, &[]), // slot 0, name="title", Text, Server
2967                (1, 3, 0x03, 0x00, &[]), // slot 1, name="count", Number, Server
2968            ],
2969            // island: id=0, Load, Inline, name_str_idx=1, byte_offset=0, slot_ids=[0, 1]
2970            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
2971            &opcodes,
2972            &slots,
2973        );
2974        // Props should be JSON: {"title":"Hello","count":42}
2975        assert!(
2976            html.contains("data-forma-props="),
2977            "should contain data-forma-props, got: {html}"
2978        );
2979        assert!(
2980            html.contains(r#"data-forma-island="0""#),
2981            "should contain island id, got: {html}"
2982        );
2983        assert!(
2984            html.contains(r#"data-forma-component="MyComponent""#),
2985            "should contain component name, got: {html}"
2986        );
2987        // Parse the JSON from the props attribute to verify correctness.
2988        // The attribute uses double quotes with HTML entity escaping:
2989        //   data-forma-props="{&quot;title&quot;:&quot;Hello&quot;,&quot;count&quot;:42}"
2990        // Browsers auto-decode &quot; back to " when reading getAttribute(),
2991        // so we simulate that here by reversing the entity encoding.
2992        let marker = "data-forma-props=\"";
2993        let props_start = html.find(marker).unwrap() + marker.len();
2994        let props_end = html[props_start..].find('"').unwrap() + props_start;
2995        let raw_attr = &html[props_start..props_end];
2996        let decoded = raw_attr
2997            .replace("&quot;", "\"")
2998            .replace("&amp;", "&")
2999            .replace("&lt;", "<")
3000            .replace("&gt;", ">");
3001        let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3002        assert_eq!(props_json["title"], "Hello");
3003        assert_eq!(props_json["count"], 42);
3004    }
3005
3006    // -- Test 37: walk_list_item_key_on_first_open_tag -----------------------
3007
3008    #[test]
3009    fn walk_list_item_key_on_first_open_tag() {
3010        // LIST_ITEM_KEY(key_str_idx=1) + OPEN_TAG("li") + TEXT("item") + CLOSE_TAG
3011        // strings: 0="li", 1="user-123", 2="item"
3012        let mut opcodes = Vec::new();
3013        opcodes.extend_from_slice(&encode_list_item_key(1));
3014        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3015        opcodes.extend_from_slice(&encode_text(2));
3016        opcodes.extend_from_slice(&encode_close_tag(0));
3017
3018        let html = walk_static(&["li", "user-123", "item"], &opcodes);
3019        assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
3020    }
3021
3022    // -- Test 38: walk_list_item_key_on_void_tag -----------------------------
3023
3024    #[test]
3025    fn walk_list_item_key_on_void_tag() {
3026        // LIST_ITEM_KEY(key_str_idx=1) + VOID_TAG("hr")
3027        // strings: 0="hr", 1="key-42"
3028        let mut opcodes = Vec::new();
3029        opcodes.extend_from_slice(&encode_list_item_key(1));
3030        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3031
3032        let html = walk_static(&["hr", "key-42"], &opcodes);
3033        assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
3034    }
3035
3036    // -- Test 39: walk_list_item_key_with_attrs ------------------------------
3037
3038    #[test]
3039    fn walk_list_item_key_with_attrs() {
3040        // LIST_ITEM_KEY + OPEN_TAG("li" class="item") + TEXT + CLOSE_TAG
3041        // strings: 0="li", 1="class", 2="item", 3="k1", 4="content"
3042        let mut opcodes = Vec::new();
3043        opcodes.extend_from_slice(&encode_list_item_key(3));
3044        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3045        opcodes.extend_from_slice(&encode_text(4));
3046        opcodes.extend_from_slice(&encode_close_tag(0));
3047
3048        let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
3049        assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
3050    }
3051
3052    // -- Test 40: walk_script_tag_props_mode ---------------------------------
3053
3054    #[test]
3055    fn walk_script_tag_props_mode() {
3056        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3057        // Island entry: PropsMode::ScriptTag with slots [0]
3058        // strings: 0="div", 1="Counter", 2="label"
3059        let mut opcodes = Vec::new();
3060        opcodes.extend_from_slice(&encode_island_start(0));
3061        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3062        opcodes.extend_from_slice(&encode_close_tag(0));
3063        opcodes.extend_from_slice(&encode_island_end(0));
3064
3065        let mut slots = SlotData::new(1);
3066        slots.set(0, SlotValue::Text("Click me".to_string()));
3067
3068        let html = walk_with_islands(
3069            &["div", "Counter", "label"],
3070            &[(0, 2, 0x01, 0x00, &[])], // slot 0, name="label", Text, Server
3071            // island: id=0, Load, ScriptTag(0x02), name_str_idx=1, byte_offset=0, slot_ids=[0]
3072            &[(0, 0x01, 0x02, 1, 0, &[0])],
3073            &opcodes,
3074            &slots,
3075        );
3076        // Should NOT have inline data-forma-props
3077        assert!(
3078            !html.contains("data-forma-props"),
3079            "ScriptTag mode should not emit inline props, got: {html}"
3080        );
3081        // Should have island attrs
3082        assert!(
3083            html.contains(r#"data-forma-island="0""#),
3084            "should contain island id"
3085        );
3086        // Should have script tag at the end
3087        assert!(
3088            html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3089            "should contain script tag, got: {html}"
3090        );
3091        assert!(html.contains("</script>"), "should close script tag");
3092        // Parse the JSON from the script tag
3093        let script_start =
3094            html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3095        let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3096        let json: serde_json::Value =
3097            serde_json::from_str(&html[script_start..script_end]).unwrap();
3098        assert_eq!(json["0"]["label"], "Click me");
3099    }
3100
3101    // -- Test 41: walk_deferred_props_mode -----------------------------------
3102
3103    #[test]
3104    fn walk_deferred_props_mode() {
3105        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3106        // Island entry: PropsMode::Deferred — should not emit props at all
3107        // strings: 0="div", 1="LazyWidget", 2="data"
3108        let mut opcodes = Vec::new();
3109        opcodes.extend_from_slice(&encode_island_start(0));
3110        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3111        opcodes.extend_from_slice(&encode_close_tag(0));
3112        opcodes.extend_from_slice(&encode_island_end(0));
3113
3114        let mut slots = SlotData::new(1);
3115        slots.set(0, SlotValue::Text("ignored".to_string()));
3116
3117        let html = walk_with_islands(
3118            &["div", "LazyWidget", "data"],
3119            &[(0, 2, 0x01, 0x00, &[])],
3120            // island: id=0, Load, Deferred(0x03), name_str_idx=1, byte_offset=0, slot_ids=[0]
3121            &[(0, 0x01, 0x03, 1, 0, &[0])],
3122            &opcodes,
3123            &slots,
3124        );
3125        assert!(
3126            !html.contains("data-forma-props"),
3127            "Deferred mode should not emit inline props, got: {html}"
3128        );
3129        assert!(
3130            !html.contains("__forma_islands"),
3131            "Deferred mode should not emit script tag, got: {html}"
3132        );
3133        // Should still have island attrs
3134        assert!(
3135            html.contains(r#"data-forma-island="0""#),
3136            "should contain island id"
3137        );
3138        assert!(
3139            html.contains(r#"data-forma-component="LazyWidget""#),
3140            "should contain component name"
3141        );
3142    }
3143
3144    // -- Test 42: walk_island_no_entry_in_table -------------------------------
3145
3146    #[test]
3147    fn walk_island_no_entry_in_table() {
3148        // An ISLAND_START whose id is absent from the island table used to
3149        // render happily: markers, but no `data-forma-*` attributes, so the
3150        // client could never hydrate that island and nothing said why. It is
3151        // now a load-time error instead of an invisible one at runtime.
3152        let mut opcodes = Vec::new();
3153        opcodes.extend_from_slice(&encode_island_start(5));
3154        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3155        opcodes.extend_from_slice(&encode_text(1));
3156        opcodes.extend_from_slice(&encode_close_tag(0));
3157        opcodes.extend_from_slice(&encode_island_end(5));
3158
3159        let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3160        assert_eq!(
3161            IrModule::parse(&data).unwrap_err(),
3162            IrError::UnknownIslandId(5)
3163        );
3164    }
3165
3166    #[test]
3167    fn walk_nested_island_start_before_a_root_element_is_an_error() {
3168        // Only one element can carry a data-forma-island set, so an island
3169        // that opens another island before its own root element would have
3170        // silently lost its identity — never hydrated, never disposed.
3171        let mut opcodes = Vec::new();
3172        opcodes.extend_from_slice(&encode_island_start(0));
3173        opcodes.extend_from_slice(&encode_island_start(1));
3174        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3175        opcodes.extend_from_slice(&encode_close_tag(0));
3176        opcodes.extend_from_slice(&encode_island_end(1));
3177        opcodes.extend_from_slice(&encode_island_end(0));
3178
3179        let slots = SlotData::new(0);
3180        let data = build_minimal_ir(
3181            &["div", "Outer", "Inner"],
3182            &[],
3183            &opcodes,
3184            &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3185        );
3186        let module = IrModule::parse(&data).unwrap();
3187        assert_eq!(
3188            walk_to_html(&module, &slots).unwrap_err(),
3189            IrError::NestedIslandRoot { outer: 0, inner: 1 }
3190        );
3191    }
3192
3193    #[test]
3194    fn walk_island_nested_inside_another_islands_root_is_fine() {
3195        // The legal shape: each island opens its own element first.
3196        let mut opcodes = Vec::new();
3197        opcodes.extend_from_slice(&encode_island_start(0));
3198        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3199        opcodes.extend_from_slice(&encode_island_start(1));
3200        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3201        opcodes.extend_from_slice(&encode_close_tag(0));
3202        opcodes.extend_from_slice(&encode_island_end(1));
3203        opcodes.extend_from_slice(&encode_close_tag(0));
3204        opcodes.extend_from_slice(&encode_island_end(0));
3205
3206        let slots = SlotData::new(0);
3207        let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3208        let html = walk_with_islands(
3209            &["div", "Outer", "Inner"],
3210            &[],
3211            &[
3212                (0, 0x01, 0x01, 1, 0, &[]),
3213                (1, 0x01, 0x01, 2, inner_offset, &[]),
3214            ],
3215            &opcodes,
3216            &slots,
3217        );
3218        assert!(html.contains(r#"data-forma-component="Outer""#));
3219        assert!(html.contains(r#"data-forma-component="Inner""#));
3220    }
3221
3222    // -- Test 43: walk_inline_props_empty_slots ------------------------------
3223
3224    #[test]
3225    fn walk_inline_props_empty_slots() {
3226        // Island with Inline props mode but no slot_ids — should not emit data-forma-props
3227        // strings: 0="div", 1="Simple"
3228        let mut opcodes = Vec::new();
3229        opcodes.extend_from_slice(&encode_island_start(0));
3230        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3231        opcodes.extend_from_slice(&encode_close_tag(0));
3232        opcodes.extend_from_slice(&encode_island_end(0));
3233
3234        let slots = SlotData::new(0);
3235        let html = walk_with_islands(
3236            &["div", "Simple"],
3237            &[],
3238            &[(0, 0x01, 0x01, 1, 0, &[])], // no slot_ids
3239            &opcodes,
3240            &slots,
3241        );
3242        assert!(
3243            !html.contains("data-forma-props"),
3244            "empty slot_ids should not emit props, got: {html}"
3245        );
3246        assert!(
3247            html.contains(r#"data-forma-island="0""#),
3248            "should contain island id"
3249        );
3250    }
3251
3252    // -- Inline props size ceiling (F8) --------------------------------------
3253
3254    /// Build a one-island page whose single prop slot holds `payload`.
3255    fn walk_island_with_prop_payload(payload: &str) -> String {
3256        let mut opcodes = Vec::new();
3257        opcodes.extend_from_slice(&encode_island_start(0));
3258        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3259        opcodes.extend_from_slice(&encode_close_tag(0));
3260        opcodes.extend_from_slice(&encode_island_end(0));
3261
3262        let mut slots = SlotData::new(1);
3263        slots.set(0, SlotValue::Text(payload.to_string()));
3264        walk_with_islands(
3265            &["div", "Big", "blob"],
3266            &[(0, 2, 0x01, 0x00, &[])],
3267            &[(0, 0x01, 0x01, 1, 0, &[0])],
3268            &opcodes,
3269            &slots,
3270        )
3271    }
3272
3273    #[test]
3274    fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3275        // Slots 0 and 1 are both named "dup". The three name-resolving paths
3276        // in this crate used to pick differently (first / last / last by
3277        // ascending id); they all keep the first now.
3278        let mut opcodes = Vec::new();
3279        opcodes.extend_from_slice(&encode_island_start(0));
3280        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3281        opcodes.extend_from_slice(&encode_close_tag(0));
3282        opcodes.extend_from_slice(&encode_island_end(0));
3283
3284        let mut slots = SlotData::new(2);
3285        slots.set(0, SlotValue::Text("first".into()));
3286        slots.set(1, SlotValue::Text("second".into()));
3287
3288        let html = walk_with_islands(
3289            &["div", "Comp", "dup"],
3290            &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3291            &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3292            &opcodes,
3293            &slots,
3294        );
3295        assert!(
3296            html.contains(r#"data-forma-props="{&quot;dup&quot;:&quot;first&quot;}""#),
3297            "expected the first slot to win, got: {html}"
3298        );
3299    }
3300
3301    #[test]
3302    fn inline_props_under_the_ceiling_stay_inline() {
3303        let html = walk_island_with_prop_payload("small");
3304        assert!(html.contains(r#"data-forma-props="{&quot;blob&quot;:&quot;small&quot;}""#));
3305        assert!(!html.contains("__forma_islands"));
3306    }
3307
3308    #[test]
3309    fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3310        // An unbounded `data-forma-props` is a multi-kilobyte attribute
3311        // repeated on every island root. Above the ceiling the payload moves
3312        // to the shared block — and to EXACTLY one of the two channels, since
3313        // the client reads the inline attribute first and would never look at
3314        // the block if both were present.
3315        let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3316        let html = walk_island_with_prop_payload(&payload);
3317        assert!(
3318            !html.contains("data-forma-props"),
3319            "over-size props must not stay in the attribute"
3320        );
3321        assert!(
3322            html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3323            "over-size props must land in the shared block, got: {}",
3324            &html[..html.len().min(200)]
3325        );
3326        assert!(html.contains(&payload), "the payload itself must survive");
3327    }
3328
3329    #[test]
3330    fn island_fragment_keeps_over_size_props_inline() {
3331        // A fragment cannot emit a second `__forma_islands` block: it would
3332        // duplicate a DOM id and, being earlier in document order after the
3333        // swap, shadow the page's own block for every other island.
3334        let mut opcodes = Vec::new();
3335        opcodes.extend_from_slice(&encode_island_start(0));
3336        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3337        opcodes.extend_from_slice(&encode_close_tag(0));
3338        opcodes.extend_from_slice(&encode_island_end(0));
3339
3340        let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3341        let mut slots = SlotData::new(1);
3342        slots.set(0, SlotValue::Text(payload));
3343
3344        let data = build_minimal_ir(
3345            &["div", "Big", "blob"],
3346            &[(0, 2, 0x01, 0x00, &[])],
3347            &opcodes,
3348            &[(0, 0x01, 0x01, 1, 0, &[0])],
3349        );
3350        let module = IrModule::parse(&data).unwrap();
3351        let fragment = walk_island(&module, &slots, 0).unwrap();
3352        assert!(fragment.contains("data-forma-props"));
3353        assert!(!fragment.contains("__forma_islands"));
3354    }
3355
3356    // -- Test 44: walk_island_and_list_key_combined --------------------------
3357
3358    #[test]
3359    fn walk_island_and_list_key_combined() {
3360        // ISLAND_START(0) + LIST_ITEM_KEY + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
3361        // Both pending_island and pending_list_key active on same OPEN_TAG
3362        // strings: 0="div", 1="Card", 2="item-1"
3363        let mut opcodes = Vec::new();
3364        opcodes.extend_from_slice(&encode_island_start(0));
3365        opcodes.extend_from_slice(&encode_list_item_key(2));
3366        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3367        opcodes.extend_from_slice(&encode_close_tag(0));
3368        opcodes.extend_from_slice(&encode_island_end(0));
3369
3370        let slots = SlotData::new(0);
3371        let html = walk_with_islands(
3372            &["div", "Card", "item-1"],
3373            &[],
3374            &[(0, 0x01, 0x01, 1, 0, &[])],
3375            &opcodes,
3376            &slots,
3377        );
3378        assert!(
3379            html.contains(r#"data-forma-island="0""#),
3380            "should contain island attrs"
3381        );
3382        assert!(
3383            html.contains(r#"data-forma-key="item-1""#),
3384            "should contain list key attr, got: {html}"
3385        );
3386    }
3387
3388    // -- Test 45: walk_multiple_islands_script_tag ---------------------------
3389
3390    #[test]
3391    fn walk_multiple_islands_script_tag() {
3392        // Two islands, both ScriptTag mode, should produce a single script tag
3393        // with both entries
3394        // strings: 0="div", 1="span", 2="CompA", 3="CompB", 4="msg", 5="flag"
3395        let mut opcodes = Vec::new();
3396        // Island 0
3397        opcodes.extend_from_slice(&encode_island_start(0));
3398        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3399        opcodes.extend_from_slice(&encode_close_tag(0));
3400        opcodes.extend_from_slice(&encode_island_end(0));
3401        // Island 1 — its byte_offset must point at its own ISLAND_START.
3402        let island1_offset = opcodes.len() as u32;
3403        opcodes.extend_from_slice(&encode_island_start(1));
3404        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3405        opcodes.extend_from_slice(&encode_close_tag(1));
3406        opcodes.extend_from_slice(&encode_island_end(1));
3407
3408        let mut slots = SlotData::new(2);
3409        slots.set(0, SlotValue::Text("hello".to_string()));
3410        slots.set(1, SlotValue::Bool(true));
3411
3412        let html = walk_with_islands(
3413            &["div", "span", "CompA", "CompB", "msg", "flag"],
3414            &[
3415                (0, 4, 0x01, 0x00, &[]), // slot 0, name="msg", Text
3416                (1, 5, 0x02, 0x00, &[]), // slot 1, name="flag", Bool
3417            ],
3418            &[
3419                (0, 0x01, 0x02, 2, 0, &[0]), // island 0, ScriptTag, name="CompA", byte_offset=0, slots=[0]
3420                (1, 0x01, 0x02, 3, island1_offset, &[1]), // island 1, ScriptTag, name="CompB", slots=[1]
3421            ],
3422            &opcodes,
3423            &slots,
3424        );
3425        // Parse the script tag JSON
3426        let script_start =
3427            html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3428        let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3429        let json: serde_json::Value =
3430            serde_json::from_str(&html[script_start..script_end]).unwrap();
3431        assert_eq!(json["0"]["msg"], "hello");
3432        assert_eq!(json["1"]["flag"], true);
3433    }
3434
3435    // -- Test 46: walk_list_item_key_consumed_once --------------------------
3436
3437    #[test]
3438    fn walk_list_item_key_consumed_once() {
3439        // LIST_ITEM_KEY + OPEN_TAG("li") + OPEN_TAG("span") + TEXT + CLOSE_TAG("span") + CLOSE_TAG("li")
3440        // Key should only appear on the first tag (li), not span
3441        // strings: 0="li", 1="span", 2="abc", 3="text"
3442        let mut opcodes = Vec::new();
3443        opcodes.extend_from_slice(&encode_list_item_key(2));
3444        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3445        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3446        opcodes.extend_from_slice(&encode_text(3));
3447        opcodes.extend_from_slice(&encode_close_tag(1));
3448        opcodes.extend_from_slice(&encode_close_tag(0));
3449
3450        let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
3451        assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
3452    }
3453
3454    // -- Test 47: walk_slot_value_to_json -----------------------------------
3455
3456    #[test]
3457    fn slot_value_to_json_all_types() {
3458        // Verify SlotValue::to_json() for all variants
3459        assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
3460        assert_eq!(
3461            SlotValue::Text("hello".to_string()).to_json(),
3462            serde_json::Value::String("hello".to_string())
3463        );
3464        assert_eq!(
3465            SlotValue::Bool(true).to_json(),
3466            serde_json::Value::Bool(true)
3467        );
3468        assert_eq!(
3469            SlotValue::Bool(false).to_json(),
3470            serde_json::Value::Bool(false)
3471        );
3472        assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
3473        assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
3474        assert_eq!(
3475            SlotValue::array(vec![
3476                SlotValue::Text("a".to_string()),
3477                SlotValue::Number(1.0)
3478            ])
3479            .to_json(),
3480            serde_json::json!(["a", 1])
3481        );
3482        assert_eq!(
3483            SlotValue::object(vec![(
3484                "key".to_string(),
3485                SlotValue::Text("val".to_string())
3486            ),])
3487            .to_json(),
3488            serde_json::json!({"key": "val"})
3489        );
3490    }
3491
3492    // -- Test 48: dyn_attr_emits_attribute_with_value ----------------------
3493
3494    #[test]
3495    fn dyn_attr_emits_attribute_with_value() {
3496        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + CLOSE_TAG("input")
3497        // slot 0 = Text("password") → <input type="password"></input>
3498        // strings: 0="input", 1="type", 2="input_type" (slot name)
3499        let mut opcodes = Vec::new();
3500        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3501        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3502        opcodes.extend_from_slice(&encode_close_tag(0));
3503
3504        let mut slots = SlotData::new(1);
3505        slots.set(0, SlotValue::Text("password".to_string()));
3506
3507        let html = walk_with_slots(
3508            &["input", "type", "input_type"],
3509            &[(0, 2, 0x01, 0x01, b"password")], // slot_id=0, name_str_idx=2, Text, Client, default="password"
3510            &opcodes,
3511            &slots,
3512        );
3513        assert_eq!(html, r#"<input type="password"></input>"#);
3514    }
3515
3516    // -- Test 49: dyn_attr_skips_when_slot_null ----------------------------
3517
3518    #[test]
3519    fn dyn_attr_skips_when_slot_null() {
3520        // DYN_ATTR with Null slot value should emit no attribute at all.
3521        // <div> (no class attr) </div>
3522        // strings: 0="div", 1="class", 2="cls" (slot name)
3523        let mut opcodes = Vec::new();
3524        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3525        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3526        opcodes.extend_from_slice(&encode_close_tag(0));
3527
3528        let slots = SlotData::new(1); // slot 0 is Null by default
3529
3530        let html = walk_with_slots(
3531            &["div", "class", "cls"],
3532            &[(0, 2, 0x01, 0x00, &[])],
3533            &opcodes,
3534            &slots,
3535        );
3536        assert_eq!(html, "<div></div>");
3537    }
3538
3539    // -- Test 50: dyn_attr_multiple_on_same_element ------------------------
3540
3541    #[test]
3542    fn dyn_attr_multiple_on_same_element() {
3543        // OPEN_TAG("input") + DYN_ATTR("type", slot=0) + DYN_ATTR("class", slot=1) + CLOSE_TAG
3544        // strings: 0="input", 1="type", 2="class", 3="slot_type", 4="slot_class"
3545        let mut opcodes = Vec::new();
3546        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3547        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3548        opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
3549        opcodes.extend_from_slice(&encode_close_tag(0));
3550
3551        let mut slots = SlotData::new(2);
3552        slots.set(0, SlotValue::Text("email".to_string()));
3553        slots.set(1, SlotValue::Text("form-input".to_string()));
3554
3555        let html = walk_with_slots(
3556            &["input", "type", "class", "slot_type", "slot_class"],
3557            &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
3558            &opcodes,
3559            &slots,
3560        );
3561        assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
3562    }
3563
3564    // -- Test 51: dyn_attr_on_void_tag -------------------------------------
3565
3566    #[test]
3567    fn dyn_attr_on_void_tag() {
3568        // VOID_TAG("input") + DYN_ATTR("type", slot=0)
3569        // slot 0 = Text("checkbox") → <input type="checkbox">
3570        // strings: 0="input", 1="type", 2="slot_type"
3571        let mut opcodes = Vec::new();
3572        opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3573        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3574
3575        let mut slots = SlotData::new(1);
3576        slots.set(0, SlotValue::Text("checkbox".to_string()));
3577
3578        let html = walk_with_slots(
3579            &["input", "type", "slot_type"],
3580            &[(0, 2, 0x01, 0x01, b"text")],
3581            &opcodes,
3582            &slots,
3583        );
3584        assert_eq!(html, r#"<input type="checkbox">"#);
3585    }
3586
3587    // -- walk_island tests -------------------------------------------------
3588
3589    #[test]
3590    fn walk_island_basic() {
3591        // Build IR with an island containing <p>hello</p>
3592        // strings: 0="MyIsland", 1="div", 2="p", 3="hello"
3593        // We need a wrapper div before the island, the island content, and wrapper close.
3594        //
3595        // Opcodes layout:
3596        // OPEN_TAG "div" []       -> offset 0
3597        // ISLAND_START id=0       -> offset X (byte_offset for island table)
3598        // OPEN_TAG "p" []
3599        // TEXT "hello"
3600        // CLOSE_TAG "p"
3601        // ISLAND_END id=0
3602        // CLOSE_TAG "div"
3603
3604        let pre_island = encode_open_tag(1, &[]); // OPEN_TAG "div" -> str_idx=1
3605        let island_start = encode_island_start(0);
3606        let open_p = encode_open_tag(2, &[]); // OPEN_TAG "p" -> str_idx=2
3607        let text_hello = encode_text(3);
3608        let close_p = encode_close_tag(2);
3609        let island_end = encode_island_end(0);
3610        let close_div = encode_close_tag(1);
3611
3612        let byte_offset = pre_island.len() as u32; // island starts after the div open
3613
3614        let mut opcodes = Vec::new();
3615        opcodes.extend_from_slice(&pre_island);
3616        opcodes.extend_from_slice(&island_start);
3617        opcodes.extend_from_slice(&open_p);
3618        opcodes.extend_from_slice(&text_hello);
3619        opcodes.extend_from_slice(&close_p);
3620        opcodes.extend_from_slice(&island_end);
3621        opcodes.extend_from_slice(&close_div);
3622
3623        let data = build_minimal_ir(
3624            &["MyIsland", "div", "p", "hello"],
3625            &[],
3626            &opcodes,
3627            &[(0, 0x01, 0x01, 0, byte_offset, &[])], // island 0, Load, Inline, name="MyIsland"
3628        );
3629        let module = IrModule::parse(&data).unwrap();
3630        let slots = SlotData::new(0);
3631
3632        let html = walk_island(&module, &slots, 0).unwrap();
3633        // The fragment is the island ROOT carrying the full data-forma-* set —
3634        // seeking past ISLAND_START used to drop all of it, so a fragment
3635        // swapped in via outerHTML never hydrated.
3636        assert_eq!(
3637            html,
3638            "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
3639             data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
3640        );
3641        // The f:iN markers live outside the root and survive an outerHTML
3642        // swap, so the fragment must not re-emit them.
3643        assert!(!html.contains("<!--f:i0-->"));
3644        assert!(!html.contains("<!--/f:i0-->"));
3645    }
3646
3647    #[test]
3648    fn walk_island_attrs_match_walk_to_html() {
3649        // Parity: the fragment's root must carry the same data-forma-*
3650        // attribute set that the full-page walk produces for the same island.
3651        let island_start = encode_island_start(0);
3652        let open_div = encode_open_tag(1, &[]);
3653        let dyn_text = encode_dyn_text(0, 0);
3654        let close_div = encode_close_tag(1);
3655        let island_end = encode_island_end(0);
3656
3657        let mut opcodes = Vec::new();
3658        opcodes.extend_from_slice(&island_start);
3659        opcodes.extend_from_slice(&open_div);
3660        opcodes.extend_from_slice(&dyn_text);
3661        opcodes.extend_from_slice(&close_div);
3662        opcodes.extend_from_slice(&island_end);
3663
3664        let data = build_minimal_ir(
3665            &["IslandComp", "div", "greeting"],
3666            &[(0, 2, 0x01, 0x00, &[])],
3667            &opcodes,
3668            &[(0, 0x02, 0x01, 0, 0, &[0])], // trigger=Visible, Inline props
3669        );
3670        let module = IrModule::parse(&data).unwrap();
3671        let mut slots = SlotData::new(1);
3672        slots.set(0, SlotValue::Text("World".to_string()));
3673
3674        let page = walk_to_html(&module, &slots).unwrap();
3675        let fragment = walk_island(&module, &slots, 0).unwrap();
3676
3677        for attr in [
3678            "data-forma-island=\"0\"",
3679            "data-forma-component=\"IslandComp\"",
3680            "data-forma-status=\"pending\"",
3681            "data-forma-hydrate=\"visible\"",
3682            "data-forma-props=",
3683        ] {
3684            assert!(page.contains(attr), "page missing {attr}: {page}");
3685            assert!(
3686                fragment.contains(attr),
3687                "fragment missing {attr}: {fragment}"
3688            );
3689        }
3690    }
3691
3692    #[test]
3693    fn walk_island_script_tag_mode_inlines_props_in_fragment() {
3694        // A ScriptTag island rendered as a fragment must inline its props
3695        // rather than emit a second __forma_islands block: that would
3696        // duplicate a DOM id, and because the injected copy precedes the SSR
3697        // one in document order it would shadow it — every other ScriptTag
3698        // island would then read a truncated shared-props map.
3699        let mut opcodes = Vec::new();
3700        opcodes.extend_from_slice(&encode_island_start(0));
3701        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3702        opcodes.extend_from_slice(&encode_close_tag(1));
3703        opcodes.extend_from_slice(&encode_island_end(0));
3704
3705        let data = build_minimal_ir(
3706            &["IslandComp", "div", "greeting"],
3707            &[(0, 2, 0x01, 0x00, &[])],
3708            &opcodes,
3709            &[(0, 0x01, 0x02, 0, 0, &[0])], // props_mode = ScriptTag
3710        );
3711        let module = IrModule::parse(&data).unwrap();
3712        let mut slots = SlotData::new(1);
3713        slots.set(0, SlotValue::Text("World".to_string()));
3714
3715        let fragment = walk_island(&module, &slots, 0).unwrap();
3716        assert!(
3717            fragment.contains("data-forma-props="),
3718            "ScriptTag island fragment must inline its props: {fragment}"
3719        );
3720        assert!(
3721            !fragment.contains("__forma_islands"),
3722            "fragment must not emit a second islands block: {fragment}"
3723        );
3724    }
3725
3726    #[test]
3727    fn walk_island_nested_script_tag_island_keeps_its_props() {
3728        // A nested ScriptTag island would otherwise push into the shared
3729        // script_tag_props map, which a fragment render drops on return — so
3730        // the nested island would mount with null props.
3731        let mut opcodes = encode_island_start(0);
3732        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3733        let inner_offset = opcodes.len() as u32;
3734        opcodes.extend_from_slice(&encode_island_start(1));
3735        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3736        opcodes.extend_from_slice(&encode_close_tag(1));
3737        opcodes.extend_from_slice(&encode_island_end(1));
3738        opcodes.extend_from_slice(&encode_close_tag(1));
3739        opcodes.extend_from_slice(&encode_island_end(0));
3740
3741        let data = build_minimal_ir(
3742            &["Outer", "div", "greeting", "Inner"],
3743            &[(0, 2, 0x01, 0x00, &[])],
3744            &opcodes,
3745            &[
3746                (0, 0x01, 0x01, 0, 0, &[]),
3747                (1, 0x01, 0x02, 3, inner_offset, &[0]), // nested, ScriptTag
3748            ],
3749        );
3750        let module = IrModule::parse(&data).unwrap();
3751        let mut slots = SlotData::new(1);
3752        slots.set(0, SlotValue::Text("World".into()));
3753
3754        let fragment = walk_island(&module, &slots, 0).unwrap();
3755        assert!(
3756            fragment.contains("data-forma-component=\"Inner\""),
3757            "nested island must render: {fragment}"
3758        );
3759        assert!(
3760            fragment.contains("data-forma-props="),
3761            "nested ScriptTag island must keep its props inline: {fragment}"
3762        );
3763        assert!(!fragment.contains("__forma_islands"), "{fragment}");
3764    }
3765
3766    #[test]
3767    fn walk_island_not_found() {
3768        // Build IR with no islands, then request island_id=5
3769        let opcodes = encode_text(0);
3770        let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
3771        let module = IrModule::parse(&data).unwrap();
3772        let slots = SlotData::new(0);
3773
3774        let result = walk_island(&module, &slots, 5);
3775        assert!(result.is_err());
3776        match result.unwrap_err() {
3777            IrError::IslandNotFound(5) => {} // expected
3778            other => panic!("expected IslandNotFound(5), got {other:?}"),
3779        }
3780    }
3781
3782    #[test]
3783    fn walk_island_with_dyn_text() {
3784        // Island containing DYN_TEXT that references a slot
3785        // strings: 0="IslandComp", 1="div", 2="greeting"
3786        // slot 0: name_str_idx=2 ("greeting"), type=Text
3787
3788        let island_start = encode_island_start(0);
3789        let open_div = encode_open_tag(1, &[]); // str_idx=1 -> "div"
3790        let dyn_text = encode_dyn_text(0, 0); // slot_id=0, marker_id=0
3791        let close_div = encode_close_tag(1);
3792        let island_end = encode_island_end(0);
3793
3794        let byte_offset = 0u32; // island starts at beginning of opcodes
3795
3796        let mut opcodes = Vec::new();
3797        opcodes.extend_from_slice(&island_start);
3798        opcodes.extend_from_slice(&open_div);
3799        opcodes.extend_from_slice(&dyn_text);
3800        opcodes.extend_from_slice(&close_div);
3801        opcodes.extend_from_slice(&island_end);
3802
3803        let data = build_minimal_ir(
3804            &["IslandComp", "div", "greeting"],
3805            &[(0, 2, 0x01, 0x00, &[])], // slot_id=0, name="greeting", Text, Server
3806            &opcodes,
3807            &[(0, 0x01, 0x01, 0, byte_offset, &[0])], // island 0, slot_ids=[0]
3808        );
3809        let module = IrModule::parse(&data).unwrap();
3810
3811        let mut slots = SlotData::new(1);
3812        slots.set(0, SlotValue::Text("World".to_string()));
3813
3814        let html = walk_island(&module, &slots, 0).unwrap();
3815        // Root element keeps its island identity AND its props.
3816        assert_eq!(
3817            html,
3818            "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
3819             data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
3820             data-forma-props=\"{&quot;greeting&quot;:&quot;World&quot;}\">\
3821             <!--f:t0-->World<!--/f:t0--></div>"
3822        );
3823    }
3824
3825    #[test]
3826    fn walk_island_byte_offset_out_of_bounds() {
3827        // Island table entry with byte_offset pointing past the opcode stream.
3828        // This is now rejected at parse time: a byte_offset must point at that
3829        // island's own ISLAND_START opcode, which is both a bounds check and a
3830        // structural one (an in-range offset landing mid-operand on a stray
3831        // 0x0B byte would pass a bare bounds check).
3832        let opcodes = encode_text(0); // just a small opcode stream
3833
3834        let data = build_minimal_ir(
3835            &["BadIsland", "hello"],
3836            &[],
3837            &opcodes,
3838            &[(0, 0x01, 0x01, 0, 9999, &[])], // byte_offset=9999, way past end
3839        );
3840        let err = IrModule::parse(&data).unwrap_err();
3841        assert_eq!(
3842            err,
3843            IrError::IslandOffsetMismatch {
3844                island_id: 0,
3845                byte_offset: 9999
3846            }
3847        );
3848    }
3849
3850    // -- PROP opcode tests -------------------------------------------------
3851
3852    /// Encode a PROP opcode: opcode(1) + src_slot_id(2) + prop_str_idx(4) + target_slot_id(2)
3853    fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
3854        let mut buf = vec![0x12]; // Opcode::Prop
3855        buf.extend_from_slice(&src_slot_id.to_le_bytes());
3856        buf.extend_from_slice(&prop_str_idx.to_le_bytes());
3857        buf.extend_from_slice(&target_slot_id.to_le_bytes());
3858        buf
3859    }
3860
3861    // -- Test: PROP extracts object property inside LIST body ----------------
3862
3863    #[test]
3864    fn walk_prop_in_list_body() {
3865        // LIST(slot=0, item_slot=1):
3866        //   PROP(src=1, "name", target=2)
3867        //   DYN_TEXT(slot=2, marker=0)
3868        //
3869        // Slot 0 = Array([Object{name:"Alice"}, Object{name:"Bob"}])
3870        // Strings: 0="items", 1="item", 2="name"
3871        let mut body = Vec::new();
3872        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(item_slot=1, prop="name", target=2)
3873        body.extend_from_slice(&encode_dyn_text(2, 0)); // DYN_TEXT(slot=2)
3874        let opcodes = encode_list(0, 1, &body);
3875
3876        let mut slots = SlotData::new(3);
3877        slots.set(
3878            0,
3879            SlotValue::array(vec![
3880                SlotValue::object(vec![(
3881                    "name".to_string(),
3882                    SlotValue::Text("Alice".to_string()),
3883                )]),
3884                SlotValue::object(vec![(
3885                    "name".to_string(),
3886                    SlotValue::Text("Bob".to_string()),
3887                )]),
3888            ]),
3889        );
3890
3891        let html = walk_with_slots(
3892            &["items", "item", "name"],
3893            &[
3894                (0, 0, 0x04, 0x00, &[]), // slot 0: Array, Server
3895                (1, 1, 0x05, 0x00, &[]), // slot 1: Object, Server
3896                (2, 2, 0x01, 0x00, &[]), // slot 2: Text, Server
3897            ],
3898            &opcodes,
3899            &slots,
3900        );
3901        assert!(html.contains("Alice"), "should contain Alice, got: {html}");
3902        assert!(html.contains("Bob"), "should contain Bob, got: {html}");
3903    }
3904
3905    // -- Test: PROP with multiple properties ---------------------------------
3906
3907    #[test]
3908    fn walk_prop_multiple_properties() {
3909        // LIST(slot=0, item_slot=1):
3910        //   PROP(src=1, "name", target=2)
3911        //   PROP(src=1, "email", target=3)
3912        //   <tr><td>DYN_TEXT(2)</td><td>DYN_TEXT(3)</td></tr>
3913        //
3914        // Strings: 0="rows", 1="row", 2="name", 3="email", 4="tr", 5="td"
3915        let mut body = Vec::new();
3916        body.extend_from_slice(&encode_prop(1, 2, 2)); // PROP(1, "name", 2)
3917        body.extend_from_slice(&encode_prop(1, 3, 3)); // PROP(1, "email", 3)
3918        body.extend_from_slice(&encode_open_tag(4, &[])); // <tr>
3919        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
3920        body.extend_from_slice(&encode_dyn_text(2, 0)); // name
3921        body.extend_from_slice(&encode_close_tag(5)); // </td>
3922        body.extend_from_slice(&encode_open_tag(5, &[])); // <td>
3923        body.extend_from_slice(&encode_dyn_text(3, 1)); // email
3924        body.extend_from_slice(&encode_close_tag(5)); // </td>
3925        body.extend_from_slice(&encode_close_tag(4)); // </tr>
3926        let opcodes = encode_list(0, 1, &body);
3927
3928        let mut slots = SlotData::new(4);
3929        slots.set(
3930            0,
3931            SlotValue::array(vec![
3932                SlotValue::object(vec![
3933                    ("name".to_string(), SlotValue::Text("Alice".to_string())),
3934                    (
3935                        "email".to_string(),
3936                        SlotValue::Text("alice@test.com".to_string()),
3937                    ),
3938                ]),
3939                SlotValue::object(vec![
3940                    ("name".to_string(), SlotValue::Text("Bob".to_string())),
3941                    (
3942                        "email".to_string(),
3943                        SlotValue::Text("bob@test.com".to_string()),
3944                    ),
3945                ]),
3946            ]),
3947        );
3948
3949        let html = walk_with_slots(
3950            &["rows", "row", "name", "email", "tr", "td"],
3951            &[
3952                (0, 0, 0x04, 0x00, &[]),
3953                (1, 1, 0x05, 0x00, &[]),
3954                (2, 2, 0x01, 0x00, &[]),
3955                (3, 3, 0x01, 0x00, &[]),
3956            ],
3957            &opcodes,
3958            &slots,
3959        );
3960        assert!(
3961            html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
3962            "should contain Alice td, got: {html}"
3963        );
3964        assert!(
3965            html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
3966            "should contain alice email, got: {html}"
3967        );
3968        assert!(
3969            html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
3970            "should contain Bob td, got: {html}"
3971        );
3972        assert!(
3973            html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
3974            "should contain bob email, got: {html}"
3975        );
3976    }
3977
3978    // -- Test: PROP on non-Object returns Null (empty string for DYN_TEXT) ----
3979
3980    #[test]
3981    fn walk_prop_on_non_object() {
3982        // PROP(src=0, "name", target=1) then DYN_TEXT(1)
3983        // Slot 0 = Text("hello") — not an object
3984        let mut opcodes = Vec::new();
3985        opcodes.extend_from_slice(&encode_prop(0, 1, 1));
3986        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
3987
3988        let mut slots = SlotData::new(2);
3989        slots.set(0, SlotValue::Text("hello".to_string()));
3990
3991        let html = walk_with_slots(
3992            &["src", "name"],
3993            &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3994            &opcodes,
3995            &slots,
3996        );
3997        // PROP on non-Object produces Null → DYN_TEXT renders zero-width space (U+200B)
3998        assert!(
3999            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4000            "non-object PROP should produce empty text, got: {html}"
4001        );
4002    }
4003
4004    // -- Test: PROP with missing property returns Null -----------------------
4005
4006    #[test]
4007    fn walk_prop_missing_property() {
4008        // PROP(src=0, "missing", target=1) then DYN_TEXT(1)
4009        // Slot 0 = Object with only "name" key
4010        let mut opcodes = Vec::new();
4011        opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4012        opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4013
4014        let mut slots = SlotData::new(2);
4015        slots.set(
4016            0,
4017            SlotValue::object(vec![(
4018                "name".to_string(),
4019                SlotValue::Text("Alice".to_string()),
4020            )]),
4021        );
4022
4023        let html = walk_with_slots(
4024            &["src", "target", "missing"],
4025            &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4026            &opcodes,
4027            &slots,
4028        );
4029        assert!(
4030            html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4031            "missing property should produce empty text, got: {html}"
4032        );
4033    }
4034
4035    // -- Recursion depth limit tests ----------------------------------------
4036
4037    #[test]
4038    fn walk_nested_show_if_within_limit() {
4039        // 5 levels of nested SHOW_IF — well within the 64-level limit.
4040        // Each level wraps the next; innermost renders TEXT "deep".
4041        // strings: 0..=4 = "s0".."s4", 5 = "deep"
4042        let inner = encode_text(5);
4043        let level4 = encode_show_if(4, &inner, &[]);
4044        let level3 = encode_show_if(3, &level4, &[]);
4045        let level2 = encode_show_if(2, &level3, &[]);
4046        let level1 = encode_show_if(1, &level2, &[]);
4047        let level0 = encode_show_if(0, &level1, &[]);
4048
4049        let mut slots = SlotData::new(5);
4050        for i in 0..5 {
4051            slots.set(i, SlotValue::Bool(true));
4052        }
4053
4054        let html = walk_with_slots(
4055            &["s0", "s1", "s2", "s3", "s4", "deep"],
4056            &[
4057                (0, 0, 0x02, 0x00, &[]),
4058                (1, 1, 0x02, 0x00, &[]),
4059                (2, 2, 0x02, 0x00, &[]),
4060                (3, 3, 0x02, 0x00, &[]),
4061                (4, 4, 0x02, 0x00, &[]),
4062            ],
4063            &level0,
4064            &slots,
4065        );
4066        assert!(
4067            html.contains("deep"),
4068            "5 levels of nesting should succeed, got: {html}"
4069        );
4070    }
4071
4072    /// Build a one-island ScriptTag-mode page whose single prop is `payload`,
4073    /// and return (full html, the raw script-block body).
4074    fn script_tag_props_html(payload: &str) -> (String, String) {
4075        // ISLAND_START(0) + OPEN_TAG("div") + CLOSE_TAG + ISLAND_END(0)
4076        // strings: 0="div", 1="Comp", 2="payload"
4077        let mut opcodes = Vec::new();
4078        opcodes.extend_from_slice(&encode_island_start(0));
4079        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4080        opcodes.extend_from_slice(&encode_close_tag(0));
4081        opcodes.extend_from_slice(&encode_island_end(0));
4082
4083        let mut slots = SlotData::new(1);
4084        slots.set(0, SlotValue::Text(payload.to_string()));
4085
4086        let html = walk_with_islands(
4087            &["div", "Comp", "payload"],
4088            &[(0, 2, 0x01, 0x00, &[])],
4089            &[(0, 0x01, 0x02, 1, 0, &[0])],
4090            &opcodes,
4091            &slots,
4092        );
4093
4094        const OPEN: &str = "type=\"application/json\">";
4095        let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4096        let end = html[start..].find("</script>").expect("script close") + start;
4097        let body = html[start..end].to_string();
4098        (html, body)
4099    }
4100
4101    #[test]
4102    fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4103        // The property that actually makes the escaping total: no bare '<' or
4104        // '>' survives into the data block, so nothing in it can terminate or
4105        // re-open the enclosing <script> element. Escaping only "</" (as this
4106        // used to) is not enough — see the breakout test below.
4107        let (_html, body) = script_tag_props_html("</script>alert(1)");
4108        assert!(
4109            !body.contains('<') && !body.contains('>'),
4110            "islands block must contain no raw angle brackets, got: {body}"
4111        );
4112        assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4113
4114        // ...and the escaping is value-preserving: JSON.parse (here
4115        // serde_json::from_str, which decodes \u003c identically) round-trips
4116        // back to the original prop value.
4117        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4118        assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4119    }
4120
4121    #[test]
4122    fn walk_script_tag_props_escapes_comment_script_breakout() {
4123        // The payload the old "</" replace did NOT handle. `<!--` followed by
4124        // `<script` drives a spec-compliant HTML tokenizer into the
4125        // script-data-double-escaped state, where our own trailing </script>
4126        // is consumed as text instead of ending the element — swallowing the
4127        // whole rest of the page and leaving an inert page.
4128        let payload = "<!--<script>PAYLOAD";
4129        let (html, body) = script_tag_props_html(payload);
4130
4131        assert!(
4132            !html.contains("<!--<script"),
4133            "must not emit the script-data-double-escape trigger, got: {html}"
4134        );
4135        assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4136
4137        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4138        assert_eq!(parsed["0"]["payload"], payload);
4139    }
4140
4141    #[test]
4142    #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4143    fn walk_real_benchmark_ir() {
4144        // Find the benchmark IR file dynamically (hash changes on rebuild)
4145        let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4146        let ir_path = std::fs::read_dir(&dist_dir)
4147            .expect("admin/dist must exist")
4148            .filter_map(|e| e.ok())
4149            .find(|e| {
4150                let name = e.file_name();
4151                let s = name.to_string_lossy();
4152                s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4153            })
4154            .map(|e| e.path())
4155            .expect("platform-benchmark.*.ir must exist in admin/dist");
4156        let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4157        let module = IrModule::parse(&data).expect("IR must parse");
4158
4159        // Walk with default slots
4160        let slots = SlotData::new_from_defaults(&module.slots);
4161        let html = walk_to_html(&module, &slots).expect("walk must succeed");
4162
4163        // Verify island markers exist
4164        assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4165        assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4166        assert!(
4167            html.contains("data-forma-island=\"0\""),
4168            "missing island 0 attr"
4169        );
4170
4171        // The key assertion: island content must include FilterBar's children
4172        // (not just an empty shell div)
4173        assert!(
4174            html.contains("Search"),
4175            "FilterBar must contain 'Search' label text in SSR"
4176        );
4177        assert!(
4178            html.contains("filter-group"),
4179            "FilterBar must contain filter-group divs in SSR"
4180        );
4181        assert!(
4182            html.contains("Sort by"),
4183            "FilterBar must contain 'Sort by' label text in SSR"
4184        );
4185
4186        // PerfPanel content
4187        assert!(
4188            html.contains("Performance"),
4189            "PerfPanel must contain 'Performance' heading in SSR"
4190        );
4191
4192        // BenchmarkDataTable — SHOW_IF may pick either branch depending on slot defaults
4193        // Just check the structure exists
4194        assert!(
4195            html.contains("benchmark-data-table"),
4196            "BenchmarkDataTable root class must exist"
4197        );
4198
4199        // The FilterBar island must be a real, closed region, not just a
4200        // stray opening marker. (This replaced a pair of `eprintln!`s that
4201        // printed the region "for manual inspection" and asserted nothing.)
4202        let start = html
4203            .find("<!--f:i0-->")
4204            .expect("FilterBar island start marker");
4205        let end = html
4206            .find("<!--/f:i0-->")
4207            .expect("FilterBar island end marker");
4208        assert!(end > start, "island end marker must follow its start");
4209    }
4210
4211    // -- Output budget (F-02) -----------------------------------------------
4212
4213    #[test]
4214    fn walk_output_limit_trips_inside_one_opcode() {
4215        // The case a top-of-loop check cannot catch: ONE OpenTag opcode can
4216        // emit hundreds of megabytes, because attr_count goes to u16::MAX and
4217        // every value escapes up to 5x. The budget must be enforced by the
4218        // append itself, not once per iteration.
4219        let big = "&".repeat(4096); // escapes to 5x = 20 KiB each
4220        let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4221        let opcodes = encode_open_tag(0, &attrs);
4222
4223        let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4224        let module = IrModule::parse(&data).unwrap();
4225        let slots = SlotData::new(0);
4226
4227        let limits = WalkLimits {
4228            max_output_bytes: 64 * 1024,
4229            ..WalkLimits::default()
4230        };
4231        let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4232        assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4233
4234        // ...and a generous budget still renders it.
4235        let ok = WalkLimits {
4236            max_output_bytes: 64 * 1024 * 1024,
4237            ..WalkLimits::default()
4238        };
4239        assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4240    }
4241
4242    #[test]
4243    fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4244        // DynText emits a whole slot value in one iteration too.
4245        let opcodes = encode_dyn_text(0, 0);
4246        let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4247        let module = IrModule::parse(&data).unwrap();
4248
4249        let mut slots = SlotData::new(1);
4250        slots.set(0, SlotValue::Text("x".repeat(100_000)));
4251
4252        let limits = WalkLimits {
4253            max_output_bytes: 4096,
4254            ..WalkLimits::default()
4255        };
4256        assert!(matches!(
4257            walk_to_html_with_limits(&module, &slots, limits),
4258            Err(IrError::OutputLimitExceeded { .. })
4259        ));
4260    }
4261
4262    // -- LIST iteration budget (F-08) ---------------------------------------
4263
4264    #[test]
4265    fn walk_list_budget_exceeded() {
4266        // Depth bounds nesting, not volume: two nested LISTs over the same
4267        // 300-element array is 90,000 body walks from a tiny module.
4268        let body = encode_text(2);
4269        let inner = encode_list(0, 1, &body);
4270        let opcodes = encode_list(0, 1, &inner);
4271
4272        let data = build_minimal_ir(
4273            &["items", "item", "x"],
4274            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4275            &opcodes,
4276            &[],
4277        );
4278        let module = IrModule::parse(&data).unwrap();
4279
4280        let mut slots = SlotData::new(2);
4281        slots.set(
4282            0,
4283            SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4284        );
4285
4286        let err = walk_to_html(&module, &slots).unwrap_err();
4287        assert_eq!(
4288            err,
4289            IrError::ListBudgetExceeded {
4290                max: DEFAULT_MAX_LIST_ITERATIONS
4291            }
4292        );
4293        // Deliberately a DIFFERENT error from ListDepthExceeded — an operator
4294        // reading "nested LIST depth exceeded maximum of 4" for this case
4295        // would be chasing the wrong thing.
4296        assert_ne!(
4297            err,
4298            IrError::ListDepthExceeded {
4299                max: MAX_LIST_DEPTH
4300            }
4301        );
4302    }
4303
4304    // -- Offset arithmetic and structural tiling (F-06 / F-07) --------------
4305
4306    /// Corrupt a u32 length field inside an opcode buffer.
4307    fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4308        buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4309    }
4310
4311    #[test]
4312    fn parse_rejects_show_if_then_len_past_the_stream() {
4313        // SHOW_IF: opcode(1) slot_id(2) then_len(4) else_len(4)
4314        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4315        patch_u32(&mut opcodes, 3, 0xFFFF_FF00); // then_len
4316        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4317        assert!(matches!(
4318            IrModule::parse(&data),
4319            Err(IrError::MalformedControlFlow { .. })
4320        ));
4321    }
4322
4323    #[test]
4324    fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4325        // then_len short by one, so the byte at then_end is a TEXT opcode
4326        // rather than the SHOW_ELSE marker.
4327        let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4328        patch_u32(&mut opcodes, 3, 4); // then body is 5 bytes; claim 4
4329        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4330        assert!(matches!(
4331            IrModule::parse(&data),
4332            Err(IrError::MalformedControlFlow { .. })
4333        ));
4334    }
4335
4336    #[test]
4337    fn parse_rejects_list_body_len_past_the_stream() {
4338        // LIST: opcode(1) slot_id(2) item_slot_id(2) body_len(4)
4339        let mut opcodes = encode_list(0, 1, &encode_text(2));
4340        patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
4341        let data = build_minimal_ir(
4342            &["items", "item", "x"],
4343            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4344            &opcodes,
4345            &[],
4346        );
4347        assert!(matches!(
4348            IrModule::parse(&data),
4349            Err(IrError::MalformedControlFlow { .. })
4350        ));
4351    }
4352
4353    #[test]
4354    fn parse_rejects_switch_case_body_len_past_the_stream() {
4355        // The over-long length is in case 0, which does NOT match the slot
4356        // value at render time — a non-matching case must be validated too.
4357        let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
4358        // header 0 body_len is at opcode(1)+slot(2)+count(2)+val_idx(4) = 9
4359        patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
4360        let data = build_minimal_ir(
4361            &["mode", "a", "x", "b"],
4362            &[(0, 0, 0x01, 0x00, &[])],
4363            &opcodes,
4364            &[],
4365        );
4366        assert!(matches!(
4367            IrModule::parse(&data),
4368            Err(IrError::MalformedControlFlow { .. })
4369        ));
4370    }
4371
4372    #[test]
4373    fn parse_rejects_fallback_skip_past_the_stream() {
4374        // TRY_START: opcode(1) fallback_len(4)
4375        let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
4376        patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
4377        let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
4378        assert!(matches!(
4379            IrModule::parse(&data),
4380            Err(IrError::MalformedControlFlow { .. })
4381        ));
4382    }
4383
4384    #[test]
4385    fn parse_accepts_zero_length_else_branch_known_residual() {
4386        // DOCUMENTED RESIDUAL: an under-long length whose orphaned bytes still
4387        // decode as a legal sibling sequence is structurally indistinguishable
4388        // from a legitimate module. No length check can reject it; only module
4389        // integrity (a populated source_hash) would. This test exists so the
4390        // case is not later mistaken for a fixed hole.
4391        let opcodes = encode_show_if(0, &encode_text(1), &[]);
4392        let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4393        assert!(IrModule::parse(&data).is_ok());
4394    }
4395
4396    // -- TRY/FALLBACK scoping (F-10) ----------------------------------------
4397
4398    #[test]
4399    fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
4400        // Layout: TRY_START [ SHOW_IF(then = FALLBACK) TEXT("main") ] FALLBACK TEXT("fb")
4401        //
4402        // The FALLBACK inside the then-branch belongs to no TRY_START in its
4403        // own range. The walker's `fallback_stack` is range-local so it cannot
4404        // pop the outer entry and skip the wrong region — but it would still
4405        // fall through and walk the fallback body as if it were main content,
4406        // which is a silently wrong page. Validation rejects the module first.
4407        let then_ops = vec![0x0E]; // stray FALLBACK inside the then-branch
4408        let mut main_ops = encode_show_if(0, &then_ops, &[]);
4409        main_ops.extend_from_slice(&encode_text(1));
4410        let opcodes = encode_try(&main_ops, &encode_text(2));
4411
4412        let data = build_minimal_ir(
4413            &["flag", "main", "fb"],
4414            &[(0, 0, 0x02, 0x00, &[])],
4415            &opcodes,
4416            &[],
4417        );
4418        assert!(matches!(
4419            IrModule::parse(&data).unwrap_err(),
4420            IrError::MalformedControlFlow { .. }
4421        ));
4422    }
4423
4424    #[test]
4425    fn walk_try_without_a_fallback_is_rejected_at_load() {
4426        // A TRY_START whose FALLBACK never arrives ships a page with no
4427        // fallback and no diagnostic.
4428        let mut opcodes = vec![0x0D]; // TRY_START
4429        opcodes.extend_from_slice(&4u32.to_le_bytes()); // fallback_len
4430        opcodes.extend_from_slice(&encode_text(0));
4431
4432        let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
4433        assert!(matches!(
4434            IrModule::parse(&data).unwrap_err(),
4435            IrError::MalformedControlFlow { .. }
4436        ));
4437    }
4438
4439    // -- Pending island / list-key scoping (F-11) ---------------------------
4440
4441    #[test]
4442    fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
4443        // ISLAND_START, SHOW_IF (both branches empty), then the root element.
4444        // Clearing pending state at the end of every walk_range would wipe the
4445        // island attributes here and the island would never hydrate — the
4446        // reason the clear lives on ISLAND_END instead.
4447        let mut opcodes = encode_island_start(0);
4448        opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
4449        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4450        opcodes.extend_from_slice(&encode_close_tag(1));
4451        opcodes.extend_from_slice(&encode_island_end(0));
4452
4453        let slots = SlotData::new(1);
4454        let html = walk_with_islands(
4455            &["Comp", "div", "flag"],
4456            &[(0, 2, 0x02, 0x00, &[])],
4457            &[(0, 0x01, 0x01, 0, 0, &[])],
4458            &opcodes,
4459            &slots,
4460        );
4461        assert!(
4462            html.contains("<div data-forma-island=\"0\""),
4463            "island root must keep its attributes, got: {html}"
4464        );
4465    }
4466
4467    #[test]
4468    fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
4469        let mut body = encode_list_item_key(3);
4470        body.extend_from_slice(&encode_show_if(1, &[], &[]));
4471        body.extend_from_slice(&encode_open_tag(2, &[]));
4472        body.extend_from_slice(&encode_close_tag(2));
4473        let opcodes = encode_list(0, 1, &body);
4474
4475        let mut slots = SlotData::new(2);
4476        slots.set(
4477            0,
4478            SlotValue::array(vec![
4479                SlotValue::Text("a".into()),
4480                SlotValue::Text("b".into()),
4481            ]),
4482        );
4483
4484        let html = walk_with_slots(
4485            &["items", "item", "li", "k"],
4486            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4487            &opcodes,
4488            &slots,
4489        );
4490        assert_eq!(
4491            html.matches("data-forma-key=\"k\"").count(),
4492            2,
4493            "every item element must keep its key, got: {html}"
4494        );
4495    }
4496
4497    #[test]
4498    fn walk_island_attrs_do_not_leak_past_the_island_end() {
4499        // ISLAND_START with no element inside, then an element AFTER the
4500        // island's closing marker. The island's identity attributes must not
4501        // land on it.
4502        let mut opcodes = encode_island_start(0);
4503        opcodes.extend_from_slice(&encode_island_end(0));
4504        opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4505        opcodes.extend_from_slice(&encode_close_tag(1));
4506
4507        let slots = SlotData::new(0);
4508        let html = walk_with_islands(
4509            &["Comp", "div"],
4510            &[],
4511            &[(0, 0x01, 0x01, 0, 0, &[])],
4512            &opcodes,
4513            &slots,
4514        );
4515        assert!(
4516            !html.contains("data-forma-island"),
4517            "island attributes must not leak onto an element outside the island, got: {html}"
4518        );
4519    }
4520
4521    #[test]
4522    fn walk_list_key_does_not_leak_past_the_list() {
4523        let body = encode_list_item_key(2); // key, but no element to carry it
4524        let mut opcodes = encode_list(0, 1, &body);
4525        opcodes.extend_from_slice(&encode_open_tag(3, &[]));
4526        opcodes.extend_from_slice(&encode_close_tag(3));
4527
4528        let mut slots = SlotData::new(2);
4529        slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
4530
4531        let html = walk_with_slots(
4532            &["items", "item", "k", "div"],
4533            &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4534            &opcodes,
4535            &slots,
4536        );
4537        assert!(
4538            !html.contains("data-forma-key"),
4539            "list key must not leak onto an element after the list, got: {html}"
4540        );
4541    }
4542
4543    // -- Name validation (S13) ----------------------------------------------
4544
4545    #[test]
4546    fn walk_rejects_hostile_tag_name() {
4547        // Tag names are written unescaped, so an IR-authored name is direct
4548        // markup injection.
4549        let opcodes = encode_open_tag(0, &[]);
4550        let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
4551        let module = IrModule::parse(&data).unwrap();
4552        let slots = SlotData::new(0);
4553        assert!(matches!(
4554            walk_to_html(&module, &slots),
4555            Err(IrError::InvalidName(_))
4556        ));
4557    }
4558
4559    #[test]
4560    fn walk_rejects_hostile_close_tag_name() {
4561        let mut opcodes = encode_open_tag(0, &[]);
4562        opcodes.extend_from_slice(&encode_close_tag(1));
4563        let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
4564        let module = IrModule::parse(&data).unwrap();
4565        let slots = SlotData::new(0);
4566        assert!(matches!(
4567            walk_to_html(&module, &slots),
4568            Err(IrError::InvalidName(_))
4569        ));
4570    }
4571
4572    #[test]
4573    fn walk_drops_hostile_attribute_names() {
4574        // Attribute names are skippable, so one bad key costs the attribute
4575        // rather than the whole page.
4576        let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
4577        let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
4578        let module = IrModule::parse(&data).unwrap();
4579        let slots = SlotData::new(0);
4580        let html = walk_to_html(&module, &slots).unwrap();
4581        assert_eq!(html, "<div ok-attr=\"v\">");
4582    }
4583
4584    #[test]
4585    fn walk_drops_hostile_boolean_attribute_name() {
4586        // Same, via the boolean (empty-value) branch.
4587        let opcodes = encode_open_tag(0, &[(1, 2)]);
4588        let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
4589        let module = IrModule::parse(&data).unwrap();
4590        let slots = SlotData::new(0);
4591        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4592    }
4593
4594    #[test]
4595    fn walk_drops_hostile_dyn_attr_name() {
4596        let mut opcodes = encode_open_tag(0, &[]);
4597        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4598        let data = build_minimal_ir(
4599            &["div", "z\"><script", "val"],
4600            &[(0, 2, 0x01, 0x00, &[])],
4601            &opcodes,
4602            &[],
4603        );
4604        let module = IrModule::parse(&data).unwrap();
4605        let mut slots = SlotData::new(1);
4606        slots.set(0, SlotValue::Text("x".into()));
4607        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4608    }
4609
4610    // -- URL / event-handler attribute safety (S14) -------------------------
4611
4612    #[test]
4613    fn walk_drops_javascript_url_in_dyn_attr() {
4614        let mut opcodes = encode_open_tag(0, &[]);
4615        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4616        let data = build_minimal_ir(
4617            &["a", "href", "url"],
4618            &[(0, 2, 0x01, 0x00, &[])],
4619            &opcodes,
4620            &[],
4621        );
4622        let module = IrModule::parse(&data).unwrap();
4623
4624        // Also covers the normalization step: browsers strip control chars
4625        // before resolving the scheme, so `java\tscript:` executes.
4626        for payload in [
4627            "javascript:alert(1)",
4628            "JaVaScRiPt:alert(1)",
4629            "java\tscript:alert(1)",
4630            "  javascript:alert(1)",
4631            "vbscript:msgbox(1)",
4632            "data:text/html,<script>alert(1)</script>",
4633        ] {
4634            let mut slots = SlotData::new(1);
4635            slots.set(0, SlotValue::Text(payload.to_string()));
4636            let html = walk_to_html(&module, &slots).unwrap();
4637            // Dropped entirely, not emitted empty: href="" resolves to the
4638            // current page and would not match the client DOM either.
4639            assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
4640        }
4641    }
4642
4643    #[test]
4644    fn walk_keeps_safe_urls_and_data_images() {
4645        let mut opcodes = encode_open_tag(0, &[]);
4646        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4647        let data = build_minimal_ir(
4648            &["img", "src", "url"],
4649            &[(0, 2, 0x01, 0x00, &[])],
4650            &opcodes,
4651            &[],
4652        );
4653        let module = IrModule::parse(&data).unwrap();
4654
4655        for payload in [
4656            "/about",
4657            "https://example.com/x?a=1",
4658            "data:image/png;base64,AAAA",
4659            // svg+xml is allowed here because <img> is an image context — the
4660            // stock CSP permits img-src data:, so a blanket data: block would
4661            // break legitimate inline images.
4662            "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
4663        ] {
4664            let mut slots = SlotData::new(1);
4665            slots.set(0, SlotValue::Text(payload.to_string()));
4666            let html = walk_to_html(&module, &slots).unwrap();
4667            assert!(html.contains("src="), "payload {payload:?} was dropped");
4668        }
4669    }
4670
4671    #[test]
4672    fn walk_blocks_svg_data_url_outside_image_context() {
4673        let mut opcodes = encode_open_tag(0, &[]);
4674        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4675        let data = build_minimal_ir(
4676            &["a", "href", "url"],
4677            &[(0, 2, 0x01, 0x00, &[])],
4678            &opcodes,
4679            &[],
4680        );
4681        let module = IrModule::parse(&data).unwrap();
4682        let mut slots = SlotData::new(1);
4683        slots.set(
4684            0,
4685            SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
4686        );
4687        // Through a document sink the SVG is parsed as a document and its
4688        // onload fires, so it is blocked here even though <img> allows it.
4689        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
4690    }
4691
4692    #[test]
4693    fn walk_drops_event_handler_attributes() {
4694        // Static attribute path...
4695        let opcodes = encode_open_tag(0, &[(1, 2)]);
4696        let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
4697        let module = IrModule::parse(&data).unwrap();
4698        assert_eq!(
4699            walk_to_html(&module, &SlotData::new(0)).unwrap(),
4700            "<div>",
4701            "static on* attribute must be dropped"
4702        );
4703
4704        // ...and the dynamic one.
4705        let mut opcodes = encode_open_tag(0, &[]);
4706        opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4707        let data = build_minimal_ir(
4708            &["div", "ONCLICK", "h"],
4709            &[(0, 2, 0x01, 0x00, &[])],
4710            &opcodes,
4711            &[],
4712        );
4713        let module = IrModule::parse(&data).unwrap();
4714        let mut slots = SlotData::new(1);
4715        slots.set(0, SlotValue::Text("alert(1)".into()));
4716        assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4717    }
4718
4719    #[test]
4720    fn walk_drops_javascript_url_in_static_attr() {
4721        // Static values come from the string table (template-author level),
4722        // but renderAttr applies identical rules to static props — leaving
4723        // them unchecked would preserve an SSR/CSR divergence.
4724        let opcodes = encode_open_tag(0, &[(1, 2)]);
4725        let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
4726        let module = IrModule::parse(&data).unwrap();
4727        assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
4728    }
4729
4730    #[test]
4731    fn walk_comment_cannot_self_close() {
4732        // A leading ">" or "->" closes an HTML comment immediately per the
4733        // tokenizer, spilling the rest into the document as markup.
4734        for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
4735            let opcodes = encode_comment(0);
4736            let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
4737            let module = IrModule::parse(&data).unwrap();
4738            let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
4739            assert!(
4740                html.starts_with("<!-- "),
4741                "comment must not start with a closing sequence, got: {html}"
4742            );
4743        }
4744    }
4745
4746    // -- Number rendering parity (F-05) -------------------------------------
4747
4748    #[test]
4749    fn walk_switch_matches_on_js_number_text() {
4750        // to_text also drives SWITCH case comparison, and the case strings are
4751        // authored on the JS side — so branch selection follows JS semantics.
4752        let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
4753        let data = build_minimal_ir(
4754            &["n", "1e+21", "big", "1000", "small"],
4755            &[(0, 0, 0x03, 0x00, &[])],
4756            &opcodes,
4757            &[],
4758        );
4759        let module = IrModule::parse(&data).unwrap();
4760
4761        let mut slots = SlotData::new(1);
4762        slots.set(0, SlotValue::Number(1e21));
4763        assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
4764
4765        let mut slots = SlotData::new(1);
4766        slots.set(0, SlotValue::Number(1000.0));
4767        assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
4768    }
4769}