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