Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,19 +299,16 @@ impl WebUIHandler {
let collection_name = &for_loop.collection;

// If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
let collection = match self.resolve_value(collection_name, context) {
Some(val) => val,
None => return Ok(()), // missing collection = no iterations
};

let items = match collection {
Value::Array(arr) => arr,
_ => {
// Hydration comments are always emitted regardless of collection presence.
let items = match self.resolve_value(collection_name, context) {
Some(Value::Array(arr)) => arr,
Some(_) => {
return Err(HandlerError::TypeError(format!(
"Collection '{}' is not an array",
collection_name
)))
}
None => Vec::new(),
};

if let Some(p) = &mut self.plugin {
Expand Down Expand Up @@ -353,17 +350,17 @@ impl WebUIHandler {
signal: &webui_protocol::WebUIFragmentSignal,
context: &mut WebUIProcessContext,
) -> Result<()> {
if let Some(value) = self.resolve_value(&signal.value, context) {
if let Some(p) = &mut self.plugin {
p.on_binding_start(&signal.value, context.writer)?;
}
if let Some(p) = &mut self.plugin {
p.on_binding_start(&signal.value, context.writer)?;
}

if let Some(value) = self.resolve_value(&signal.value, context) {
let content = self.format_signal_value(&value, signal.raw)?;
context.writer.write(&content)?;
}

if let Some(p) = &mut self.plugin {
p.on_binding_end(&signal.value, context.writer)?;
}
if let Some(p) = &mut self.plugin {
p.on_binding_end(&signal.value, context.writer)?;
}
Ok(())
}
Expand Down
147 changes: 147 additions & 0 deletions crates/webui-handler/src/plugin/fast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,4 +809,151 @@ mod tests {

assert_eq!(output, expected);
}

#[test]
fn test_hydration_missing_signal_still_emits_markers() {
let mut fragments = HashMap::new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I am not yet developed skill to understand this test - would it be better if the test has big giant comment explaining on what it is doing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI can figure out the test contents easily, but isn't the name good? testing if hydration emits markers for missing signals

fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("missing_field", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol { fragments };
let state = test_json!({});
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
// Hydration comments must be emitted even when signal is not found in state
assert!(
output.contains("<!--fe-b$$start$$0$$missing_field$$fe-b-->"),
"Expected binding start marker for missing signal, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$missing_field$$fe-b-->"),
"Expected binding end marker for missing signal, got: {output}"
);
// Start and end markers should be adjacent (no content between them)
assert!(output.contains(
"<!--fe-b$$start$$0$$missing_field$$fe-b--><!--fe-b$$end$$0$$missing_field$$fe-b-->"
));
}

#[test]
fn test_hydration_missing_for_collection_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<ul>"),
WebUIFragment::for_loop("item", "missing_items", "loop-body"),
WebUIFragment::raw("</ul>"),
],
},
);
fragments.insert(
"loop-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
},
);
let protocol = WebUIProtocol { fragments };
let state = test_json!({});
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
// Hydration comments must be emitted even when collection is missing from state
assert!(
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
"Expected binding start marker for missing collection, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
"Expected binding end marker for missing collection, got: {output}"
);
}

#[test]
fn test_hydration_empty_string_signal_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<p>"),
WebUIFragment::signal("name", false),
WebUIFragment::raw("</p>"),
],
},
);
let protocol = WebUIProtocol { fragments };
let state = test_json!({"name": ""});
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$name$$fe-b-->"),
"Expected binding start marker for empty string signal, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$name$$fe-b-->"),
"Expected binding end marker for empty string signal, got: {output}"
);
assert!(output.contains("<!--fe-b$$start$$0$$name$$fe-b--><!--fe-b$$end$$0$$name$$fe-b-->"));
}

#[test]
fn test_hydration_empty_collection_still_emits_markers() {
let mut fragments = HashMap::new();
fragments.insert(
"index.html".to_string(),
FragmentList {
fragments: vec![WebUIFragment::component("my-comp")],
},
);
fragments.insert(
"my-comp".to_string(),
FragmentList {
fragments: vec![
WebUIFragment::raw("<ul>"),
WebUIFragment::for_loop("item", "items", "loop-body"),
WebUIFragment::raw("</ul>"),
],
},
);
fragments.insert(
"loop-body".to_string(),
FragmentList {
fragments: vec![WebUIFragment::signal("item", false)],
},
);
let protocol = WebUIProtocol { fragments };
let state = test_json!({"items": []});
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
assert!(
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
"Expected binding start marker for empty collection, got: {output}"
);
assert!(
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
"Expected binding end marker for empty collection, got: {output}"
);
}
}
Loading