1use super::HandlerPlugin;
13use crate::{ResponseWriter, Result};
14
15const REPEAT_START: &str = "<!--wr-->";
16const REPEAT_END: &str = "<!--/wr-->";
17const REPEAT_ITEM: &str = "<!--wi-->";
18const COND_START: &str = "<!--wc-->";
19const COND_END: &str = "<!--/wc-->";
20
21pub struct WebUIHydrationPlugin;
27
28impl WebUIHydrationPlugin {
29 #[must_use]
31 pub fn new() -> Self {
32 Self
33 }
34}
35
36impl Default for WebUIHydrationPlugin {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl HandlerPlugin for WebUIHydrationPlugin {
43 fn push_scope(&mut self) {}
44
45 fn pop_scope(&mut self) {}
46
47 fn on_binding_start(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
48 Ok(())
49 }
50
51 fn on_binding_end(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
52 Ok(())
53 }
54
55 fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
56 writer.write(REPEAT_START)
57 }
58
59 fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
60 writer.write(REPEAT_END)
61 }
62
63 fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
64 writer.write(COND_START)
65 }
66
67 fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
68 writer.write(COND_END)
69 }
70
71 fn on_repeat_item_start(
72 &mut self,
73 _index: usize,
74 writer: &mut dyn ResponseWriter,
75 ) -> Result<()> {
76 writer.write(REPEAT_ITEM)
77 }
78
79 fn on_repeat_item_end(
81 &mut self,
82 _index: usize,
83 _writer: &mut dyn ResponseWriter,
84 ) -> Result<()> {
85 Ok(())
86 }
87
88 fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
89 Ok(())
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 struct TestWriter {
98 output: String,
99 }
100
101 impl TestWriter {
102 fn new() -> Self {
103 Self {
104 output: String::new(),
105 }
106 }
107 }
108
109 impl ResponseWriter for TestWriter {
110 fn write(&mut self, content: &str) -> Result<()> {
111 self.output.push_str(content);
112 Ok(())
113 }
114 fn end(&mut self) -> Result<()> {
115 Ok(())
116 }
117 }
118
119 #[test]
120 fn test_default_creates_instance() {
121 let _plugin = WebUIHydrationPlugin::default();
122 }
123
124 #[test]
125 fn test_signal_binding_emits_no_output() {
126 let mut plugin = WebUIHydrationPlugin::new();
127 let mut writer = TestWriter::new();
128 plugin.on_binding_start("x", &mut writer).unwrap();
129 plugin.on_binding_end("x", &mut writer).unwrap();
130 assert_eq!(
131 writer.output, "",
132 "signal binding hooks must not emit output"
133 );
134 }
135
136 #[test]
137 fn test_for_loop_emits_repeat_markers() {
138 let mut plugin = WebUIHydrationPlugin::new();
139 let mut writer = TestWriter::new();
140 plugin.on_for_start("items", &mut writer).unwrap();
141 plugin.on_repeat_item_start(0, &mut writer).unwrap();
142 plugin.push_scope();
143 writer.write("<div>A</div>").unwrap();
144 plugin.pop_scope();
145 plugin.on_repeat_item_end(0, &mut writer).unwrap();
146 plugin.on_repeat_item_start(1, &mut writer).unwrap();
147 plugin.push_scope();
148 writer.write("<div>B</div>").unwrap();
149 plugin.pop_scope();
150 plugin.on_repeat_item_end(1, &mut writer).unwrap();
151 plugin.on_for_end("items", &mut writer).unwrap();
152 assert_eq!(
153 writer.output,
154 "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
155 );
156 }
157
158 #[test]
159 fn test_empty_for_loop_emits_boundary_markers() {
160 let mut plugin = WebUIHydrationPlugin::new();
161 let mut writer = TestWriter::new();
162 plugin.on_for_start("items", &mut writer).unwrap();
163 plugin.on_for_end("items", &mut writer).unwrap();
164 assert_eq!(writer.output, "<!--wr--><!--/wr-->");
165 }
166
167 #[test]
168 fn test_if_true_emits_cond_markers() {
169 let mut plugin = WebUIHydrationPlugin::new();
170 let mut writer = TestWriter::new();
171 plugin.on_if_start("show", &mut writer).unwrap();
172 plugin.push_scope();
173 writer.write("<p>visible</p>").unwrap();
174 plugin.pop_scope();
175 plugin.on_if_end("show", &mut writer).unwrap();
176 assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
177 }
178
179 #[test]
180 fn test_if_false_emits_empty_cond_markers() {
181 let mut plugin = WebUIHydrationPlugin::new();
182 let mut writer = TestWriter::new();
183 plugin.on_if_start("show", &mut writer).unwrap();
184 plugin.on_if_end("show", &mut writer).unwrap();
186 assert_eq!(writer.output, "<!--wc--><!--/wc-->");
187 }
188
189 #[test]
190 fn test_nested_repeat_inside_conditional() {
191 let mut plugin = WebUIHydrationPlugin::new();
192 let mut writer = TestWriter::new();
193 plugin.on_if_start("show", &mut writer).unwrap();
194 plugin.push_scope();
195 plugin.on_for_start("items", &mut writer).unwrap();
196 plugin.on_repeat_item_start(0, &mut writer).unwrap();
197 plugin.push_scope();
198 writer.write("<li>X</li>").unwrap();
199 plugin.pop_scope();
200 plugin.on_repeat_item_end(0, &mut writer).unwrap();
201 plugin.on_for_end("items", &mut writer).unwrap();
202 plugin.pop_scope();
203 plugin.on_if_end("show", &mut writer).unwrap();
204 assert_eq!(
205 writer.output,
206 "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
207 );
208 }
209
210 #[test]
211 fn test_element_data_emits_no_output() {
212 let mut plugin = WebUIHydrationPlugin::new();
213 let mut writer = TestWriter::new();
214 plugin.push_scope();
215 let data = 3u32.to_le_bytes();
216 plugin.on_element_data(&data, &mut writer).unwrap();
217 assert_eq!(writer.output, "", "element data must not emit output");
218 }
219
220 #[test]
221 fn test_scope_push_pop_is_noop() {
222 let mut plugin = WebUIHydrationPlugin::new();
223 plugin.push_scope();
224 plugin.push_scope();
225 plugin.pop_scope();
226 plugin.pop_scope();
227 }
228
229 #[test]
230 fn test_route_component_state_emits_no_output() {
231 let plugin = WebUIHydrationPlugin::new();
232 let mut writer = TestWriter::new();
233 let state = serde_json::json!({
234 "title": "Hello",
235 "cartOpen": true,
236 "items": [{"name": "A&B"}],
237 });
238 plugin
239 .write_route_component_state(&state, &mut writer)
240 .unwrap();
241 assert_eq!(
242 writer.output, "",
243 "route component state must not emit output"
244 );
245 }
246
247 #[test]
248 fn test_full_lifecycle_with_markers() {
249 let mut plugin = WebUIHydrationPlugin::new();
250 let mut writer = TestWriter::new();
251
252 plugin.push_scope();
254 plugin.on_binding_start("a", &mut writer).unwrap();
256 writer.write("hello").unwrap();
257 plugin.on_binding_end("a", &mut writer).unwrap();
258 plugin.on_for_start("list", &mut writer).unwrap();
260 plugin.on_repeat_item_start(0, &mut writer).unwrap();
261 plugin.push_scope();
262 writer.write("<x-item>1</x-item>").unwrap();
263 plugin.pop_scope();
264 plugin.on_repeat_item_end(0, &mut writer).unwrap();
265 plugin.on_for_end("list", &mut writer).unwrap();
266 plugin.on_if_start("flag", &mut writer).unwrap();
268 plugin.push_scope();
269 writer.write("<p>yes</p>").unwrap();
270 plugin.pop_scope();
271 plugin.on_if_end("flag", &mut writer).unwrap();
272 plugin.pop_scope();
273
274 assert_eq!(
275 writer.output,
276 "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
277 );
278 }
279
280 #[test]
281 fn test_on_render_complete_only_emits_rendered_components() {
282 let mut writer = TestWriter::new();
283
284 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
285 protocol
286 .components
287 .entry("comp-a".to_string())
288 .or_default()
289 .template = "<script>/*comp-a*/</script>".to_string();
290 protocol
291 .components
292 .entry("comp-b".to_string())
293 .or_default()
294 .template = "<script>/*comp-b*/</script>".to_string();
295 protocol
296 .components
297 .entry("comp-c".to_string())
298 .or_default()
299 .template = "<script>/*comp-c*/</script>".to_string();
300
301 let mut rendered = std::collections::HashSet::new();
302 rendered.insert("comp-a".to_string());
303
304 crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
305 .unwrap();
306
307 assert!(
308 writer.output.contains("comp-a"),
309 "rendered component should be emitted: {}",
310 writer.output
311 );
312 assert!(
313 !writer.output.contains("comp-b"),
314 "non-rendered component should NOT be emitted: {}",
315 writer.output
316 );
317 assert!(
318 !writer.output.contains("comp-c"),
319 "non-rendered component should NOT be emitted: {}",
320 writer.output
321 );
322 }
323
324 #[test]
325 fn test_on_render_complete_empty_rendered_set() {
326 let mut writer = TestWriter::new();
327 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
328 protocol
329 .components
330 .entry("comp-a".to_string())
331 .or_default()
332 .template = "<script>/*a*/</script>".to_string();
333 let rendered = std::collections::HashSet::new();
334 crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
335 .unwrap();
336 assert_eq!(writer.output, "", "empty rendered set should emit nothing");
337 }
338
339 #[test]
340 fn test_on_render_complete_skips_empty_template() {
341 let mut writer = TestWriter::new();
342 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
343 protocol
344 .components
345 .entry("comp-a".to_string())
346 .or_default()
347 .template = String::new();
348 let mut rendered = std::collections::HashSet::new();
349 rendered.insert("comp-a".to_string());
350 crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
351 .unwrap();
352 assert_eq!(writer.output, "", "empty template should not be emitted");
353 }
354
355 #[test]
356 fn test_on_render_complete_unknown_component() {
357 let mut writer = TestWriter::new();
358 let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
359 let mut rendered = std::collections::HashSet::new();
360 rendered.insert("nonexistent-comp".to_string());
361 crate::plugin::emit_rendered_component_templates(&protocol, &rendered, None, &mut writer)
362 .unwrap();
363 assert_eq!(
364 writer.output, "",
365 "unknown component should not cause error"
366 );
367 }
368
369 use std::collections::HashMap;
372 use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
373 use webui_test_utils::test_json;
374
375 use crate::{RenderOptions, WebUIHandler};
376
377 fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
378 let mut writer = TestWriter::new();
379 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
380 handler
381 .handle(
382 protocol,
383 state,
384 &RenderOptions::new("index.html", "/"),
385 &mut writer,
386 )
387 .unwrap();
388 writer.output
389 }
390
391 #[test]
392 fn test_handler_emits_hydration_markers_for_loop_and_if() {
393 let mut fragments = HashMap::new();
395 fragments.insert(
396 "index.html".to_string(),
397 FragmentList {
398 fragments: vec![
399 WebUIFragment::for_loop("item", "items", "for-body"),
400 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
401 ],
402 },
403 );
404 fragments.insert(
405 "for-body".to_string(),
406 FragmentList {
407 fragments: vec![WebUIFragment::signal("item", false)],
408 },
409 );
410 fragments.insert(
411 "if-body".to_string(),
412 FragmentList {
413 fragments: vec![WebUIFragment::raw("<span>yes</span>")],
414 },
415 );
416 let protocol = WebUIProtocol::new(fragments);
417 let state = test_json!({"items": ["a", "b"], "show": true});
418 let output = render_with_webui_plugin(&protocol, &state);
419
420 assert!(
422 output.contains("<!--wr-->"),
423 "Expected repeat-start marker, got: {output}"
424 );
425 assert!(
426 output.contains("<!--/wr-->"),
427 "Expected repeat-end marker, got: {output}"
428 );
429 assert!(
430 output.contains("<!--wi-->"),
431 "Expected repeat-item marker, got: {output}"
432 );
433 assert_eq!(
435 output.matches("<!--wi-->").count(),
436 2,
437 "Expected 2 repeat-item markers, got: {output}"
438 );
439
440 assert!(
442 output.contains("<!--wc-->"),
443 "Expected cond-start marker, got: {output}"
444 );
445 assert!(
446 output.contains("<!--/wc-->"),
447 "Expected cond-end marker, got: {output}"
448 );
449
450 assert!(
452 output.contains("a"),
453 "Expected for-loop items in output, got: {output}"
454 );
455 assert!(
456 output.contains("b"),
457 "Expected for-loop items in output, got: {output}"
458 );
459 assert!(
460 output.contains("<span>yes</span>"),
461 "Expected if-condition body, got: {output}"
462 );
463 }
464}