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