#[cfg(feature = "tracing")]
use crate::format::SlotSource;
use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
use crate::parser::IrModule;
use crate::slot::{SlotData, SlotValue};
use std::fmt;
struct IslandPending {
id: u16,
component_name: String,
inline_props: Option<String>,
trigger: IslandTrigger,
}
pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalkLimits {
pub max_output_bytes: usize,
pub max_list_iterations: usize,
}
impl Default for WalkLimits {
fn default() -> Self {
WalkLimits {
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
}
}
}
struct WalkState {
list_depth: u8,
pending_island: Option<IslandPending>,
pending_list_key: Option<String>,
script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
pending_tag_close: bool,
pending_tag_name: String,
max_output_bytes: usize,
list_iterations: usize,
max_list_iterations: usize,
slot_journal: Vec<(u16, SlotValue)>,
force_inline_props: bool,
text_only: Option<TextOnlyParent>,
pending_control: PendingControl,
select: Option<SelectBinding>,
option_by_text: Option<OptionByText>,
nodes: usize,
islands: Vec<u16>,
#[cfg(any(test, feature = "__fault-injection"))]
fault_armed: bool,
}
impl WalkState {
fn new(limits: WalkLimits) -> Self {
WalkState {
list_depth: 0,
pending_island: None,
pending_list_key: None,
script_tag_props: std::collections::BTreeMap::new(),
pending_tag_close: false,
pending_tag_name: String::new(),
max_output_bytes: limits.max_output_bytes,
list_iterations: 0,
max_list_iterations: limits.max_list_iterations,
slot_journal: Vec::new(),
force_inline_props: false,
text_only: None,
pending_control: PendingControl::None,
select: None,
option_by_text: None,
nodes: 0,
islands: Vec::new(),
#[cfg(any(test, feature = "__fault-injection"))]
fault_armed: false,
}
}
fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
if self.list_depth > 0 {
self.slot_journal.push((slot_id, previous));
}
}
fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
while self.slot_journal.len() > mark {
if let Some((slot_id, previous)) = self.slot_journal.pop() {
slots.set(slot_id, previous);
}
}
}
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
#[cfg(feature = "tracing")]
if let Some(entry) = module.slot_entry(slot_id) {
if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
tracing::warn!(
slot_id = slot_id,
"Server-sourced slot has no value at render time — handler may have a bug"
);
}
}
}
pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
walk_to_html_with_limits(module, slots, WalkLimits::default())
}
pub fn walk_to_html_with_limits(
module: &IrModule,
slots: &SlotData,
limits: WalkLimits,
) -> Result<String, IrError> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(initial_capacity(len));
let mut state = WalkState::new(limits);
let mut slots_mut = slots.clone();
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
finish_page(&mut out, &state)?;
Ok(out)
}
fn initial_capacity(opcodes_len: usize) -> usize {
opcodes_len.saturating_mul(4).min(1 << 20)
}
fn finish_page(out: &mut String, state: &WalkState) -> Result<(), IrError> {
if !state.script_tag_props.is_empty() {
let json =
serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
push_checked(
out,
"<script id=\"__forma_islands\" type=\"application/json\">",
state.max_output_bytes,
)?;
push_json_script_body(out, &json, state.max_output_bytes)?;
push_checked(out, "</script>", state.max_output_bytes)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkCounts {
pub nodes: usize,
pub bytes: usize,
pub list_iterations: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkOutput {
pub html: String,
pub counts: WalkCounts,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkFailure {
pub error: IrError,
pub component_path: Vec<String>,
pub counts: WalkCounts,
}
impl fmt::Display for WalkFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.error)?;
if !self.component_path.is_empty() {
write!(f, " (in {})", self.component_path.join(" > "))?;
}
Ok(())
}
}
impl std::error::Error for WalkFailure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
pub fn walk_to_html_counted(
module: &IrModule,
slots: &SlotData,
limits: WalkLimits,
) -> Result<WalkOutput, WalkFailure> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(initial_capacity(len));
let mut state = WalkState::new(limits);
#[cfg(any(test, feature = "__fault-injection"))]
{
state.fault_armed = true;
}
let mut slots_mut = slots.clone();
let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
finish_page(&mut out, &state)
}));
let counts = WalkCounts {
nodes: state.nodes,
bytes: out.len(),
list_iterations: state.list_iterations,
};
let error = match walked {
Ok(Ok(())) => return Ok(WalkOutput { html: out, counts }),
Ok(Err(error)) => error,
Err(payload) => IrError::Panicked {
message: panic_message(payload.as_ref()),
},
};
let component_path = state
.islands
.iter()
.map(|&id| {
module
.island_entry(id)
.and_then(|entry| module.strings.get(entry.name_str_idx).ok())
.map_or_else(|| format!("island {id}"), str::to_string)
})
.collect();
Err(WalkFailure {
error,
component_path,
counts,
})
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"a panic with no message".to_string()
}
}
fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
for ch in json.chars() {
match ch {
'<' => push_checked(out, "\\u003c", limit)?,
'>' => push_checked(out, "\\u003e", limit)?,
'\u{2028}' => push_checked(out, "\\u2028", limit)?,
'\u{2029}' => push_checked(out, "\\u2029", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
walk_island_with_limits(module, slots, island_id, WalkLimits::default())
}
pub fn walk_island_with_limits(
module: &IrModule,
slots: &SlotData,
island_id: u16,
limits: WalkLimits,
) -> Result<String, IrError> {
let entry = module
.island_entry(island_id)
.ok_or(IrError::IslandNotFound(island_id))?;
let byte_offset = entry.byte_offset as usize;
let ops = &module.opcodes;
if byte_offset >= ops.len() {
return Err(IrError::BufferTooShort {
expected: byte_offset.saturating_add(1),
actual: ops.len(),
});
}
let opcode = Opcode::from_byte(ops[byte_offset])?;
if opcode != Opcode::IslandStart {
return Err(IrError::InvalidOpcode(ops[byte_offset]));
}
let content_start = byte_offset.saturating_add(3);
if content_start > ops.len() {
return Err(IrError::BufferTooShort {
expected: content_start,
actual: ops.len(),
});
}
let mut out = String::with_capacity(256);
let mut state = WalkState::new(limits);
state.force_inline_props = true;
let mut slots_mut = slots.clone();
let component_name = module.strings.get(entry.name_str_idx)?.to_string();
let inline_props = match entry.props_mode {
PropsMode::Inline | PropsMode::ScriptTag => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, &slots_mut, &entry.slot_ids);
Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
}
}
PropsMode::Deferred => None,
};
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
inline_props,
trigger: entry.trigger,
});
walk_range_until_island_end(
module,
&mut slots_mut,
ops,
content_start,
island_id,
&mut out,
&mut state,
0,
)?;
Ok(out)
}
const MAX_LIST_DEPTH: u8 = 4;
const MAX_RECURSION_DEPTH: usize = 64;
enum WalkMode {
Range { end: usize },
UntilIslandEnd { target_island_id: u16 },
}
#[allow(clippy::too_many_arguments)]
fn walk_range(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
end: usize,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::Range { end },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_until_island_end(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
target_island_id: u16,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::UntilIslandEnd { target_island_id },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_impl(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
mode: WalkMode,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
if depth > MAX_RECURSION_DEPTH {
return Err(IrError::RecursionLimitExceeded);
}
let strings = &module.strings;
let mut pos = start;
let limit = state.max_output_bytes;
let mut fallback_stack: Vec<usize> = Vec::new();
let loop_bound = match mode {
WalkMode::Range { end } => end,
WalkMode::UntilIslandEnd { .. } => usize::MAX, };
let region_limit = match mode {
WalkMode::Range { end } => end.min(ops.len()),
WalkMode::UntilIslandEnd { .. } => ops.len(),
};
while pos < loop_bound {
if out.len() > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
if pos >= ops.len() {
match mode {
WalkMode::Range { .. } => {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
WalkMode::UntilIslandEnd { .. } => break, }
}
let opcode = Opcode::from_byte(ops[pos])?;
pos += 1;
if state.pending_tag_close && opcode != Opcode::DynAttr {
close_start_tag(out, state, limit)?;
}
match opcode {
Opcode::OpenTag | Opcode::VoidTag => {
let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
let tag = strings.get(tag_str_idx)?;
let has_content = opcode == Opcode::OpenTag;
emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
state.nodes += 1;
pos = new_pos;
}
Opcode::CloseTag => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let tag = strings.get(str_idx)?;
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
leave_element(out, tag, state, limit)?;
push_checked(out, "</", limit)?;
push_checked(out, tag, limit)?;
push_checked(out, ">", limit)?;
}
Opcode::Text => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
if let Some(parent) = state.text_only.as_mut() {
if !parent.value_bound {
write_text_only(out, parent, text, limit)?;
}
} else {
push_escaped_html(out, text, limit)?;
if let Some(option) = state.option_by_text.as_mut() {
option.text.push_str(text);
}
}
state.nodes += 1;
}
Opcode::DynText => {
let slot_id = read_u16(ops, pos)?;
pos += 2;
let marker_id = read_u16(ops, pos)?;
pos += 2;
check_slot_source(module, slot_id, slots.get(slot_id));
let value = slots.get(slot_id).to_text();
#[cfg(any(test, feature = "__fault-injection"))]
#[allow(clippy::panic)]
if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
panic!("stand-in component panicked (FAULT_TRIGGER)");
}
if let Some(parent) = state.text_only.as_mut() {
if !parent.value_bound {
write_text_only(out, parent, &value, limit)?;
}
} else {
push_checked(out, "<!--f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
let written = if value.is_empty() {
"\u{200B}"
} else {
value.as_str()
};
push_escaped_html(out, written, limit)?;
push_checked(out, "<!--/f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
if let Some(option) = state.option_by_text.as_mut() {
option.text.push_str(written);
}
}
state.nodes += 1;
}
Opcode::DynAttr => {
let attr_str_idx = read_u32(ops, pos)?;
let slot_id = read_u16(ops, pos + 4)?;
pos += 6;
check_slot_source(module, slot_id, slots.get(slot_id));
if state.pending_tag_close {
let attr_name = strings.get(attr_str_idx)?;
if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
continue;
}
let text;
let value = match slots.get(slot_id) {
SlotValue::Bool(false) | SlotValue::Null => None,
SlotValue::Bool(true) => Some(AttrValue::Bare),
other => {
text = other.to_text();
Some(AttrValue::Valued(text.as_str()))
}
};
if let Some(value) = value {
let tag = std::mem::take(&mut state.pending_tag_name);
emit_attr(out, &tag, attr_name, value, limit)?;
state.pending_tag_name = tag;
}
}
}
Opcode::IslandStart => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if let Some(outer) = &state.pending_island {
return Err(IrError::NestedIslandRoot {
outer: outer.id,
inner: island_id,
});
}
state.islands.push(island_id);
if state.text_only.is_none() {
push_checked(out, "<!--f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
}
if let Some(entry) = module.island_entry(island_id) {
let component_name = strings.get(entry.name_str_idx)?.to_string();
let inline_props = match entry.props_mode {
PropsMode::Inline => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, slots, &entry.slot_ids);
let json = serde_json::to_string(&props)
.unwrap_or_else(|_| "{}".to_string());
if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
{
state
.script_tag_props
.insert(island_id, serde_json::Value::Object(props));
None
} else {
Some(json)
}
}
}
PropsMode::ScriptTag if state.force_inline_props => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, slots, &entry.slot_ids);
Some(
serde_json::to_string(&props)
.unwrap_or_else(|_| "{}".to_string()),
)
}
}
PropsMode::ScriptTag => {
if !entry.slot_ids.is_empty() {
let props = build_island_props(module, slots, &entry.slot_ids);
state
.script_tag_props
.insert(island_id, serde_json::Value::Object(props));
}
None
}
PropsMode::Deferred => None,
};
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
inline_props,
trigger: entry.trigger,
});
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START names an island that is not in the island table — \
markers emitted but no data-forma-* attributes, so it will not hydrate \
(module was not validated)"
);
}
}
Opcode::IslandEnd => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if state.islands.last() == Some(&island_id) {
state.islands.pop();
}
if state.pending_island.take().is_some() {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START was not followed by an element — island attributes dropped, \
this island will not hydrate (compiler should emit a shell element)"
);
}
if let WalkMode::UntilIslandEnd { target_island_id } = mode {
if island_id == target_island_id {
if state.pending_tag_close {
close_start_tag(out, state, limit)?;
}
return Ok(());
}
}
if state.text_only.is_none() {
push_checked(out, "<!--/f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
}
}
Opcode::Comment => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
push_checked(out, "<!--", limit)?;
if text.starts_with('>') || text.starts_with("->") {
push_checked(out, " ", limit)?;
}
push_checked(out, &text.replace("--", "--"), limit)?;
push_checked(out, "-->", limit)?;
}
Opcode::ShowIf => {
let slot_id = read_u16(ops, pos)?;
let then_len = read_u32(ops, pos + 2)? as usize;
let else_len = read_u32(ops, pos + 6)? as usize;
pos += 10;
check_slot_source(module, slot_id, slots.get(slot_id));
let condition = slots.get(slot_id).as_bool();
let then_start = pos;
let then_end = bounded_offset(pos, then_len, region_limit)?;
if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
return Err(IrError::InvalidSection);
}
let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
let markers = state.text_only.is_none();
if markers {
push_checked(out, "<!--f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
}
if condition {
walk_range(
module,
slots,
ops,
then_start,
then_end,
out,
state,
depth + 1,
)?;
} else {
walk_range(
module,
slots,
ops,
else_start,
else_end,
out,
state,
depth + 1,
)?;
}
if markers {
push_checked(out, "<!--/f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
}
pos = else_end;
}
Opcode::ShowElse => {
}
Opcode::Switch => {
let slot_id = read_u16(ops, pos)?;
let case_count = read_u16(ops, pos + 2)? as usize;
pos += 4;
let mut cases =
Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
for _ in 0..case_count {
let val_str_idx = read_u32(ops, pos)?;
let body_len = read_u32(ops, pos + 4)? as usize;
cases.push((val_str_idx, body_len));
pos += 8;
}
check_slot_source(module, slot_id, slots.get(slot_id));
let slot_text = slots.get(slot_id).to_text();
let mut body_pos = pos;
let mut matched = false;
for (val_str_idx, body_len) in &cases {
let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
let case_val = strings.get(*val_str_idx)?;
if !matched && case_val == slot_text {
matched = true;
walk_range(
module,
slots,
ops,
body_pos,
case_end,
out,
state,
depth + 1,
)?;
}
body_pos = case_end;
}
pos = body_pos; }
Opcode::List => {
let slot_id = read_u16(ops, pos)?;
let item_slot_id = read_u16(ops, pos + 2)?;
let body_len = read_u32(ops, pos + 4)? as usize;
pos += 8;
check_slot_source(module, slot_id, slots.get(slot_id));
let body_start = pos;
let body_end = bounded_offset(pos, body_len, region_limit)?;
let list_marker_id = slot_id;
let markers = state.text_only.is_none();
if markers {
push_checked(out, "<!--f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
}
let items = match slots.get(slot_id) {
SlotValue::Array(a) => Some(a.clone()),
_ => None,
};
if let Some(items) = items.filter(|a| !a.is_empty()) {
state.list_depth += 1;
if state.list_depth > MAX_LIST_DEPTH {
return Err(IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH,
});
}
let remaining = state
.max_list_iterations
.saturating_sub(state.list_iterations);
if items.len() > remaining {
return Err(IrError::ListBudgetExceeded {
max: state.max_list_iterations,
});
}
for item in items.iter() {
if state.list_iterations >= state.max_list_iterations {
return Err(IrError::ListBudgetExceeded {
max: state.max_list_iterations,
});
}
state.list_iterations += 1;
let mark = state.slot_journal.len();
let previous = slots.replace(item_slot_id, item.clone());
state.slot_journal.push((item_slot_id, previous));
let walked = walk_range(
module,
slots,
ops,
body_start,
body_end,
out,
state,
depth + 1,
);
state.unwind_slot_journal(slots, mark);
walked?;
state.pending_list_key = None;
}
state.list_depth -= 1;
}
state.pending_list_key = None;
if markers {
push_checked(out, "<!--/f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
}
pos = body_end;
}
Opcode::TryStart => {
let fallback_len = read_u32(ops, pos)? as usize;
pos += 4;
fallback_stack.push(fallback_len);
}
Opcode::Fallback => {
if let Some(skip) = fallback_stack.pop() {
pos = bounded_offset(pos, skip, region_limit)?;
}
}
Opcode::Preload => {
if pos >= ops.len() {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
let resource_type = ops[pos];
let url_str_idx = read_u32(ops, pos + 1)?;
let url = strings.get(url_str_idx)?;
pos += 5;
let (as_val, type_attr) = match resource_type {
1 => ("font", " type=\"font/woff2\" crossorigin"),
2 => ("style", ""),
3 => ("script", ""),
4 => ("image", ""),
_ => ("fetch", ""),
};
push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
push_escaped_attr(out, url, limit)?;
push_checked(out, "\" as=\"", limit)?;
push_checked(out, as_val, limit)?;
push_checked(out, "\"", limit)?;
push_checked(out, type_attr, limit)?;
push_checked(out, ">\n", limit)?;
}
Opcode::ListItemKey => {
let key_str_idx = read_u32(ops, pos)?;
pos += 4;
let key_val = strings.get(key_str_idx)?;
state.pending_list_key = Some(key_val.to_string());
}
Opcode::Prop => {
let src_slot_id = read_u16(ops, pos)?;
let prop_str_idx = read_u32(ops, pos + 2)?;
let target_slot_id = read_u16(ops, pos + 6)?;
pos += 8;
let prop_name = strings.get(prop_str_idx)?;
let value = slots.get(src_slot_id).get_property(prop_name);
let previous = slots.replace(target_slot_id, value);
state.journal_write(target_slot_id, previous);
}
}
}
match mode {
WalkMode::Range { .. } => {
if state.pending_tag_close {
close_start_tag(out, state, limit)?;
}
Ok(())
}
WalkMode::UntilIslandEnd { target_island_id } => {
Err(IrError::IslandNotFound(target_island_id))
}
}
}
fn build_island_props(
module: &IrModule,
slots: &SlotData,
slot_ids: &[u16],
) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for &slot_id in slot_ids {
if let Some(entry) = module.slot_entry(slot_id) {
if let Ok(name) = module.strings.get(entry.name_str_idx) {
if map.contains_key(name) {
#[cfg(feature = "tracing")]
tracing::warn!(
slot_id = slot_id,
name = name,
"Island declares two slots with the same name — keeping the first, \
the later value is not reachable from the props object"
);
continue;
}
let value = slots.get(slot_id).to_json();
map.insert(name.to_string(), value);
}
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
slot_id = slot_id,
"Island references a slot id that is not in the slot table — prop dropped"
);
}
}
map
}
fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
base.checked_add(len)
.filter(|e| *e <= limit)
.ok_or(IrError::InvalidSection)
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
}
fn is_event_handler_attr(name: &str) -> bool {
name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
}
const URL_ATTRS: [&str; 8] = [
"href",
"src",
"action",
"formaction",
"xlink:href",
"poster",
"background",
"data",
];
const IMAGE_CONTEXT_TAGS: [&str; 13] = [
"img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
"td", "th",
];
fn is_url_attr(name: &str) -> bool {
URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
}
fn is_dangerous_url(value: &str, tag: &str) -> bool {
let normalized: String = value
.chars()
.filter(|c| {
let n = *c as u32;
!((n <= 0x20) || (0x7F..=0x9F).contains(&n))
})
.collect();
let lower = normalized.to_ascii_lowercase();
if lower.starts_with("javascript") || lower.starts_with("vbscript") {
return true;
}
if lower.starts_with("data:text/html") {
return true;
}
if lower.starts_with("data:image/svg+xml") {
return !IMAGE_CONTEXT_TAGS
.iter()
.any(|t| tag.eq_ignore_ascii_case(t));
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttrValue<'a> {
Bare,
Valued(&'a str),
}
fn emit_attr(
out: &mut String,
tag: &str,
key: &str,
val: AttrValue<'_>,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_name(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
return Ok(());
}
if is_event_handler_attr(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
return Ok(());
}
if let AttrValue::Valued(v) = val {
if is_url_attr(key) && is_dangerous_url(v, tag) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
return Ok(());
}
}
push_checked(out, " ", limit)?;
push_checked(out, key, limit)?;
if let AttrValue::Valued(v) = val {
push_checked(out, "=\"", limit)?;
push_escaped_attr(out, v, limit)?;
push_checked(out, "\"", limit)?;
}
Ok(())
}
fn emit_open_tag(
out: &mut String,
tag: &str,
attrs: &[(&str, &str)],
has_content: bool,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
if has_content {
enter_element(out, tag, state, limit)?;
}
push_checked(out, "<", limit)?;
push_checked(out, tag, limit)?;
for (key, val) in attrs {
if consume_control_attr(state, key, Prop::Static(val)) {
continue;
}
let value = if val.is_empty() {
AttrValue::Bare
} else {
AttrValue::Valued(val)
};
emit_attr(out, tag, key, value, limit)?;
}
if let Some(island) = state.pending_island.take() {
push_checked(out, " data-forma-island=\"", limit)?;
push_u16(out, island.id, limit)?;
push_checked(out, "\" data-forma-component=\"", limit)?;
push_escaped_attr(out, &island.component_name, limit)?;
push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
let trigger_str = match island.trigger {
IslandTrigger::Load => "load",
IslandTrigger::Visible => "visible",
IslandTrigger::Interaction => "interaction",
IslandTrigger::Idle => "idle",
};
push_checked(out, " data-forma-hydrate=\"", limit)?;
push_checked(out, trigger_str, limit)?;
push_checked(out, "\"", limit)?;
if let Some(ref props_json) = island.inline_props {
push_checked(out, " data-forma-props=\"", limit)?;
push_escaped_attr(out, props_json, limit)?;
push_checked(out, "\"", limit)?;
}
}
if let Some(key) = state.pending_list_key.take() {
push_checked(out, " data-forma-key=\"", limit)?;
push_escaped_attr(out, &key, limit)?;
push_checked(out, "\"", limit)?;
}
state.pending_tag_close = true;
state.pending_tag_name.clear();
state.pending_tag_name.push_str(tag);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextOnlyTag {
Textarea,
Title,
Style,
Script,
}
impl TextOnlyTag {
fn from_tag(tag: &str) -> Option<Self> {
[Self::Textarea, Self::Title, Self::Style, Self::Script]
.into_iter()
.find(|t| tag.eq_ignore_ascii_case(t.name()))
}
fn name(self) -> &'static str {
match self {
TextOnlyTag::Textarea => "textarea",
TextOnlyTag::Title => "title",
TextOnlyTag::Style => "style",
TextOnlyTag::Script => "script",
}
}
}
#[derive(Debug)]
struct TextOnlyParent {
tag: TextOnlyTag,
started: bool,
value_bound: bool,
}
fn write_text_only(
out: &mut String,
parent: &mut TextOnlyParent,
text: &str,
limit: usize,
) -> Result<(), IrError> {
if text.is_empty() {
return Ok(());
}
if parent.tag == TextOnlyTag::Textarea
&& !parent.started
&& (text.starts_with('\n') || text.starts_with('\r'))
{
push_checked(out, "\n", limit)?;
}
parent.started = true;
push_escaped_html(out, text, limit)
}
#[derive(Debug, Default)]
enum PendingControl {
#[default]
None,
Textarea { value: Option<String> },
Select {
value: Option<SlotValue>,
multiple: bool,
},
OptionTag { value: Option<String> },
}
#[derive(Debug)]
struct SelectBinding {
wanted: SelectWanted,
matched: bool,
}
#[derive(Debug)]
enum SelectWanted {
One(String),
Any(Vec<String>),
}
impl SelectBinding {
fn new(value: &SlotValue, multiple: bool) -> Self {
let wanted = match value {
SlotValue::Array(items) if multiple => {
SelectWanted::Any(items.iter().map(js_string).collect())
}
other => SelectWanted::One(other.to_text()),
};
SelectBinding {
wanted,
matched: false,
}
}
fn selects(&mut self, option_value: &str) -> bool {
match &self.wanted {
SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
SelectWanted::One(value) => {
let hit = !self.matched && value == option_value;
self.matched |= hit;
hit
}
}
}
}
fn js_string(value: &SlotValue) -> String {
match value {
SlotValue::Null => "null".to_string(),
other => other.to_text(),
}
}
#[derive(Debug)]
struct OptionByText {
insert_at: usize,
text: String,
}
#[derive(Clone, Copy)]
enum Prop<'a> {
Static(&'a str),
Slot(&'a SlotValue),
}
impl Prop<'_> {
fn control_text(self) -> String {
match self {
Prop::Static(s) => s.to_string(),
Prop::Slot(v) => v.to_text(),
}
}
fn is_written(self) -> bool {
!matches!(
self,
Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
)
}
fn written(self) -> Option<String> {
match self {
_ if !self.is_written() => None,
Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
other => Some(other.control_text()),
}
}
fn to_slot(self) -> SlotValue {
match self {
Prop::Static(s) => SlotValue::Text(s.to_string()),
Prop::Slot(v) => v.clone(),
}
}
}
fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
match &mut state.pending_control {
PendingControl::None => false,
PendingControl::Textarea { value } => {
if key != "value" {
return false;
}
*value = Some(prop.control_text());
true
}
PendingControl::Select { value, multiple } => match key {
"value" => {
*value = Some(prop.to_slot());
true
}
"multiple" => {
*multiple |= prop.is_written();
false
}
_ => false,
},
PendingControl::OptionTag { value } => match key {
"selected" => true,
"value" => {
if value.is_none() {
*value = prop.written();
}
false
}
_ => false,
},
}
}
fn enter_element(
out: &mut String,
tag: &str,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let is = |name: &str| tag.eq_ignore_ascii_case(name);
if is("option") || is("optgroup") {
finish_option_by_text(out, state, limit)?;
}
state.pending_control = if is("textarea") {
PendingControl::Textarea { value: None }
} else if is("select") {
PendingControl::Select {
value: None,
multiple: false,
}
} else if is("option") && state.select.is_some() {
PendingControl::OptionTag { value: None }
} else {
PendingControl::None
};
if state.text_only.is_none() {
state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
tag,
started: false,
value_bound: false,
});
}
Ok(())
}
fn leave_element(
out: &mut String,
tag: &str,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let is = |name: &str| tag.eq_ignore_ascii_case(name);
if is("option") || is("optgroup") || is("select") {
finish_option_by_text(out, state, limit)?;
}
if is("select") {
state.select = None;
}
if state
.text_only
.as_ref()
.is_some_and(|parent| is(parent.tag.name()))
{
state.text_only = None;
}
Ok(())
}
fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
state.pending_tag_close = false;
match std::mem::take(&mut state.pending_control) {
PendingControl::None => {}
PendingControl::Textarea { value } => {
push_checked(out, ">", limit)?;
if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
if parent.tag == TextOnlyTag::Textarea {
parent.value_bound = true;
write_text_only(out, parent, &value, limit)?;
}
}
return Ok(());
}
PendingControl::Select { value, multiple } => {
state.select = value.map(|value| SelectBinding::new(&value, multiple));
}
PendingControl::OptionTag { value: Some(value) } => {
if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
push_checked(out, " selected", limit)?;
}
}
PendingControl::OptionTag { value: None } => {
state.option_by_text = Some(OptionByText {
insert_at: out.len(),
text: String::new(),
});
}
}
push_checked(out, ">", limit)
}
fn finish_option_by_text(
out: &mut String,
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
let Some(option) = state.option_by_text.take() else {
return Ok(());
};
let value = strip_and_collapse_ascii_whitespace(&option.text);
if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
const SELECTED: &str = " selected";
if out.len().saturating_add(SELECTED.len()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.insert_str(option.insert_at, SELECTED);
}
Ok(())
}
fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for word in text.split_ascii_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
if pos + 2 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 2,
actual: data.len(),
});
}
Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
}
pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
if pos + 4 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 4,
actual: data.len(),
});
}
Ok(u32::from_le_bytes([
data[pos],
data[pos + 1],
data[pos + 2],
data[pos + 3],
]))
}
pub(crate) fn read_tag_with_attrs<'a>(
ops: &[u8],
pos: usize,
strings: &'a crate::parser::StringTable,
) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
let tag_str_idx = read_u32(ops, pos)?;
let attr_count = read_u16(ops, pos + 4)? as usize;
let mut cursor = pos + 6;
let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
for _ in 0..attr_count {
let key_idx = read_u32(ops, cursor)?;
let val_idx = read_u32(ops, cursor + 4)?;
let key = strings.get(key_idx)?;
let val = strings.get(val_idx)?;
attrs.push((key, val));
cursor += 8;
}
Ok((tag_str_idx, attrs, cursor))
}
const MAX_ESCAPE_EXPANSION: usize = 6;
fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
if out.len().saturating_add(s.len()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push_str(s);
Ok(())
}
fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
let mut buf = [0u8; 5];
let mut remaining = value;
let mut start = buf.len();
loop {
start -= 1;
buf[start] = b'0' + (remaining % 10) as u8;
remaining /= 10;
if remaining == 0 {
break;
}
}
match core::str::from_utf8(&buf[start..]) {
Ok(digits) => push_checked(out, digits, limit),
Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
}
}
fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in text.chars() {
match ch {
'&' => push_checked(out, "&", limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in text.chars() {
match ch {
'&' => push_checked(out, "&", limit)?,
'"' => push_checked(out, """, limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::test_helpers::{
build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
};
use crate::slot::{SlotData, SlotValue};
fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&marker_id.to_le_bytes());
buf
}
fn encode_island_start(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0B];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_island_end(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0C];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_comment(str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x10];
buf.extend_from_slice(&str_idx.to_le_bytes());
buf
}
fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
let data = build_minimal_ir(strings, &[], opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
walk_to_html(&module, &slots).unwrap()
}
fn walk_with_slots(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn walk_with_islands(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
buf
}
fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
bytes.extend_from_slice(&slot_id.to_le_bytes());
bytes
}
#[test]
fn walk_static_div() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
assert_eq!(html, r#"<div class="container">Hello</div>"#);
}
#[test]
fn walk_void_tag() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "type", "email"], &opcodes);
assert_eq!(html, r#"<input type="email">"#);
}
#[test]
fn walk_nested_elements() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "span", "Hi"], &opcodes);
assert_eq!(html, "<div><span>Hi</span></div>");
}
#[test]
fn walk_text_escaping() {
let opcodes = encode_text(0);
let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
assert_eq!(html, "<script>alert('xss')</script>");
}
#[test]
fn walk_attr_escaping() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let html = walk_static(&["div", "title", "a\"b"], &opcodes);
assert_eq!(html, r#"<div title="a"b">"#);
}
#[test]
fn walk_empty_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div"], &opcodes);
assert_eq!(html, "<div></div>");
}
#[test]
fn walk_dyn_text() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_null_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let slots = SlotData::new(1);
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_empty_string_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(String::new()));
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_escaping() {
let opcodes = encode_dyn_text(0, 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
}
#[test]
fn walk_island_markers() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_text(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Hello"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
}
#[test]
fn walk_comment() {
let opcodes = encode_comment(0);
let html = walk_static(&["This is a comment"], &opcodes);
assert_eq!(html, "<!--This is a comment-->");
}
#[test]
fn walk_boolean_attr() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "disabled", ""], &opcodes);
assert_eq!(html, "<input disabled>");
}
fn walk_dyn_attr_with(value: SlotValue) -> String {
let mut opcodes = encode_void_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, value);
walk_with_slots(
&["input", "disabled"],
&[(0, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
)
}
#[test]
fn dyn_attr_bool_matches_the_client_renderer() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(true)),
"<input disabled>",
"true must render the bare attribute"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(false)),
"<input>",
"false must omit the attribute, not write disabled=\"false\""
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Null),
"<input>",
"null must omit the attribute"
);
}
#[test]
fn dyn_attr_empty_string_is_a_value_not_an_absence() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Text(String::new())),
"<input disabled=\"\">"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Text("yes".into())),
"<input disabled=\"yes\">"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Number(0.0)),
"<input disabled=\"0\">"
);
}
#[test]
fn walk_multiple_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(
&["div", "id", "main", "class", "container", "data-x", "42"],
&opcodes,
);
assert_eq!(
html,
r#"<div id="main" class="container" data-x="42"></div>"#
);
}
#[test]
fn walk_show_if_true_branch() {
let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Yes"), "should contain then-branch text");
assert!(!html.contains("No"), "should NOT contain else-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_false_branch() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("No"), "should contain else-branch text");
assert!(!html.contains("Yes"), "should NOT contain then-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_truthy_text() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["greeting", "Shown", "Hidden"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(
html.contains("Shown"),
"truthy text should render then branch"
);
assert!(
!html.contains("Hidden"),
"truthy text should NOT render else branch"
);
}
#[test]
fn walk_show_if_falsy_null() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["maybe", "Present", "Missing"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Missing"), "null should render else branch");
assert!(
!html.contains("Present"),
"null should NOT render then branch"
);
}
#[test]
fn walk_show_if_no_else() {
let then_ops = encode_text(1);
let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["flag", "Content"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><!--/f:s0-->",
"empty else should produce only markers"
);
}
#[test]
fn walk_show_if_nested() {
let inner_then = encode_text(4);
let inner_else = encode_text(5);
let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
let outer_then = inner_show_if; let outer_else = encode_text(3);
let opcodes = encode_show_if(0, &outer_then, &outer_else);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false));
let html = walk_with_slots(
&[
"outer",
"inner",
"outer-yes",
"outer-no",
"inner-yes",
"inner-no",
],
&[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("inner-no"),
"nested: inner false should render inner else"
);
assert!(
!html.contains("inner-yes"),
"nested: inner true branch should not render"
);
assert!(
!html.contains("outer-no"),
"nested: outer else should not render"
);
assert!(
html.contains("<!--f:s0-->"),
"should have outer opening marker"
);
assert!(
html.contains("<!--/f:s0-->"),
"should have outer closing marker"
);
assert!(
html.contains("<!--f:s1-->"),
"should have inner opening marker"
);
assert!(
html.contains("<!--/f:s1-->"),
"should have inner closing marker"
);
}
#[test]
fn walk_show_if_with_elements() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[]));
then_ops.extend_from_slice(&encode_text(2));
then_ops.extend_from_slice(&encode_close_tag(1));
let else_ops = encode_text(3);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["flag", "span", "Hello", "Bye"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
"then branch should produce full HTML element"
);
}
#[test]
fn walk_list_renders_items() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".to_string()),
SlotValue::Text("Bob".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain first item");
assert!(html.contains("Bob"), "should contain second item");
assert!(
html.starts_with("<!--f:l0-->"),
"should start with list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should end with list close marker"
);
}
#[test]
fn walk_list_empty_array() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::array(vec![]));
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"empty array should produce only markers"
);
}
#[test]
fn walk_list_with_elements() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_dyn_text(1, 0));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("A".to_string()),
SlotValue::Text("B".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
"should render li elements with content, got: {html}"
);
}
#[test]
fn walk_list_null_slot() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let slots = SlotData::new(2);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"null slot should produce only markers"
);
}
#[test]
fn walk_list_nested() {
let inner_body = encode_dyn_text(2, 0);
let inner_list = encode_list(1, 2, &inner_body);
let opcodes = encode_list(0, 1, &inner_list);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Text("b".to_string()),
]),
SlotValue::array(vec![SlotValue::Text("c".to_string())]),
]),
);
let html = walk_with_slots(
&["outer", "inner", "item"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains("a"), "should contain nested item 'a'");
assert!(html.contains("b"), "should contain nested item 'b'");
assert!(html.contains("c"), "should contain nested item 'c'");
assert!(
html.starts_with("<!--f:l0-->"),
"should have outer list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should have outer list close marker"
);
assert!(
html.contains("<!--f:l1-->"),
"should have inner list open marker"
);
assert!(
html.contains("<!--/f:l1-->"),
"should have inner list close marker"
);
}
#[test]
fn walk_list_depth_exceeded() {
let innermost_body = encode_dyn_text(5, 0);
let level5 = encode_list(4, 5, &innermost_body);
let level4 = encode_list(3, 4, &level5);
let level3 = encode_list(2, 3, &level4);
let level2 = encode_list(1, 2, &level3);
let level1 = encode_list(0, 1, &level2);
let mut slots = SlotData::new(6);
slots.set(
0,
SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
"deep".to_string(),
)])]),
])])]),
);
let data = build_minimal_ir(
&["s0", "s1", "s2", "s3", "s4", "s5"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x04, 0x00, &[]),
(3, 3, 0x04, 0x00, &[]),
(4, 4, 0x04, 0x00, &[]),
(5, 5, 0x01, 0x00, &[]),
],
&level1,
&[],
);
let module = IrModule::parse(&data).unwrap();
let result = walk_to_html(&module, &slots);
assert!(result.is_err(), "should fail with depth exceeded");
match result.unwrap_err() {
IrError::ListDepthExceeded { max: 4 } => {}
other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
}
}
#[test]
fn walk_switch_matching_case() {
let case_home_body = encode_text(2); let case_about_body = encode_text(4); let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("About"), "should contain matching case text");
assert!(
!html.contains("Home"),
"should NOT contain non-matching case text"
);
}
#[test]
fn walk_switch_no_match() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("missing".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.is_empty(),
"no match should produce empty output, got: {html}"
);
}
#[test]
fn walk_switch_first_case() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("home".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Home"), "should contain first case text");
assert!(
!html.contains("About"),
"should NOT contain second case text"
);
}
#[test]
fn walk_switch_stops_at_the_first_match() {
let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("home".to_string()));
let html = walk_with_slots(
&["page", "home", "FIRST", "SECOND"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "FIRST");
}
#[test]
fn walk_switch_with_elements() {
let mut active_body = Vec::new();
active_body.extend_from_slice(&encode_open_tag(2, &[]));
active_body.extend_from_slice(&encode_text(3));
active_body.extend_from_slice(&encode_close_tag(2));
let inactive_body = encode_text(5);
let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("active".to_string()));
let html = walk_with_slots(
&["page", "active", "span", "Active", "inactive", "Inactive"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<span>Active</span>",
"should produce full HTML element from matching case"
);
}
#[test]
fn walk_try_renders_main() {
let main_ops = encode_text(0);
let fallback_ops = encode_text(1);
let opcodes = encode_try(&main_ops, &fallback_ops);
let html = walk_static(&["Main", "Fallback"], &opcodes);
assert!(html.contains("Main"), "should contain main body text");
assert!(
!html.contains("Fallback"),
"should NOT contain fallback text"
);
}
#[test]
fn walk_preload_font() {
let opcodes = encode_preload(1, 0);
let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
assert!(
html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
"should produce preload link for font, got: {html}"
);
}
#[test]
fn walk_preload_style() {
let opcodes = encode_preload(2, 0);
let html = walk_static(&["/_assets/styles.css"], &opcodes);
assert!(
html.contains(r#"as="style""#),
"should contain as=\"style\", got: {html}"
);
assert!(
html.contains(r#"href="/_assets/styles.css""#),
"should contain correct href, got: {html}"
);
}
#[test]
fn roundtrip_page_structure() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_open_tag(3, &[])); opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); opcodes.extend_from_slice(&encode_open_tag(7, &[])); opcodes.extend_from_slice(&encode_text(8)); opcodes.extend_from_slice(&encode_close_tag(7)); opcodes.extend_from_slice(&encode_close_tag(3)); opcodes.extend_from_slice(&encode_open_tag(9, &[])); opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); opcodes.extend_from_slice(&encode_close_tag(10)); opcodes.extend_from_slice(&encode_close_tag(9)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
"div", "id", "app",
],
&[],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
);
}
#[test]
fn roundtrip_dynamic_greeting() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_dyn_text(0, 0)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&["div", "class", "greeting", "content"],
&[(0, 3, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Hello, World".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
);
}
#[test]
fn roundtrip_conditional_content() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
then_ops.extend_from_slice(&encode_text(4));
then_ops.extend_from_slice(&encode_close_tag(1));
let mut else_ops = Vec::new();
else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
else_ops.extend_from_slice(&encode_text(5));
else_ops.extend_from_slice(&encode_close_tag(1));
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let ir = build_minimal_ir(
&["auth", "span", "class", "badge", "Logged In", "Guest"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&[],
);
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
);
}
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
);
}
}
#[test]
fn roundtrip_list_rendering() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[])); body.extend_from_slice(&encode_dyn_text(1, 0)); body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let ir = build_minimal_ir(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".into()),
SlotValue::Text("Bob".into()),
SlotValue::Text("Charlie".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
"<!--f:l0-->\
<li><!--f:t0-->Alice<!--/f:t0--></li>\
<li><!--f:t0-->Bob<!--/f:t0--></li>\
<li><!--f:t0-->Charlie<!--/f:t0--></li>\
<!--/f:l0-->"
);
}
#[test]
fn roundtrip_switch_view() {
let mut home_body = Vec::new();
home_body.extend_from_slice(&encode_open_tag(2, &[]));
home_body.extend_from_slice(&encode_text(3));
home_body.extend_from_slice(&encode_close_tag(2));
let mut about_body = Vec::new();
about_body.extend_from_slice(&encode_open_tag(2, &[]));
about_body.extend_from_slice(&encode_text(5));
about_body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
let ir = build_minimal_ir(
&["page", "home", "h1", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<h1>About</h1>");
}
#[test]
fn roundtrip_nested_structure() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(4, &[]));
then_ops.extend_from_slice(&encode_text(5));
then_ops.extend_from_slice(&encode_close_tag(4));
let show_if_ops = encode_show_if(0, &then_ops, &[]);
let mut list_body = Vec::new();
list_body.extend_from_slice(&encode_open_tag(9, &[]));
list_body.extend_from_slice(&encode_dyn_text(2, 0));
list_body.extend_from_slice(&encode_close_tag(9));
let list_ops = encode_list(1, 2, &list_body);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&show_if_ops); opcodes.extend_from_slice(&encode_open_tag(8, &[])); opcodes.extend_from_slice(&list_ops); opcodes.extend_from_slice(&encode_close_tag(8)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
],
&[
(0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Bool(true));
slots.set(
1,
SlotValue::array(vec![
SlotValue::Text("Home".into()),
SlotValue::Text("About".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
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>"#
);
}
#[test]
fn walk_island_attrs_on_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Hello", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 2, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_with_existing_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "class", "form", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 3, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
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-->"#
);
}
#[test]
fn walk_island_attrs_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["input", "Widget"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
);
}
#[test]
fn walk_inline_props_attribute() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("Hello".to_string()));
slots.set(1, SlotValue::Number(42.0));
let html = walk_with_islands(
&["div", "MyComponent", "title", "count"],
&[
(0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(
html.contains("data-forma-props="),
"should contain data-forma-props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id, got: {html}"
);
assert!(
html.contains(r#"data-forma-component="MyComponent""#),
"should contain component name, got: {html}"
);
let marker = "data-forma-props=\"";
let props_start = html.find(marker).unwrap() + marker.len();
let props_end = html[props_start..].find('"').unwrap() + props_start;
let raw_attr = &html[props_start..props_end];
let decoded = raw_attr
.replace(""", "\"")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
assert_eq!(props_json["title"], "Hello");
assert_eq!(props_json["count"], 42);
}
#[test]
fn walk_list_item_key_on_first_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "user-123", "item"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
}
#[test]
fn walk_list_item_key_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
let html = walk_static(&["hr", "key-42"], &opcodes);
assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
}
#[test]
fn walk_list_item_key_with_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(3));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(4));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
}
#[test]
fn walk_script_tag_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Click me".to_string()));
let html = walk_with_islands(
&["div", "Counter", "label"],
&[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"ScriptTag mode should not emit inline props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"<script id="__forma_islands" type="application/json">"#),
"should contain script tag, got: {html}"
);
assert!(html.contains("</script>"), "should close script tag");
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["label"], "Click me");
}
#[test]
fn walk_deferred_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("ignored".to_string()));
let html = walk_with_islands(
&["div", "LazyWidget", "data"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x03, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"Deferred mode should not emit inline props, got: {html}"
);
assert!(
!html.contains("__forma_islands"),
"Deferred mode should not emit script tag, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"data-forma-component="LazyWidget""#),
"should contain component name"
);
}
#[test]
fn walk_island_no_entry_in_table() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(5));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(5));
let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownIslandId(5)
);
}
#[test]
fn walk_nested_island_start_before_a_root_element_is_an_error() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let data = build_minimal_ir(
&["div", "Outer", "Inner"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &slots).unwrap_err(),
IrError::NestedIslandRoot { outer: 0, inner: 1 }
);
}
#[test]
fn walk_island_nested_inside_another_islands_root_is_fine() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
let html = walk_with_islands(
&["div", "Outer", "Inner"],
&[],
&[
(0, 0x01, 0x01, 1, 0, &[]),
(1, 0x01, 0x01, 2, inner_offset, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains(r#"data-forma-component="Outer""#));
assert!(html.contains(r#"data-forma-component="Inner""#));
}
#[test]
fn walk_inline_props_empty_slots() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Simple"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"empty slot_ids should not emit props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
}
fn walk_island_with_prop_payload(payload: &str) -> String {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
walk_with_islands(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0])],
&opcodes,
&slots,
)
}
#[test]
fn island_props_keep_the_first_slot_for_a_duplicated_name() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("first".into()));
slots.set(1, SlotValue::Text("second".into()));
let html = walk_with_islands(
&["div", "Comp", "dup"],
&[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(
html.contains(r#"data-forma-props="{"dup":"first"}""#),
"expected the first slot to win, got: {html}"
);
}
#[test]
fn inline_props_under_the_ceiling_stay_inline() {
let html = walk_island_with_prop_payload("small");
assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
assert!(!html.contains("__forma_islands"));
}
#[test]
fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
let html = walk_island_with_prop_payload(&payload);
assert!(
!html.contains("data-forma-props"),
"over-size props must not stay in the attribute"
);
assert!(
html.contains(r#"<script id="__forma_islands" type="application/json">"#),
"over-size props must land in the shared block, got: {}",
&html[..html.len().min(200)]
);
assert!(html.contains(&payload), "the payload itself must survive");
}
#[test]
fn island_fragment_keeps_over_size_props_inline() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload));
let data = build_minimal_ir(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[0])],
);
let module = IrModule::parse(&data).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(fragment.contains("data-forma-props"));
assert!(!fragment.contains("__forma_islands"));
}
#[test]
fn walk_island_and_list_key_combined() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Card", "item-1"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island attrs"
);
assert!(
html.contains(r#"data-forma-key="item-1""#),
"should contain list key attr, got: {html}"
);
}
#[test]
fn walk_multiple_islands_script_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let island1_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
slots.set(1, SlotValue::Bool(true));
let html = walk_with_islands(
&["div", "span", "CompA", "CompB", "msg", "flag"],
&[
(0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
&[
(0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, island1_offset, &[1]), ],
&opcodes,
&slots,
);
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["msg"], "hello");
assert_eq!(json["1"]["flag"], true);
}
#[test]
fn walk_list_item_key_consumed_once() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
}
#[test]
fn slot_value_to_json_all_types() {
assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
assert_eq!(
SlotValue::Text("hello".to_string()).to_json(),
serde_json::Value::String("hello".to_string())
);
assert_eq!(
SlotValue::Bool(true).to_json(),
serde_json::Value::Bool(true)
);
assert_eq!(
SlotValue::Bool(false).to_json(),
serde_json::Value::Bool(false)
);
assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
assert_eq!(
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Number(1.0)
])
.to_json(),
serde_json::json!(["a", 1])
);
assert_eq!(
SlotValue::object(vec![(
"key".to_string(),
SlotValue::Text("val".to_string())
),])
.to_json(),
serde_json::json!({"key": "val"})
);
}
#[test]
fn dyn_attr_emits_attribute_with_value() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("password".to_string()));
let html = walk_with_slots(
&["input", "type", "input_type"],
&[(0, 2, 0x01, 0x01, b"password")], &opcodes,
&slots,
);
assert_eq!(html, r#"<input type="password"></input>"#);
}
#[test]
fn dyn_attr_skips_when_slot_null() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let slots = SlotData::new(1);
let html = walk_with_slots(
&["div", "class", "cls"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<div></div>");
}
#[test]
fn dyn_attr_multiple_on_same_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("email".to_string()));
slots.set(1, SlotValue::Text("form-input".to_string()));
let html = walk_with_slots(
&["input", "type", "class", "slot_type", "slot_class"],
&[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
}
#[test]
fn dyn_attr_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("checkbox".to_string()));
let html = walk_with_slots(
&["input", "type", "slot_type"],
&[(0, 2, 0x01, 0x01, b"text")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="checkbox">"#);
}
#[test]
fn walk_island_basic() {
let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
let close_p = encode_close_tag(2);
let island_end = encode_island_end(0);
let close_div = encode_close_tag(1);
let byte_offset = pre_island.len() as u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&pre_island);
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_p);
opcodes.extend_from_slice(&text_hello);
opcodes.extend_from_slice(&close_p);
opcodes.extend_from_slice(&island_end);
opcodes.extend_from_slice(&close_div);
let data = build_minimal_ir(
&["MyIsland", "div", "p", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[])], );
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
);
assert!(!html.contains("<!--f:i0-->"));
assert!(!html.contains("<!--/f:i0-->"));
}
#[test]
fn walk_island_attrs_match_walk_to_html() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]);
let dyn_text = encode_dyn_text(0, 0);
let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x02, 0x01, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let page = walk_to_html(&module, &slots).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
for attr in [
"data-forma-island=\"0\"",
"data-forma-component=\"IslandComp\"",
"data-forma-status=\"pending\"",
"data-forma-hydrate=\"visible\"",
"data-forma-props=",
] {
assert!(page.contains(attr), "page missing {attr}: {page}");
assert!(
fragment.contains(attr),
"fragment missing {attr}: {fragment}"
);
}
}
#[test]
fn walk_island_script_tag_mode_inlines_props_in_fragment() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x02, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
fragment.contains("data-forma-props="),
"ScriptTag island fragment must inline its props: {fragment}"
);
assert!(
!fragment.contains("__forma_islands"),
"fragment must not emit a second islands block: {fragment}"
);
}
#[test]
fn walk_island_nested_script_tag_island_keeps_its_props() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
let inner_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["Outer", "div", "greeting", "Inner"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[
(0, 0x01, 0x01, 0, 0, &[]),
(1, 0x01, 0x02, 3, inner_offset, &[0]), ],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".into()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
fragment.contains("data-forma-component=\"Inner\""),
"nested island must render: {fragment}"
);
assert!(
fragment.contains("data-forma-props="),
"nested ScriptTag island must keep its props inline: {fragment}"
);
assert!(!fragment.contains("__forma_islands"), "{fragment}");
}
#[test]
fn walk_island_not_found() {
let opcodes = encode_text(0);
let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let result = walk_island(&module, &slots, 5);
assert!(result.is_err());
match result.unwrap_err() {
IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
}
}
#[test]
fn walk_island_with_dyn_text() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let byte_offset = 0u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])], &opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
data-forma-props=\"{"greeting":"World"}\">\
<!--f:t0-->World<!--/f:t0--></div>"
);
}
#[test]
fn walk_island_byte_offset_out_of_bounds() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["BadIsland", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, 9999, &[])], );
let err = IrModule::parse(&data).unwrap_err();
assert_eq!(
err,
IrError::IslandOffsetMismatch {
island_id: 0,
byte_offset: 9999
}
);
}
fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
buf.extend_from_slice(&prop_str_idx.to_le_bytes());
buf.extend_from_slice(&target_slot_id.to_le_bytes());
buf
}
#[test]
fn walk_prop_in_list_body() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_dyn_text(2, 0)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Bob".to_string()),
)]),
]),
);
let html = walk_with_slots(
&["items", "item", "name"],
&[
(0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
&opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain Alice, got: {html}");
assert!(html.contains("Bob"), "should contain Bob, got: {html}");
}
#[test]
fn walk_prop_multiple_properties() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_prop(1, 3, 3)); body.extend_from_slice(&encode_open_tag(4, &[])); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(2, 0)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(3, 1)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_close_tag(4)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(4);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Alice".to_string())),
(
"email".to_string(),
SlotValue::Text("alice@test.com".to_string()),
),
]),
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Bob".to_string())),
(
"email".to_string(),
SlotValue::Text("bob@test.com".to_string()),
),
]),
]),
);
let html = walk_with_slots(
&["rows", "row", "name", "email", "tr", "td"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x05, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
(3, 3, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(
html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
"should contain Alice td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
"should contain alice email, got: {html}"
);
assert!(
html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
"should contain Bob td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
"should contain bob email, got: {html}"
);
}
#[test]
fn walk_prop_on_non_object() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 1, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["src", "name"],
&[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"non-object PROP should produce empty text, got: {html}"
);
}
#[test]
fn walk_prop_missing_property() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 2, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
);
let html = walk_with_slots(
&["src", "target", "missing"],
&[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"missing property should produce empty text, got: {html}"
);
}
#[test]
fn walk_nested_show_if_within_limit() {
let inner = encode_text(5);
let level4 = encode_show_if(4, &inner, &[]);
let level3 = encode_show_if(3, &level4, &[]);
let level2 = encode_show_if(2, &level3, &[]);
let level1 = encode_show_if(1, &level2, &[]);
let level0 = encode_show_if(0, &level1, &[]);
let mut slots = SlotData::new(5);
for i in 0..5 {
slots.set(i, SlotValue::Bool(true));
}
let html = walk_with_slots(
&["s0", "s1", "s2", "s3", "s4", "deep"],
&[
(0, 0, 0x02, 0x00, &[]),
(1, 1, 0x02, 0x00, &[]),
(2, 2, 0x02, 0x00, &[]),
(3, 3, 0x02, 0x00, &[]),
(4, 4, 0x02, 0x00, &[]),
],
&level0,
&slots,
);
assert!(
html.contains("deep"),
"5 levels of nesting should succeed, got: {html}"
);
}
fn script_tag_props_html(payload: &str) -> (String, String) {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_with_islands(
&["div", "Comp", "payload"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
const OPEN: &str = "type=\"application/json\">";
let start = html.find(OPEN).expect("islands script block") + OPEN.len();
let end = html[start..].find("</script>").expect("script close") + start;
let body = html[start..end].to_string();
(html, body)
}
#[test]
fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
let (_html, body) = script_tag_props_html("</script>alert(1)");
assert!(
!body.contains('<') && !body.contains('>'),
"islands block must contain no raw angle brackets, got: {body}"
);
assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
}
#[test]
fn walk_script_tag_props_escapes_comment_script_breakout() {
let payload = "<!--<script>PAYLOAD";
let (html, body) = script_tag_props_html(payload);
assert!(
!html.contains("<!--<script"),
"must not emit the script-data-double-escape trigger, got: {html}"
);
assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["0"]["payload"], payload);
}
#[test]
#[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
fn walk_real_benchmark_ir() {
let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
let ir_path = std::fs::read_dir(&dist_dir)
.expect("admin/dist must exist")
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with("platform-benchmark.") && s.ends_with(".ir")
})
.map(|e| e.path())
.expect("platform-benchmark.*.ir must exist in admin/dist");
let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
let module = IrModule::parse(&data).expect("IR must parse");
let slots = SlotData::new_from_defaults(&module.slots);
let html = walk_to_html(&module, &slots).expect("walk must succeed");
assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
assert!(
html.contains("data-forma-island=\"0\""),
"missing island 0 attr"
);
assert!(
html.contains("Search"),
"FilterBar must contain 'Search' label text in SSR"
);
assert!(
html.contains("filter-group"),
"FilterBar must contain filter-group divs in SSR"
);
assert!(
html.contains("Sort by"),
"FilterBar must contain 'Sort by' label text in SSR"
);
assert!(
html.contains("Performance"),
"PerfPanel must contain 'Performance' heading in SSR"
);
assert!(
html.contains("benchmark-data-table"),
"BenchmarkDataTable root class must exist"
);
let start = html
.find("<!--f:i0-->")
.expect("FilterBar island start marker");
let end = html
.find("<!--/f:i0-->")
.expect("FilterBar island end marker");
assert!(end > start, "island end marker must follow its start");
}
#[test]
fn walk_output_limit_trips_inside_one_opcode() {
let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
let opcodes = encode_open_tag(0, &attrs);
let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let limits = WalkLimits {
max_output_bytes: 64 * 1024,
..WalkLimits::default()
};
let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
let ok = WalkLimits {
max_output_bytes: 64 * 1024 * 1024,
..WalkLimits::default()
};
assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
}
#[test]
fn walk_output_limit_trips_on_a_single_dyn_text_value() {
let opcodes = encode_dyn_text(0, 0);
let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".repeat(100_000)));
let limits = WalkLimits {
max_output_bytes: 4096,
..WalkLimits::default()
};
assert!(matches!(
walk_to_html_with_limits(&module, &slots, limits),
Err(IrError::OutputLimitExceeded { .. })
));
}
#[test]
fn walk_list_budget_exceeded() {
let body = encode_text(2);
let inner = encode_list(0, 1, &body);
let opcodes = encode_list(0, 1, &inner);
let data = build_minimal_ir(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
);
let err = walk_to_html(&module, &slots).unwrap_err();
assert_eq!(
err,
IrError::ListBudgetExceeded {
max: DEFAULT_MAX_LIST_ITERATIONS
}
);
assert_ne!(
err,
IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH
}
);
}
fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
#[test]
fn parse_rejects_show_if_then_len_past_the_stream() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_list_body_len_past_the_stream() {
let mut opcodes = encode_list(0, 1, &encode_text(2));
patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
let data = build_minimal_ir(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_switch_case_body_len_past_the_stream() {
let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
let data = build_minimal_ir(
&["mode", "a", "x", "b"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_fallback_skip_past_the_stream() {
let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_accepts_zero_length_else_branch_known_residual() {
let opcodes = encode_show_if(0, &encode_text(1), &[]);
let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(IrModule::parse(&data).is_ok());
}
#[test]
fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
let then_ops = vec![0x0E]; let mut main_ops = encode_show_if(0, &then_ops, &[]);
main_ops.extend_from_slice(&encode_text(1));
let opcodes = encode_try(&main_ops, &encode_text(2));
let data = build_minimal_ir(
&["flag", "main", "fb"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data).unwrap_err(),
IrError::MalformedControlFlow { .. }
));
}
#[test]
fn walk_try_without_a_fallback_is_rejected_at_load() {
let mut opcodes = vec![0x0D]; opcodes.extend_from_slice(&4u32.to_le_bytes()); opcodes.extend_from_slice(&encode_text(0));
let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data).unwrap_err(),
IrError::MalformedControlFlow { .. }
));
}
#[test]
fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(1);
let html = walk_with_islands(
&["Comp", "div", "flag"],
&[(0, 2, 0x02, 0x00, &[])],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<div data-forma-island=\"0\""),
"island root must keep its attributes, got: {html}"
);
}
#[test]
fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
let mut body = encode_list_item_key(3);
body.extend_from_slice(&encode_show_if(1, &[], &[]));
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("a".into()),
SlotValue::Text("b".into()),
]),
);
let html = walk_with_slots(
&["items", "item", "li", "k"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html.matches("data-forma-key=\"k\"").count(),
2,
"every item element must keep its key, got: {html}"
);
}
#[test]
fn walk_island_attrs_do_not_leak_past_the_island_end() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_island_end(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Comp", "div"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-island"),
"island attributes must not leak onto an element outside the island, got: {html}"
);
}
#[test]
fn walk_list_key_does_not_leak_past_the_list() {
let body = encode_list_item_key(2); let mut opcodes = encode_list(0, 1, &body);
opcodes.extend_from_slice(&encode_open_tag(3, &[]));
opcodes.extend_from_slice(&encode_close_tag(3));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
let html = walk_with_slots(
&["items", "item", "k", "div"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-key"),
"list key must not leak onto an element after the list, got: {html}"
);
}
#[test]
fn walk_rejects_hostile_tag_name() {
let opcodes = encode_open_tag(0, &[]);
let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_rejects_hostile_close_tag_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_close_tag(1));
let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_drops_hostile_attribute_names() {
let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<div ok-attr=\"v\">");
}
#[test]
fn walk_drops_hostile_boolean_attribute_name() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_hostile_dyn_attr_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["div", "z\"><script", "val"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_dyn_attr() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
" javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
}
}
#[test]
fn walk_keeps_safe_urls_and_data_images() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["img", "src", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"/about",
"https://example.com/x?a=1",
"data:image/png;base64,AAAA",
"data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert!(html.contains("src="), "payload {payload:?} was dropped");
}
}
#[test]
fn walk_blocks_svg_data_url_outside_image_context() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(
0,
SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
}
#[test]
fn walk_drops_event_handler_attributes() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &SlotData::new(0)).unwrap(),
"<div>",
"static on* attribute must be dropped"
);
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["div", "ONCLICK", "h"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("alert(1)".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_static_attr() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
}
#[test]
fn walk_comment_cannot_self_close() {
for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
let opcodes = encode_comment(0);
let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
assert!(
html.starts_with("<!-- "),
"comment must not start with a closing sequence, got: {html}"
);
}
}
#[test]
fn walk_switch_matches_on_js_number_text() {
let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
let data = build_minimal_ir(
&["n", "1e+21", "big", "1000", "small"],
&[(0, 0, 0x03, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Number(1e21));
assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Number(1000.0));
assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
}
struct Page {
strings: Vec<String>,
ops: Vec<u8>,
slots: Vec<(u8, SlotValue)>,
islands: Vec<(u16, u32, u32)>,
}
impl Page {
fn new() -> Self {
Page {
strings: Vec::new(),
ops: Vec::new(),
slots: Vec::new(),
islands: Vec::new(),
}
}
fn intern(&mut self, s: &str) -> u32 {
let index = match self.strings.iter().position(|x| x == s) {
Some(i) => i,
None => {
self.strings.push(s.to_string());
self.strings.len() - 1
}
};
u32::try_from(index).unwrap()
}
fn slot(&mut self, value: SlotValue) -> u16 {
let type_byte = match &value {
SlotValue::Null | SlotValue::Text(_) => 0x01,
SlotValue::Bool(_) => 0x02,
SlotValue::Number(_) => 0x03,
SlotValue::Array(_) => 0x04,
SlotValue::Object(_) => 0x05,
};
self.slots.push((type_byte, value));
u16::try_from(self.slots.len() - 1).unwrap()
}
fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
let tag = self.intern(tag);
let attrs: Vec<(u32, u32)> = attrs
.iter()
.map(|(k, v)| (self.intern(k), self.intern(v)))
.collect();
self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
self
}
fn close(&mut self, tag: &str) -> &mut Self {
let tag = self.intern(tag);
self.ops.extend_from_slice(&encode_close_tag(tag));
self
}
fn text(&mut self, text: &str) -> &mut Self {
let text = self.intern(text);
self.ops.extend_from_slice(&encode_text(text));
self
}
fn dyn_text(&mut self, slot: u16) -> &mut Self {
self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
self
}
fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
let name = self.intern(name);
self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
self
}
fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
let name = self.intern(name);
self.ops
.extend_from_slice(&encode_prop(source, name, target));
self
}
fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
let outer = std::mem::take(&mut self.ops);
f(self);
std::mem::replace(&mut self.ops, outer)
}
fn show_if(
&mut self,
slot: u16,
then_ops: impl FnOnce(&mut Self),
else_ops: impl FnOnce(&mut Self),
) -> &mut Self {
let then_ops = self.body(then_ops);
let else_ops = self.body(else_ops);
self.ops
.extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
self
}
fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
let body = self.body(body);
self.ops.extend_from_slice(&encode_list(slot, item, &body));
self
}
fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
let name = self.intern(&format!("Island{id}"));
let offset = u32::try_from(self.ops.len()).unwrap();
self.islands.push((id, name, offset));
self.ops.extend_from_slice(&encode_island_start(id));
body(self);
self.ops.extend_from_slice(&encode_island_end(id));
self
}
fn module_and_slots(&self) -> (IrModule, SlotData) {
let mut strings = self.strings.clone();
let mut decls = Vec::new();
for (id, (type_byte, _)) in self.slots.iter().enumerate() {
strings.push(format!("slot{id}"));
let name = u32::try_from(strings.len() - 1).unwrap();
decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
}
let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
.islands
.iter()
.map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
.collect();
let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
let data = build_minimal_ir(&strings, &decls, &self.ops, &islands);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(self.slots.len());
for (id, (_, value)) in self.slots.iter().enumerate() {
slots.set(u16::try_from(id).unwrap(), value.clone());
}
(module, slots)
}
fn render(&self) -> String {
let (module, slots) = self.module_and_slots();
walk_to_html(&module, &slots).unwrap()
}
fn render_island(&self, id: u16) -> String {
let (module, slots) = self.module_and_slots();
walk_island(&module, &slots, id).unwrap()
}
}
fn text(s: &str) -> SlotValue {
SlotValue::Text(s.to_string())
}
#[test]
fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
let mut page = Page::new();
let inside = page.slot(text("a<b&c"));
let outside = page.slot(text("world"));
page.open("div", &[])
.open(parent, &[])
.dyn_text(inside)
.close(parent)
.open("p", &[])
.dyn_text(outside)
.close("p")
.close("div");
assert_eq!(
page.render(),
format!(
"<div><{parent}>a<b&c</{parent}>\
<p><!--f:t1-->world<!--/f:t1--></p></div>"
),
"inside <{parent}>"
);
}
}
#[test]
fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
let mut page = Page::new();
let value = page.slot(text("hello"));
page.open(parent, &[]).dyn_text(value).close(parent);
assert_eq!(
page.render(),
format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
"inside <{parent}>"
);
}
}
#[test]
fn an_empty_value_in_a_text_only_parent_writes_nothing() {
let mut page = Page::new();
let inside = page.slot(text(""));
let outside = page.slot(text(""));
page.open("textarea", &[])
.dyn_text(inside)
.close("textarea")
.open("p", &[])
.dyn_text(outside)
.close("p");
assert_eq!(
page.render(),
"<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
);
}
#[test]
fn a_textarea_doubles_a_leading_line_feed() {
fn textarea(build: impl FnOnce(&mut Page)) -> String {
let mut page = Page::new();
page.open("textarea", &[]);
build(&mut page);
page.close("textarea");
page.render()
}
let cases: [(&str, String, &str); 5] = [
(
"a dynamic value",
textarea(|p| {
let v = p.slot(text("\nhello"));
p.dyn_text(v);
}),
"<textarea>\n\nhello</textarea>",
),
(
"a CRLF value",
textarea(|p| {
let v = p.slot(text("\r\nhello"));
p.dyn_text(v);
}),
"<textarea>\n\r\nhello</textarea>",
),
(
"an empty value first: the first written byte decides",
textarea(|p| {
let empty = p.slot(text(""));
let v = p.slot(text("\nx"));
p.dyn_text(empty).dyn_text(v);
}),
"<textarea>\n\nx</textarea>",
),
(
"an LF after other content is kept as is",
textarea(|p| {
let v = p.slot(text("\nb"));
p.text("a").dyn_text(v);
}),
"<textarea>a\nb</textarea>",
),
(
"static text",
textarea(|p| {
p.text("\nstatic");
}),
"<textarea>\n\nstatic</textarea>",
),
];
for (case, html, expected) in cases {
assert_eq!(html, expected, "{case}");
}
let mut page = Page::new();
let v = page.slot(text("\nhello"));
page.open("title", &[]).dyn_text(v).close("title");
assert_eq!(page.render(), "<title>\nhello</title>");
}
#[test]
fn no_marker_kind_is_written_inside_a_text_only_parent() {
let mut page = Page::new();
let loading = page.slot(SlotValue::Bool(false));
let name = page.slot(text("Ada"));
let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
let tag = page.slot(SlotValue::Null);
page.open("title", &[])
.show_if(
loading,
|p| {
p.text("Loading");
},
|p| {
p.dyn_text(name);
},
)
.text(" | ")
.list(tags, tag, |p| {
p.dyn_text(tag).text(";");
})
.close("title")
.open("p", &[])
.show_if(
loading,
|p| {
p.text("Loading");
},
|p| {
p.dyn_text(name);
},
)
.close("p");
assert_eq!(
page.render(),
"<title>Ada | x;y;</title>\
<p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
);
let mut page = Page::new();
page.open("textarea", &[])
.island(0, |p| {
p.text("x");
})
.close("textarea")
.island(1, |p| {
p.open("div", &[]).close("div");
});
assert_eq!(
page.render(),
"<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
data-forma-component=\"Island1\" data-forma-status=\"pending\" \
data-forma-hydrate=\"load\"></div><!--/f:i1-->"
);
}
#[test]
fn a_textarea_value_binding_is_written_as_its_text() {
let cases = [
(text("hello"), "hello"),
(
text("</textarea><script>x</script>"),
"</textarea><script>x</script>",
),
(text("\nhello"), "\n\nhello"),
(SlotValue::Null, ""),
(SlotValue::Number(0.0), "0"),
(SlotValue::Bool(false), "false"),
];
for (value, inner) in cases {
let mut page = Page::new();
let v = page.slot(value.clone());
let note = page.slot(text("note"));
page.open("textarea", &[("name", "t")])
.dyn_attr("value", v)
.close("textarea")
.open("p", &[])
.dyn_text(note)
.close("p");
assert_eq!(
page.render(),
format!(
"<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
),
"value {value:?}"
);
}
let mut page = Page::new();
page.open("textarea", &[("name", "t"), ("value", "hello")])
.close("textarea");
assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
}
#[test]
fn a_textarea_value_binding_wins_over_its_children() {
let mut page = Page::new();
let v = page.slot(text("bound"));
let child = page.slot(text("child"));
page.open("textarea", &[])
.dyn_attr("value", v)
.dyn_text(child)
.text(" default")
.close("textarea")
.open("p", &[])
.dyn_text(child)
.close("p");
assert_eq!(
page.render(),
"<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
);
}
fn select_page(value: SlotValue) -> String {
let mut page = Page::new();
let v = page.slot(value);
page.open("select", &[("name", "c")]).dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
page.render()
}
fn option(value: &str, label: &str, selected: bool) -> String {
let selected = if selected { " selected" } else { "" };
format!("<option value=\"{value}\"{selected}>{label}</option>")
}
#[test]
fn a_select_value_binding_selects_the_matching_option() {
for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
let expected = format!(
"<select name=\"c\">{}{}{}</select>",
option("a", "Apple", chosen == "a"),
option("b", "Banana", chosen == "b"),
option("c", "Cherry", chosen == "c"),
);
assert_eq!(select_page(text(value)), expected, "value {value}");
}
}
#[test]
fn a_select_value_binding_compares_as_the_client_string() {
for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
let mut page = Page::new();
let v = page.slot(value.clone());
page.open("select", &[]).dyn_attr("value", v);
for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
let placeholder = if chosen.is_empty() {
"<option value selected>Choose</option>"
} else {
"<option value>Choose</option>"
};
let expected = format!(
"<select>{placeholder}{}{}</select>",
option("1", "One", chosen == "1"),
option("2", "Two", chosen == "2"),
);
assert_eq!(page.render(), expected, "value {value:?}");
}
}
#[test]
fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
let render = |value: &str| {
let mut page = Page::new();
let v = page.slot(text(value));
let label = page.slot(text("Blue"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("Red")
.close("option")
.open("option", &[])
.text(" Dark \n green ")
.close("option")
.open("option", &[])
.dyn_text(label)
.close("option")
.close("select");
page.render()
};
assert_eq!(
render("Dark green"),
"<select><option>Red</option><option selected> Dark \n green </option>\
<option><!--f:t1-->Blue<!--/f:t1--></option></select>"
);
assert_eq!(
render("Blue"),
"<select><option>Red</option><option> Dark \n green </option>\
<option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
);
}
#[test]
fn a_select_value_binding_reaches_options_rendered_by_a_list() {
let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
.iter()
.map(|(id, label)| (id.to_string(), label.to_string()))
.collect();
let long: Vec<(String, String)> = (0..45)
.map(|i| (format!("r{i}"), format!("Row {i}")))
.collect();
let cases: [(&[(String, String)], &str, usize); 4] = [
(&short, "a", 0),
(&short, "c", 2),
(&long, "r40", 40),
(&long, "r44", 44),
];
for (rows_in, value, chosen) in cases {
let mut page = Page::new();
let v = page.slot(text(value));
let rows = rows_in
.iter()
.map(|(id, label)| {
SlotValue::object(vec![
("id".to_string(), text(id)),
("label".to_string(), text(label)),
])
})
.collect();
let items = page.slot(SlotValue::array(rows));
let item = page.slot(SlotValue::Null);
let id = page.slot(SlotValue::Null);
let label = page.slot(SlotValue::Null);
page.open("select", &[])
.dyn_attr("value", v)
.list(items, item, |p| {
p.prop(item, "id", id)
.prop(item, "label", label)
.open("option", &[])
.dyn_attr("value", id)
.dyn_text(label)
.close("option");
})
.close("select");
let expected: String = rows_in
.iter()
.enumerate()
.map(|(i, (id, label))| {
let selected = if i == chosen { " selected" } else { "" };
format!(
"<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
)
})
.collect();
assert_eq!(
page.render(),
format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
"value {value}"
);
}
}
#[test]
fn a_select_binding_ends_with_its_select() {
let mut page = Page::new();
let v = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "a")])
.text("A")
.close("option")
.close("select")
.open("datalist", &[])
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("datalist");
assert_eq!(
page.render(),
format!(
"<select>{}</select><datalist>{}</datalist>",
option("a", "A", false),
option("b", "B", false)
)
);
}
#[test]
fn an_options_first_value_attribute_is_its_value() {
for (bound, selected) in [("a", " selected"), ("b", "")] {
let mut page = Page::new();
let v = page.slot(text(bound));
let second = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "a")])
.dyn_attr("value", second)
.text("Only")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
"bound to {bound}"
);
}
}
#[test]
fn multiple_counts_only_when_the_attribute_is_written() {
for (multiple, attr, chosen) in [(false, "", false), (true, " multiple", true)] {
let mut page = Page::new();
let flag = page.slot(SlotValue::Bool(multiple));
let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
page.open("select", &[])
.dyn_attr("multiple", flag)
.dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
assert_eq!(
page.render(),
format!(
"<select{attr}>{}{}{}</select>",
option("a", "Apple", chosen),
option("b", "Banana", false),
option("c", "Cherry", chosen)
),
"multiple {multiple}"
);
}
}
#[test]
fn a_null_member_of_a_multiple_value_compares_as_null() {
let mut page = Page::new();
let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
page.open("select", &[("multiple", "")])
.dyn_attr("value", v);
for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
assert_eq!(
page.render(),
format!(
"<select multiple>{}{}{}</select>",
option("null", "Null", true),
option("x", "Empty", false),
option("b", "Banana", true)
)
);
}
#[test]
fn an_option_ends_at_the_next_option_or_optgroup() {
let mut page = Page::new();
let v = page.slot(text("A"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("A")
.open("option", &[])
.text("B")
.close("select");
assert_eq!(
page.render(),
"<select><option selected>A<option>B</select>",
"ended by <option>"
);
let mut page = Page::new();
let v = page.slot(text("A"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[])
.text("A")
.open("optgroup", &[("label", "g")])
.open("option", &[("value", "x")])
.text("X")
.close("option")
.close("optgroup")
.close("select");
assert_eq!(
page.render(),
"<select><option selected>A<optgroup label=\"g\">\
<option value=\"x\">X</option></optgroup></select>",
"ended by <optgroup>"
);
}
#[test]
fn a_single_select_selects_only_the_first_matching_option() {
let mut page = Page::new();
let v = page.slot(text("b"));
page.open("select", &[])
.dyn_attr("value", v)
.open("option", &[("value", "b")])
.text("First")
.close("option")
.open("option", &[("value", "b")])
.text("Second")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!(
"<select>{}{}</select>",
option("b", "First", true),
option("b", "Second", false)
)
);
}
#[test]
fn a_multiple_select_selects_every_option_in_an_array_value() {
let render = |attrs: &[(&str, &str)]| {
let mut page = Page::new();
let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
page.open("select", attrs).dyn_attr("value", v);
for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
page.open("option", &[("value", value)])
.text(label)
.close("option");
}
page.close("select");
page.render()
};
assert_eq!(
render(&[("multiple", "")]),
format!(
"<select multiple>{}{}{}</select>",
option("a", "Apple", true),
option("b", "Banana", false),
option("c", "Cherry", true)
)
);
assert_eq!(
render(&[]),
format!(
"<select>{}{}{}</select>",
option("a", "Apple", false),
option("b", "Banana", false),
option("c", "Cherry", false)
)
);
}
#[test]
fn a_bound_select_overrides_authored_selected_on_its_options() {
let render = |bound: bool| {
let mut page = Page::new();
let v = page.slot(text("a"));
let on = page.slot(SlotValue::Bool(true));
page.open("select", &[]);
if bound {
page.dyn_attr("value", v);
}
page.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b"), ("selected", "")])
.text("B")
.close("option")
.open("option", &[("value", "c")])
.dyn_attr("selected", on)
.text("C")
.close("option")
.close("select");
page.render()
};
assert_eq!(
render(true),
format!(
"<select>{}{}{}</select>",
option("a", "A", true),
option("b", "B", false),
option("c", "C", false)
)
);
assert_eq!(
render(false),
format!(
"<select>{}{}{}</select>",
option("a", "A", false),
option("b", "B", true),
option("c", "C", true)
)
);
}
#[test]
fn a_static_select_value_attribute_selects_its_option() {
let mut page = Page::new();
page.open("select", &[("value", "b")])
.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("select");
assert_eq!(
page.render(),
format!(
"<select>{}{}</select>",
option("a", "A", false),
option("b", "B", true)
)
);
}
#[test]
fn the_context_rules_hold_in_an_island_fragment() {
let mut page = Page::new();
let body = page.slot(text("hello"));
let choice = page.slot(text("b"));
page.island(0, |p| {
p.open("form", &[])
.open("textarea", &[])
.dyn_text(body)
.close("textarea")
.open("select", &[])
.dyn_attr("value", choice)
.open("option", &[("value", "a")])
.text("A")
.close("option")
.open("option", &[("value", "b")])
.text("B")
.close("option")
.close("select")
.close("form");
});
let inner = format!(
"<textarea>hello</textarea><select>{}{}</select>",
option("a", "A", false),
option("b", "B", true)
);
let full = page.render();
let fragment = page.render_island(0);
for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
assert!(html.contains(&inner), "{path}: {html}");
}
assert!(
full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
"{full}"
);
}
fn branding_module() -> IrModule {
let mut row = encode_open_tag(2, &[]);
row.extend_from_slice(&encode_dyn_text(2, 1));
row.extend_from_slice(&encode_close_tag(2));
let mut ops = encode_island_start(0);
ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_dyn_text(0, 0));
ops.extend_from_slice(&encode_list(1, 2, &row));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["div", "Branding", "i", "name", "colors", "color"],
&[
(0, 3, 0x01, 0x00, &[]),
(1, 4, 0x04, 0x00, &[]),
(2, 5, 0x01, 0x00, &[]),
],
&ops,
&[(0, 0x01, 0x01, 1, 0, &[])],
);
IrModule::parse(&data).unwrap()
}
fn branding(name: &str, colors: usize) -> SlotData {
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Text(name.to_string()));
slots.set(
1,
SlotValue::array(
(0..colors)
.map(|i| SlotValue::Text(format!("c{i}")))
.collect(),
),
);
slots
}
#[test]
fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
let module = branding_module();
let slots = branding("Acme", 3);
let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
assert_eq!(out.counts.bytes, out.html.len());
assert_eq!(out.counts.nodes, 8);
assert_eq!(out.counts.list_iterations, 3);
}
#[test]
fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
let module = branding_module();
let limits = WalkLimits {
max_list_iterations: 5,
..WalkLimits::default()
};
let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
assert_eq!(failure.counts.list_iterations, 0);
assert_eq!(failure.counts.nodes, 2);
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
assert!(failure.counts.bytes > 0);
let limits = WalkLimits {
max_output_bytes: 400,
..WalkLimits::default()
};
let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
let counts = failure.counts;
assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
assert!(
counts.list_iterations > 1 && counts.list_iterations < 50,
"{counts:?}"
);
assert!(
counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
"{counts:?}"
);
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
}
#[test]
fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
let mut ops = encode_open_tag(0, &[]);
ops.extend_from_slice(&encode_text(99));
let data = build_minimal_ir(&["p"], &[], &ops, &[]);
let module = IrModule::parse(&data).unwrap();
let failure =
walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
assert_eq!(
failure.error,
IrError::StringIndexOutOfBounds { index: 99, len: 1 }
);
assert!(failure.component_path.is_empty());
assert_eq!(failure.counts.nodes, 1);
}
#[test]
fn counted_walk_names_every_island_around_a_failure_outermost_first() {
let mut ops = encode_island_start(0); ops.extend_from_slice(&encode_open_tag(0, &[]));
let done_at = ops.len() as u32;
ops.extend_from_slice(&encode_island_start(2)); ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(2));
let card_at = ops.len() as u32;
ops.extend_from_slice(&encode_island_start(1)); ops.extend_from_slice(&encode_open_tag(0, &[]));
ops.extend_from_slice(&encode_text(99)); ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(1));
ops.extend_from_slice(&encode_close_tag(0));
ops.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["div", "Shell", "Card", "Done", "x"],
&[],
&ops,
&[
(0, 0x01, 0x01, 1, 0, &[]),
(1, 0x01, 0x01, 2, card_at, &[]),
(2, 0x01, 0x01, 3, done_at, &[]),
],
);
let module = IrModule::parse(&data).unwrap();
let failure =
walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
assert_eq!(
failure.component_path,
vec!["Shell".to_string(), "Card".to_string()]
);
}
#[test]
fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
let module = branding_module();
let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
assert!(html.contains("forma-test-fault"), "{html}");
let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
assert!(matches!(failure.error, IrError::Panicked { .. }));
}
#[test]
fn counted_walk_catches_a_panic_and_names_its_island() {
let module = branding_module();
let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
match &failure.error {
IrError::Panicked { message } => {
assert!(message.contains("stand-in"), "{message}")
}
other => panic!("expected Panicked, got {other:?}"),
}
assert_eq!(failure.component_path, vec!["Branding".to_string()]);
let out =
walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
assert!(out.html.contains("Acme"));
}
fn lists_module(lists: usize, nested: bool) -> IrModule {
let mut ops = encode_open_tag(0, &[]);
let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
for k in 0..lists as u16 {
let (list_slot, item_slot) = (2 * k, 2 * k + 1);
let mut row = encode_open_tag(1, &[]);
if nested {
let inner_item = 2 * lists as u16;
let mut inner_row = encode_open_tag(1, &[]);
inner_row.extend_from_slice(&encode_close_tag(1));
row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
}
row.extend_from_slice(&encode_close_tag(1));
ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
slots.push((list_slot, 2, 0x04, 0x00, &[]));
slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
}
if nested {
slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
}
ops.extend_from_slice(&encode_close_tag(0));
let data = build_minimal_ir(&["div", "i", "items", "item"], &slots, &ops, &[]);
IrModule::parse(&data).unwrap()
}
fn rows(n: usize) -> SlotValue {
SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
}
fn limits(max_list_iterations: usize) -> WalkLimits {
WalkLimits {
max_list_iterations,
..WalkLimits::default()
}
}
#[test]
fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
let module = lists_module(1, false);
let mut slots = SlotData::new(2);
slots.set(0, rows(51));
let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
assert_eq!(failure.counts.list_iterations, 0);
assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
slots.set(0, rows(50));
let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
assert_eq!(out.counts.list_iterations, 50);
assert_eq!(out.html.matches("<i>").count(), 50);
}
#[test]
fn a_list_exactly_at_the_remaining_budget_still_renders() {
let module = lists_module(2, false);
let mut slots = SlotData::new(4);
slots.set(0, rows(20));
slots.set(2, rows(30));
let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
assert_eq!(out.counts.list_iterations, 50);
assert_eq!(out.html.matches("<i>").count(), 50);
slots.set(2, rows(31));
let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
assert_eq!(
failure.counts.list_iterations, 20,
"the first list, and no more"
);
assert_eq!(failure.counts.nodes, 1 + 20);
}
#[test]
fn a_nested_list_is_checked_against_what_its_parents_left() {
let module = lists_module(1, true);
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
assert_eq!(failure.counts.list_iterations, 43);
let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
assert_eq!(out.counts.list_iterations, 63);
assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
}
#[test]
fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
let module = lists_module(1, true);
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
assert_eq!(failure.counts.list_iterations, 24);
}
}