Skip to content

Commit fac8aec

Browse files
fix: emit hydration comments for bindings when state is missing (#71)
* fix: emit hydration comments for bindings when state is missing Previously, process_signal and process_for_loop in webui-handler only signal value or collection was found in state. If the value was missing, the markers were silently skipped, leaving the client-side hydration framework unable to locate and bind to those DOM positions. This was inconsistent with process_if, which always emits markers regardless of whether the condition references missing values. The fix moves on_binding_start/on_binding_end calls outside the value resolution check in process_signal, and replaces the early return in process_for_loop with an empty Vec so markers are still written around zero iterations.
1 parent c09710d commit fac8aec

2 files changed

Lines changed: 159 additions & 15 deletions

File tree

crates/webui-handler/src/lib.rs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -299,19 +299,16 @@ impl WebUIHandler {
299299
let collection_name = &for_loop.collection;
300300

301301
// If the collection is missing, treat it as empty (0 iterations) — matches NodeJS behavior.
302-
let collection = match self.resolve_value(collection_name, context) {
303-
Some(val) => val,
304-
None => return Ok(()), // missing collection = no iterations
305-
};
306-
307-
let items = match collection {
308-
Value::Array(arr) => arr,
309-
_ => {
302+
// Hydration comments are always emitted regardless of collection presence.
303+
let items = match self.resolve_value(collection_name, context) {
304+
Some(Value::Array(arr)) => arr,
305+
Some(_) => {
310306
return Err(HandlerError::TypeError(format!(
311307
"Collection '{}' is not an array",
312308
collection_name
313309
)))
314310
}
311+
None => Vec::new(),
315312
};
316313

317314
if let Some(p) = &mut self.plugin {
@@ -353,17 +350,17 @@ impl WebUIHandler {
353350
signal: &webui_protocol::WebUIFragmentSignal,
354351
context: &mut WebUIProcessContext,
355352
) -> Result<()> {
356-
if let Some(value) = self.resolve_value(&signal.value, context) {
357-
if let Some(p) = &mut self.plugin {
358-
p.on_binding_start(&signal.value, context.writer)?;
359-
}
353+
if let Some(p) = &mut self.plugin {
354+
p.on_binding_start(&signal.value, context.writer)?;
355+
}
360356

357+
if let Some(value) = self.resolve_value(&signal.value, context) {
361358
let content = self.format_signal_value(&value, signal.raw)?;
362359
context.writer.write(&content)?;
360+
}
363361

364-
if let Some(p) = &mut self.plugin {
365-
p.on_binding_end(&signal.value, context.writer)?;
366-
}
362+
if let Some(p) = &mut self.plugin {
363+
p.on_binding_end(&signal.value, context.writer)?;
367364
}
368365
Ok(())
369366
}

crates/webui-handler/src/plugin/fast.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,4 +809,151 @@ mod tests {
809809

810810
assert_eq!(output, expected);
811811
}
812+
813+
#[test]
814+
fn test_hydration_missing_signal_still_emits_markers() {
815+
let mut fragments = HashMap::new();
816+
fragments.insert(
817+
"index.html".to_string(),
818+
FragmentList {
819+
fragments: vec![WebUIFragment::component("my-comp")],
820+
},
821+
);
822+
fragments.insert(
823+
"my-comp".to_string(),
824+
FragmentList {
825+
fragments: vec![
826+
WebUIFragment::raw("<p>"),
827+
WebUIFragment::signal("missing_field", false),
828+
WebUIFragment::raw("</p>"),
829+
],
830+
},
831+
);
832+
let protocol = WebUIProtocol { fragments };
833+
let state = test_json!({});
834+
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
835+
// Hydration comments must be emitted even when signal is not found in state
836+
assert!(
837+
output.contains("<!--fe-b$$start$$0$$missing_field$$fe-b-->"),
838+
"Expected binding start marker for missing signal, got: {output}"
839+
);
840+
assert!(
841+
output.contains("<!--fe-b$$end$$0$$missing_field$$fe-b-->"),
842+
"Expected binding end marker for missing signal, got: {output}"
843+
);
844+
// Start and end markers should be adjacent (no content between them)
845+
assert!(output.contains(
846+
"<!--fe-b$$start$$0$$missing_field$$fe-b--><!--fe-b$$end$$0$$missing_field$$fe-b-->"
847+
));
848+
}
849+
850+
#[test]
851+
fn test_hydration_missing_for_collection_still_emits_markers() {
852+
let mut fragments = HashMap::new();
853+
fragments.insert(
854+
"index.html".to_string(),
855+
FragmentList {
856+
fragments: vec![WebUIFragment::component("my-comp")],
857+
},
858+
);
859+
fragments.insert(
860+
"my-comp".to_string(),
861+
FragmentList {
862+
fragments: vec![
863+
WebUIFragment::raw("<ul>"),
864+
WebUIFragment::for_loop("item", "missing_items", "loop-body"),
865+
WebUIFragment::raw("</ul>"),
866+
],
867+
},
868+
);
869+
fragments.insert(
870+
"loop-body".to_string(),
871+
FragmentList {
872+
fragments: vec![WebUIFragment::signal("item", false)],
873+
},
874+
);
875+
let protocol = WebUIProtocol { fragments };
876+
let state = test_json!({});
877+
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
878+
// Hydration comments must be emitted even when collection is missing from state
879+
assert!(
880+
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
881+
"Expected binding start marker for missing collection, got: {output}"
882+
);
883+
assert!(
884+
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
885+
"Expected binding end marker for missing collection, got: {output}"
886+
);
887+
}
888+
889+
#[test]
890+
fn test_hydration_empty_string_signal_still_emits_markers() {
891+
let mut fragments = HashMap::new();
892+
fragments.insert(
893+
"index.html".to_string(),
894+
FragmentList {
895+
fragments: vec![WebUIFragment::component("my-comp")],
896+
},
897+
);
898+
fragments.insert(
899+
"my-comp".to_string(),
900+
FragmentList {
901+
fragments: vec![
902+
WebUIFragment::raw("<p>"),
903+
WebUIFragment::signal("name", false),
904+
WebUIFragment::raw("</p>"),
905+
],
906+
},
907+
);
908+
let protocol = WebUIProtocol { fragments };
909+
let state = test_json!({"name": ""});
910+
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
911+
assert!(
912+
output.contains("<!--fe-b$$start$$0$$name$$fe-b-->"),
913+
"Expected binding start marker for empty string signal, got: {output}"
914+
);
915+
assert!(
916+
output.contains("<!--fe-b$$end$$0$$name$$fe-b-->"),
917+
"Expected binding end marker for empty string signal, got: {output}"
918+
);
919+
assert!(output.contains("<!--fe-b$$start$$0$$name$$fe-b--><!--fe-b$$end$$0$$name$$fe-b-->"));
920+
}
921+
922+
#[test]
923+
fn test_hydration_empty_collection_still_emits_markers() {
924+
let mut fragments = HashMap::new();
925+
fragments.insert(
926+
"index.html".to_string(),
927+
FragmentList {
928+
fragments: vec![WebUIFragment::component("my-comp")],
929+
},
930+
);
931+
fragments.insert(
932+
"my-comp".to_string(),
933+
FragmentList {
934+
fragments: vec![
935+
WebUIFragment::raw("<ul>"),
936+
WebUIFragment::for_loop("item", "items", "loop-body"),
937+
WebUIFragment::raw("</ul>"),
938+
],
939+
},
940+
);
941+
fragments.insert(
942+
"loop-body".to_string(),
943+
FragmentList {
944+
fragments: vec![WebUIFragment::signal("item", false)],
945+
},
946+
);
947+
let protocol = WebUIProtocol { fragments };
948+
let state = test_json!({"items": []});
949+
let output = render_with_plugin(&protocol, &state, Box::new(FastHydrationPlugin::new()));
950+
assert!(
951+
output.contains("<!--fe-b$$start$$0$$loop-body$$fe-b-->"),
952+
"Expected binding start marker for empty collection, got: {output}"
953+
);
954+
assert!(
955+
output.contains("<!--fe-b$$end$$0$$loop-body$$fe-b-->"),
956+
"Expected binding end marker for empty collection, got: {output}"
957+
);
958+
}
812959
}

0 commit comments

Comments
 (0)