1use super::HandlerPlugin;
13use crate::{ResponseWriter, Result};
14use std::collections::HashSet;
15use webui_protocol::WebUIProtocol;
16
17const REPEAT_START: &str = "<!--wr-->";
18const REPEAT_END: &str = "<!--/wr-->";
19const REPEAT_ITEM: &str = "<!--wi-->";
20const COND_START: &str = "<!--wc-->";
21const COND_END: &str = "<!--/wc-->";
22
23pub struct WebUIHydrationPlugin;
29
30impl WebUIHydrationPlugin {
31 #[must_use]
33 pub fn new() -> Self {
34 Self
35 }
36}
37
38impl Default for WebUIHydrationPlugin {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl HandlerPlugin for WebUIHydrationPlugin {
45 fn push_scope(&mut self) {}
46
47 fn pop_scope(&mut self) {}
48
49 fn on_binding_start(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
50 Ok(())
51 }
52
53 fn on_binding_end(&mut self, _name: &str, _writer: &mut dyn ResponseWriter) -> Result<()> {
54 Ok(())
55 }
56
57 fn on_for_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
58 writer.write(REPEAT_START)
59 }
60
61 fn on_for_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
62 writer.write(REPEAT_END)
63 }
64
65 fn on_if_start(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
66 writer.write(COND_START)
67 }
68
69 fn on_if_end(&mut self, _name: &str, writer: &mut dyn ResponseWriter) -> Result<()> {
70 writer.write(COND_END)
71 }
72
73 fn on_repeat_item_start(
74 &mut self,
75 _index: usize,
76 writer: &mut dyn ResponseWriter,
77 ) -> Result<()> {
78 writer.write(REPEAT_ITEM)
79 }
80
81 fn on_repeat_item_end(
83 &mut self,
84 _index: usize,
85 _writer: &mut dyn ResponseWriter,
86 ) -> Result<()> {
87 Ok(())
88 }
89
90 fn on_element_data(&mut self, _data: &[u8], _writer: &mut dyn ResponseWriter) -> Result<()> {
91 Ok(())
92 }
93
94 fn emit_templates(
105 &self,
106 protocol: &WebUIProtocol,
107 components: &HashSet<String>,
108 nonce: Option<&str>,
109 writer: &mut dyn ResponseWriter,
110 ) -> Result<()> {
111 let mut templates: Vec<&str> = Vec::with_capacity(components.len());
112
113 for name in components {
114 if let Some(template) = protocol
115 .components
116 .get(name)
117 .map(|component| component.template.as_str())
118 .filter(|t| !t.is_empty())
119 {
120 templates.push(template);
121 }
122 }
123
124 if templates.is_empty() {
125 return Ok(());
126 }
127
128 if let Some(nonce) = nonce {
129 writer.write("<script nonce=\"")?;
130 writer.write(nonce)?;
131 writer.write("\">\n")?;
132 } else {
133 writer.write("<script>\n")?;
134 }
135 for tmpl in &templates {
136 writer.write(tmpl)?;
137 }
138 writer.write("</script>\n")?;
139
140 Ok(())
141 }
142
143 fn collect_template_js(
147 &self,
148 protocol: &WebUIProtocol,
149 components: &HashSet<String>,
150 ) -> Option<Vec<String>> {
151 let mut templates: Vec<String> = Vec::with_capacity(components.len());
152 for name in components {
153 if let Some(template) = protocol
154 .components
155 .get(name)
156 .map(|component| component.template.as_str())
157 .filter(|t| !t.is_empty())
158 {
159 templates.push(template.to_string());
160 }
161 }
162 if templates.is_empty() {
163 None
164 } else {
165 Some(templates)
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 struct TestWriter {
175 output: String,
176 }
177
178 impl TestWriter {
179 fn new() -> Self {
180 Self {
181 output: String::new(),
182 }
183 }
184 }
185
186 impl ResponseWriter for TestWriter {
187 fn write(&mut self, content: &str) -> Result<()> {
188 self.output.push_str(content);
189 Ok(())
190 }
191 fn end(&mut self) -> Result<()> {
192 Ok(())
193 }
194 }
195
196 #[test]
197 fn test_default_creates_instance() {
198 let _plugin = WebUIHydrationPlugin;
199 }
200
201 #[test]
202 fn test_signal_binding_emits_no_output() {
203 let mut plugin = WebUIHydrationPlugin::new();
204 let mut writer = TestWriter::new();
205 plugin.on_binding_start("x", &mut writer).unwrap();
206 plugin.on_binding_end("x", &mut writer).unwrap();
207 assert_eq!(
208 writer.output, "",
209 "signal binding hooks must not emit output"
210 );
211 }
212
213 #[test]
214 fn test_for_loop_emits_repeat_markers() {
215 let mut plugin = WebUIHydrationPlugin::new();
216 let mut writer = TestWriter::new();
217 plugin.on_for_start("items", &mut writer).unwrap();
218 plugin.on_repeat_item_start(0, &mut writer).unwrap();
219 plugin.push_scope();
220 writer.write("<div>A</div>").unwrap();
221 plugin.pop_scope();
222 plugin.on_repeat_item_end(0, &mut writer).unwrap();
223 plugin.on_repeat_item_start(1, &mut writer).unwrap();
224 plugin.push_scope();
225 writer.write("<div>B</div>").unwrap();
226 plugin.pop_scope();
227 plugin.on_repeat_item_end(1, &mut writer).unwrap();
228 plugin.on_for_end("items", &mut writer).unwrap();
229 assert_eq!(
230 writer.output,
231 "<!--wr--><!--wi--><div>A</div><!--wi--><div>B</div><!--/wr-->"
232 );
233 }
234
235 #[test]
236 fn test_empty_for_loop_emits_boundary_markers() {
237 let mut plugin = WebUIHydrationPlugin::new();
238 let mut writer = TestWriter::new();
239 plugin.on_for_start("items", &mut writer).unwrap();
240 plugin.on_for_end("items", &mut writer).unwrap();
241 assert_eq!(writer.output, "<!--wr--><!--/wr-->");
242 }
243
244 #[test]
245 fn test_if_true_emits_cond_markers() {
246 let mut plugin = WebUIHydrationPlugin::new();
247 let mut writer = TestWriter::new();
248 plugin.on_if_start("show", &mut writer).unwrap();
249 plugin.push_scope();
250 writer.write("<p>visible</p>").unwrap();
251 plugin.pop_scope();
252 plugin.on_if_end("show", &mut writer).unwrap();
253 assert_eq!(writer.output, "<!--wc--><p>visible</p><!--/wc-->");
254 }
255
256 #[test]
257 fn test_if_false_emits_empty_cond_markers() {
258 let mut plugin = WebUIHydrationPlugin::new();
259 let mut writer = TestWriter::new();
260 plugin.on_if_start("show", &mut writer).unwrap();
261 plugin.on_if_end("show", &mut writer).unwrap();
263 assert_eq!(writer.output, "<!--wc--><!--/wc-->");
264 }
265
266 #[test]
267 fn test_nested_repeat_inside_conditional() {
268 let mut plugin = WebUIHydrationPlugin::new();
269 let mut writer = TestWriter::new();
270 plugin.on_if_start("show", &mut writer).unwrap();
271 plugin.push_scope();
272 plugin.on_for_start("items", &mut writer).unwrap();
273 plugin.on_repeat_item_start(0, &mut writer).unwrap();
274 plugin.push_scope();
275 writer.write("<li>X</li>").unwrap();
276 plugin.pop_scope();
277 plugin.on_repeat_item_end(0, &mut writer).unwrap();
278 plugin.on_for_end("items", &mut writer).unwrap();
279 plugin.pop_scope();
280 plugin.on_if_end("show", &mut writer).unwrap();
281 assert_eq!(
282 writer.output,
283 "<!--wc--><!--wr--><!--wi--><li>X</li><!--/wr--><!--/wc-->"
284 );
285 }
286
287 #[test]
288 fn test_element_data_emits_no_output() {
289 let mut plugin = WebUIHydrationPlugin::new();
290 let mut writer = TestWriter::new();
291 plugin.push_scope();
292 let data = 3u32.to_le_bytes();
293 plugin.on_element_data(&data, &mut writer).unwrap();
294 assert_eq!(writer.output, "", "element data must not emit output");
295 }
296
297 #[test]
298 fn test_scope_push_pop_is_noop() {
299 let mut plugin = WebUIHydrationPlugin::new();
300 plugin.push_scope();
301 plugin.push_scope();
302 plugin.pop_scope();
303 plugin.pop_scope();
304 }
305
306 #[test]
307 fn test_route_component_state_emits_no_output() {
308 let plugin = WebUIHydrationPlugin::new();
309 let mut writer = TestWriter::new();
310 let state = serde_json::json!({
311 "title": "Hello",
312 "cartOpen": true,
313 "items": [{"name": "A&B"}],
314 });
315 plugin
316 .write_route_component_state(&state, &mut writer)
317 .unwrap();
318 assert_eq!(
319 writer.output, "",
320 "route component state must not emit output"
321 );
322 }
323
324 #[test]
325 fn test_full_lifecycle_with_markers() {
326 let mut plugin = WebUIHydrationPlugin::new();
327 let mut writer = TestWriter::new();
328
329 plugin.push_scope();
331 plugin.on_binding_start("a", &mut writer).unwrap();
333 writer.write("hello").unwrap();
334 plugin.on_binding_end("a", &mut writer).unwrap();
335 plugin.on_for_start("list", &mut writer).unwrap();
337 plugin.on_repeat_item_start(0, &mut writer).unwrap();
338 plugin.push_scope();
339 writer.write("<x-item>1</x-item>").unwrap();
340 plugin.pop_scope();
341 plugin.on_repeat_item_end(0, &mut writer).unwrap();
342 plugin.on_for_end("list", &mut writer).unwrap();
343 plugin.on_if_start("flag", &mut writer).unwrap();
345 plugin.push_scope();
346 writer.write("<p>yes</p>").unwrap();
347 plugin.pop_scope();
348 plugin.on_if_end("flag", &mut writer).unwrap();
349 plugin.pop_scope();
350
351 assert_eq!(
352 writer.output,
353 "hello<!--wr--><!--wi--><x-item>1</x-item><!--/wr--><!--wc--><p>yes</p><!--/wc-->"
354 );
355 }
356
357 fn iife_template(tag: &str, body: &str) -> String {
358 format!("(function(){{var w=(window.__webui||(window.__webui={{}})).templates||(window.__webui.templates={{}});w['{tag}']={{{body}}}}})();\n")
359 }
360
361 #[test]
362 fn test_emit_templates_only_emits_provided_components() {
363 let mut writer = TestWriter::new();
364
365 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
366 protocol
367 .components
368 .entry("comp-a".to_string())
369 .or_default()
370 .template = iife_template("comp-a", "h:\"a\"");
371 protocol
372 .components
373 .entry("comp-b".to_string())
374 .or_default()
375 .template = iife_template("comp-b", "h:\"b\"");
376 protocol
377 .components
378 .entry("comp-c".to_string())
379 .or_default()
380 .template = iife_template("comp-c", "h:\"c\"");
381
382 let mut components = std::collections::HashSet::new();
383 components.insert("comp-a".to_string());
384
385 let plugin = WebUIHydrationPlugin::new();
386 plugin
387 .emit_templates(&protocol, &components, None, &mut writer)
388 .unwrap();
389
390 assert!(
391 writer.output.contains("comp-a"),
392 "rendered component should be emitted: {}",
393 writer.output
394 );
395 assert!(
396 !writer.output.contains("comp-b"),
397 "non-rendered component should NOT be emitted: {}",
398 writer.output
399 );
400 assert!(
401 !writer.output.contains("comp-c"),
402 "non-rendered component should NOT be emitted: {}",
403 writer.output
404 );
405 }
406
407 #[test]
408 fn test_emit_templates_empty_set() {
409 let mut writer = TestWriter::new();
410 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
411 protocol
412 .components
413 .entry("comp-a".to_string())
414 .or_default()
415 .template = iife_template("comp-a", "h:\"a\"");
416 let components = std::collections::HashSet::new();
417 let plugin = WebUIHydrationPlugin::new();
418 plugin
419 .emit_templates(&protocol, &components, None, &mut writer)
420 .unwrap();
421 assert_eq!(writer.output, "", "empty set should emit nothing");
422 }
423
424 #[test]
425 fn test_on_render_complete_consolidates_into_single_script() {
426 let mut writer = TestWriter::new();
427
428 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
429 protocol
430 .components
431 .entry("comp-a".to_string())
432 .or_default()
433 .template = iife_template("comp-a", "h:\"a\"");
434 protocol
435 .components
436 .entry("comp-b".to_string())
437 .or_default()
438 .template = iife_template("comp-b", "h:\"b\"");
439
440 let mut rendered = std::collections::HashSet::new();
441 rendered.insert("comp-a".to_string());
442 rendered.insert("comp-b".to_string());
443
444 let plugin = WebUIHydrationPlugin::new();
445 plugin
446 .emit_templates(&protocol, &rendered, None, &mut writer)
447 .unwrap();
448
449 let script_count = writer.output.matches("<script").count();
450 assert_eq!(
451 script_count, 1,
452 "should emit exactly one <script> tag, got {script_count}: {}",
453 writer.output
454 );
455 assert!(writer.output.contains("comp-a"));
456 assert!(writer.output.contains("comp-b"));
457 }
458
459 #[test]
460 fn test_on_render_complete_skips_empty_template() {
461 let mut writer = TestWriter::new();
462 let mut protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
463 protocol
464 .components
465 .entry("comp-a".to_string())
466 .or_default()
467 .template = String::new();
468 let mut rendered = std::collections::HashSet::new();
469 rendered.insert("comp-a".to_string());
470 let plugin = WebUIHydrationPlugin::new();
471 plugin
472 .emit_templates(&protocol, &rendered, None, &mut writer)
473 .unwrap();
474 assert_eq!(writer.output, "", "empty template should not be emitted");
475 }
476
477 #[test]
478 fn test_on_render_complete_unknown_component() {
479 let mut writer = TestWriter::new();
480 let protocol = webui_protocol::WebUIProtocol::new(std::collections::HashMap::new());
481 let mut rendered = std::collections::HashSet::new();
482 rendered.insert("nonexistent-comp".to_string());
483 let plugin = WebUIHydrationPlugin::new();
484 plugin
485 .emit_templates(&protocol, &rendered, None, &mut writer)
486 .unwrap();
487 assert_eq!(
488 writer.output, "",
489 "unknown component should not cause error"
490 );
491 }
492
493 use std::collections::HashMap;
496 use webui_protocol::{ConditionExpr, FragmentList, WebUIFragment, WebUIProtocol};
497 use webui_test_utils::test_json;
498
499 use crate::{RenderOptions, WebUIHandler};
500
501 fn render_with_webui_plugin(protocol: &WebUIProtocol, state: &serde_json::Value) -> String {
502 let mut writer = TestWriter::new();
503 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
504 handler
505 .handle(
506 protocol,
507 state,
508 &RenderOptions::new("index.html", "/"),
509 &mut writer,
510 )
511 .unwrap();
512 writer.output
513 }
514
515 #[test]
516 fn test_handler_emits_hydration_markers_for_loop_and_if() {
517 let mut fragments = HashMap::new();
519 fragments.insert(
520 "index.html".to_string(),
521 FragmentList {
522 fragments: vec![
523 WebUIFragment::for_loop("item", "items", "for-body"),
524 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
525 ],
526 },
527 );
528 fragments.insert(
529 "for-body".to_string(),
530 FragmentList {
531 fragments: vec![WebUIFragment::signal("item", false)],
532 },
533 );
534 fragments.insert(
535 "if-body".to_string(),
536 FragmentList {
537 fragments: vec![WebUIFragment::raw("<span>yes</span>")],
538 },
539 );
540 let protocol = WebUIProtocol::new(fragments);
541 let state = test_json!({"items": ["a", "b"], "show": true});
542 let output = render_with_webui_plugin(&protocol, &state);
543
544 assert!(
546 output.contains("<!--wr-->"),
547 "Expected repeat-start marker, got: {output}"
548 );
549 assert!(
550 output.contains("<!--/wr-->"),
551 "Expected repeat-end marker, got: {output}"
552 );
553 assert!(
554 output.contains("<!--wi-->"),
555 "Expected repeat-item marker, got: {output}"
556 );
557 assert_eq!(
559 output.matches("<!--wi-->").count(),
560 2,
561 "Expected 2 repeat-item markers, got: {output}"
562 );
563
564 assert!(
566 output.contains("<!--wc-->"),
567 "Expected cond-start marker, got: {output}"
568 );
569 assert!(
570 output.contains("<!--/wc-->"),
571 "Expected cond-end marker, got: {output}"
572 );
573
574 assert!(
576 output.contains("a"),
577 "Expected for-loop items in output, got: {output}"
578 );
579 assert!(
580 output.contains("b"),
581 "Expected for-loop items in output, got: {output}"
582 );
583 assert!(
584 output.contains("<span>yes</span>"),
585 "Expected if-condition body, got: {output}"
586 );
587 }
588}