1pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12pub mod route_handler;
13pub mod route_matcher;
14pub(crate) mod route_renderer;
15pub(crate) mod streaming;
16
17pub use route_handler::Protocol;
18
19pub use html_encode::encode_safe;
29
30use plugin::BootstrapExtensionContext;
31use plugin::HandlerPlugin;
32use plugin::WebUiTemplatePayload;
33use route_matcher::CompiledRouteIndex;
34use serde::ser::SerializeMap;
35use serde::Serialize;
36use serde_json::Value;
37use std::borrow::Cow;
38use std::collections::{HashMap, HashSet};
39use streaming::{
40 consume_streaming_component_root, ensure_no_pending_streaming_root,
41 prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
42 validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
43 StreamingRenderState,
44};
45pub use streaming::{
46 BoundaryId, BoundaryMode, BufferSink, SessionOptions, StreamingResponse, StreamingSession,
47};
48use thiserror::Error;
49use webui_expressions::{evaluate_with_resolver, ExpressionError};
50use webui_protocol::{
51 web_ui_fragment::Fragment, InitialStateStrategy, StateProjectionMode, WebUIFragment,
52 WebUIProtocol,
53};
54use webui_state::find_value_by_dotted_path_ref;
55
56#[derive(Debug, Error)]
58pub enum HandlerError {
59 #[error("Rendering error: {0}")]
60 Rendering(String),
61
62 #[error("Rendering invariant error: {0}")]
63 Invariant(String),
64
65 #[error("Missing fragment: {0}")]
66 MissingFragment(String),
67
68 #[error("Missing data field: {0}")]
69 MissingData(String),
70
71 #[error("Type error: {0}")]
72 TypeError(String),
73
74 #[error("Protocol error: {0}")]
75 Protocol(#[from] webui_protocol::ProtocolError),
76
77 #[error("Evaluation error: {0}")]
78 Evaluation(String),
79
80 #[error("I/O error: {0}")]
81 Io(#[from] std::io::Error),
82
83 #[error("Writer error: {0}")]
84 Writer(String),
85
86 #[error("Plugin data error: {0}")]
87 PluginData(String),
88
89 #[error("client disconnected")]
96 ClientDisconnected,
97
98 #[error("streaming flush timed out")]
105 StreamTimeout,
106
107 #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
114 StreamingBoundary(Box<StreamingBoundaryError>),
115
116 #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
118 MissingStreamingBodyEnd,
119
120 #[error(
122 "streaming protocol is missing the required `head_start` signal before `{before}`; \
123 rebuild the protocol with streaming-boundary parser support"
124 )]
125 MissingStreamingHeadStart {
126 before: &'static str,
128 },
129
130 #[error("streaming protocol emitted duplicate `head_start` signals")]
132 DuplicateStreamingHeadStart,
133}
134
135#[derive(Debug)]
142pub struct StreamingBoundaryError {
143 pub signal: String,
145 pub reason: String,
147}
148
149pub type Result<T> = std::result::Result<T, HandlerError>;
150
151#[cold]
152#[inline(never)]
153fn invalid_fragment_range_error(
154 range: &std::ops::Range<usize>,
155 fragment_count: usize,
156) -> HandlerError {
157 HandlerError::Invariant(format!(
158 "fragment range {}..{} exceeds fragment count {fragment_count}",
159 range.start, range.end
160 ))
161}
162
163pub trait ResponseWriter {
165 fn write(&mut self, content: &str) -> Result<()>;
167
168 fn end(&mut self) -> Result<()>;
170
171 #[doc(hidden)]
179 fn stream_flush(&mut self) -> Result<()> {
180 Ok(())
181 }
182}
183
184pub trait FlushWriter: ResponseWriter {
189 fn flush(&mut self) -> Result<()>;
191}
192
193pub struct RenderOptions<'a> {
198 pub entry_id: &'a str,
200 pub request_path: &'a str,
202 pub nonce: Option<&'a str>,
206 pub head_inject: Option<&'a str>,
214 pub body_inject: Option<&'a str>,
219}
220
221impl<'a> RenderOptions<'a> {
222 #[must_use]
224 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
225 Self {
226 entry_id,
227 request_path,
228 nonce: None,
229 head_inject: None,
230 body_inject: None,
231 }
232 }
233
234 #[must_use]
238 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
239 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
240 self
241 }
242
243 #[must_use]
258 pub fn with_head_inject(mut self, html: &'a str) -> Self {
259 self.head_inject = if html.is_empty() { None } else { Some(html) };
260 self
261 }
262
263 #[must_use]
273 pub fn with_body_inject(mut self, html: &'a str) -> Self {
274 self.body_inject = if html.is_empty() { None } else { Some(html) };
275 self
276 }
277}
278
279pub struct WebUIHandler {
284 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
285}
286
287pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
289 pub(crate) protocol: &'protocol WebUIProtocol,
290 pub(crate) legacy_structural_signals: bool,
294 pub(crate) state: &'state Value,
295 pub(crate) writer: &'output mut dyn ResponseWriter,
296 pub(crate) local_vars: HashMap<String, Value>,
297 pub(crate) component_attrs: HashMap<String, Value>,
299 pub(crate) request_path: &'protocol str,
302 pub(crate) route_base: Cow<'protocol, str>,
307 pub(crate) rendered_components: HashSet<String>,
311 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
313 pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
316 pub(crate) entry_id: &'protocol str,
319 pub(crate) nonce: Option<&'protocol str>,
322 pub(crate) component_index: &'protocol HashMap<String, u32>,
325 pub(crate) head_inject: Option<&'protocol str>,
331 pub(crate) body_inject: Option<&'protocol str>,
335 pub(crate) head_end_emitted: bool,
342 pub(crate) body_end_emitted: bool,
347 pub(crate) route_index: &'protocol CompiledRouteIndex,
349 pub(crate) route_chain_index: usize,
353 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
355 pub(crate) json_scratch: Vec<u8>,
362 pub(crate) scope_pool: Vec<HashMap<String, Value>>,
368}
369
370pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
373
374pub(crate) fn structural_signal_value(
375 signal: &webui_protocol::WebUIFragmentSignal,
376) -> Option<&str> {
377 if !signal.raw {
378 return None;
379 }
380 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
381}
382
383fn legacy_structural_signal_value(signal: &webui_protocol::WebUIFragmentSignal) -> Option<&str> {
384 if !signal.raw {
385 return None;
386 }
387 match signal.value.as_str() {
388 "head_end" | "body_start" | "body_end" => Some(signal.value.as_str()),
389 _ => None,
390 }
391}
392
393const SCOPE_POOL_CAP: usize = 8;
396
397fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
400 pool.pop().unwrap_or_default()
401}
402
403fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
407 if pool.len() < SCOPE_POOL_CAP {
408 map.clear();
409 pool.push(map);
410 }
411}
412
413pub(crate) struct WebUiBootstrap<'a> {
414 pub(crate) state: &'a Value,
415 pub(crate) state_selection: StateSelection<'a>,
416 pub(crate) chain: &'a [Value],
417 pub(crate) inventory: &'a str,
418 pub(crate) nonce: Option<&'a str>,
419 pub(crate) css_hrefs: &'a [&'a str],
420 pub(crate) style_specs: &'a [&'a str],
421 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
422}
423
424fn component_attr_name(name: &str) -> String {
430 let stripped = name.strip_prefix(':').unwrap_or(name);
431 webui_protocol::attrs::attribute_to_camel(stripped)
432}
433
434pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
436 if n == 0 {
437 return writer.write("0");
438 }
439 let mut buf = [0u8; 20];
441 let mut pos = buf.len();
442 while n > 0 {
443 pos -= 1;
444 #[allow(clippy::cast_possible_truncation)]
446 let digit = (n % 10) as u8;
447 buf[pos] = b'0' + digit;
448 n /= 10;
449 }
450 match std::str::from_utf8(&buf[pos..]) {
452 Ok(s) => writer.write(s),
453 Err(_) => writer.write("0"),
454 }
455}
456
457pub(crate) fn write_script_safe_json<T>(
458 writer: &mut dyn ResponseWriter,
459 scratch: &mut Vec<u8>,
460 value: &T,
461) -> Result<()>
462where
463 T: Serialize + ?Sized,
464{
465 scratch.clear();
473 serde_json::to_writer(&mut *scratch, value)
474 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
475 let json = std::str::from_utf8(scratch)
476 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
477 write_script_safe_json_str(writer, json)
478}
479
480fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
481 let mut start = 0;
482 while start < json.len() {
483 let rest = &json[start..];
484 let Some(offset) = rest.find("</") else {
485 writer.write(rest)?;
486 return Ok(());
487 };
488
489 if offset > 0 {
490 writer.write(&rest[..offset])?;
491 }
492 writer.write("<\\/")?;
493 start += offset + 2;
494 }
495 Ok(())
496}
497
498fn write_json_field_name(
499 writer: &mut dyn ResponseWriter,
500 wrote_field: &mut bool,
501 name: &str,
502) -> Result<()> {
503 if *wrote_field {
504 writer.write(",")?;
505 }
506 *wrote_field = true;
507 writer.write("\"")?;
508 writer.write(name)?;
509 writer.write("\":")
510}
511
512fn write_json_field<T>(
513 writer: &mut dyn ResponseWriter,
514 scratch: &mut Vec<u8>,
515 wrote_field: &mut bool,
516 name: &str,
517 value: &T,
518) -> Result<()>
519where
520 T: Serialize + ?Sized,
521{
522 write_json_field_name(writer, wrote_field, name)?;
523 write_script_safe_json(writer, scratch, value)
524}
525
526struct ProjectedState<'a> {
544 value: &'a Value,
545 keys: &'a [&'a str],
546}
547
548impl Serialize for ProjectedState<'_> {
549 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
550 where
551 S: serde::Serializer,
552 {
553 let Value::Object(map) = self.value else {
554 return serializer.serialize_map(Some(0))?.end();
555 };
556
557 let mut out = serializer.serialize_map(None)?;
558 if self.keys.len() < map.len() {
559 let mut previous = None;
560 for key in self.keys {
561 if previous == Some(*key) {
562 continue;
563 }
564 previous = Some(*key);
565 if let Some(value) = map.get(*key) {
566 out.serialize_entry(key, value)?;
567 }
568 }
569 } else {
570 for (key, value) in map {
571 if self
572 .keys
573 .binary_search_by(|candidate| candidate.cmp(&key.as_str()))
574 .is_ok()
575 {
576 out.serialize_entry(key, value)?;
577 }
578 }
579 }
580 out.end()
581 }
582}
583
584pub(crate) fn write_selected_state(
597 writer: &mut dyn ResponseWriter,
598 scratch: &mut Vec<u8>,
599 state: &Value,
600 selection: &StateSelection<'_>,
601) -> Result<()> {
602 let keys = match selection {
603 StateSelection::Full => return write_script_safe_json(writer, scratch, state),
604 StateSelection::Keys(keys) => keys.as_slice(),
605 StateSelection::BorrowedKeys(keys) => *keys,
606 };
607 if keys.is_empty() {
608 return writer.write("{}");
609 }
610
611 debug_assert!(
616 keys.windows(2).all(|pair| pair[0] <= pair[1]),
617 "hydration keys must be sorted for binary-search projection"
618 );
619 if let Value::Object(map) = state {
620 let selects_entire_map =
621 keys.len() == map.len() && keys.iter().copied().eq(map.keys().map(String::as_str));
622 if selects_entire_map {
623 return write_script_safe_json(writer, scratch, state);
624 }
625 }
626 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
627}
628
629pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
632
633pub(crate) enum StateSelection<'a> {
635 Full,
637 Keys(Vec<&'a str>),
639 BorrowedKeys(&'a [&'a str]),
641}
642
643#[derive(Clone, Copy)]
644enum ComponentStateSurface {
645 Hydration,
646 Navigation,
647}
648
649pub(crate) fn collect_hydration_state<'a, 'b>(
655 protocol: &'a WebUIProtocol,
656 components: impl IntoIterator<Item = &'b str>,
657) -> StateSelection<'a> {
658 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
659 return StateSelection::Full;
660 }
661 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
662}
663
664pub(crate) fn collect_hydration_state_into<'a, 'b>(
665 protocol: &'a WebUIProtocol,
666 components: impl IntoIterator<Item = &'b str>,
667 keys: &mut Vec<&'a str>,
668) -> bool {
669 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
670 keys.clear();
671 return true;
672 }
673 collect_component_state_into(protocol, components, ComponentStateSurface::Hydration, keys)
674}
675
676pub(crate) fn collect_navigation_state<'a, 'b>(
678 protocol: &'a WebUIProtocol,
679 components: impl IntoIterator<Item = &'b str>,
680) -> StateSelection<'a> {
681 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
682}
683
684fn collect_component_state<'a, 'b>(
685 protocol: &'a WebUIProtocol,
686 components: impl IntoIterator<Item = &'b str>,
687 surface: ComponentStateSurface,
688) -> StateSelection<'a> {
689 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
690 if collect_component_state_into(protocol, components, surface, &mut keys) {
691 StateSelection::Full
692 } else {
693 StateSelection::Keys(keys)
694 }
695}
696
697fn collect_component_state_into<'a, 'b>(
700 protocol: &'a WebUIProtocol,
701 components: impl IntoIterator<Item = &'b str>,
702 surface: ComponentStateSurface,
703 keys: &mut Vec<&'a str>,
704) -> bool {
705 keys.clear();
706 for name in components {
707 let Some(component) = protocol.components.get(name) else {
708 return true;
709 };
710 let (mode, component_keys) = match surface {
711 ComponentStateSurface::Hydration => {
712 (component.hydration_mode, &component.hydration_keys)
713 }
714 ComponentStateSurface::Navigation => (
715 match component.navigation_mode {
716 Some(mode) => mode,
717 None if !component.navigation_keys.is_empty() => {
718 StateProjectionMode::Keys as i32
719 }
720 None => return true,
721 },
722 &component.navigation_keys,
723 ),
724 };
725 if mode == StateProjectionMode::All as i32 {
726 return true;
727 }
728 if mode == StateProjectionMode::Keys as i32
729 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
730 {
731 keys.extend(component_keys.iter().map(String::as_str));
732 } else if mode != StateProjectionMode::None as i32 {
733 return true;
734 }
735 }
736 keys.sort_unstable();
737 keys.dedup();
738 false
739}
740
741pub(crate) fn write_webui_bootstrap(
742 writer: &mut dyn ResponseWriter,
743 scratch: &mut Vec<u8>,
744 bootstrap: WebUiBootstrap<'_>,
745) -> Result<()> {
746 let mut wrote_field = false;
747
748 writer.write("{")?;
749 if !bootstrap.chain.is_empty() {
750 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
751 }
752 if !bootstrap.css_hrefs.is_empty() {
753 write_json_field(
754 writer,
755 scratch,
756 &mut wrote_field,
757 "css",
758 bootstrap.css_hrefs,
759 )?;
760 }
761 write_json_field(
762 writer,
763 scratch,
764 &mut wrote_field,
765 "inventory",
766 bootstrap.inventory,
767 )?;
768 if let Some(nonce) = bootstrap.nonce {
769 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
770 }
771 write_json_field_name(writer, &mut wrote_field, "state")?;
772 write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
773 if !bootstrap.style_specs.is_empty() {
774 write_json_field(
775 writer,
776 scratch,
777 &mut wrote_field,
778 "styles",
779 bootstrap.style_specs,
780 )?;
781 }
782 if bootstrap
783 .templates
784 .iter()
785 .any(|template| !template.template_json.is_empty())
786 {
787 write_json_field_name(writer, &mut wrote_field, "templates")?;
788 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
789 }
790 writer.write("}")
791}
792
793fn write_webui_data_block(
794 writer: &mut dyn ResponseWriter,
795 scratch: &mut Vec<u8>,
796 bootstrap: WebUiBootstrap<'_>,
797) -> Result<()> {
798 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
799 if let Some(nonce) = bootstrap.nonce {
800 writer.write(" nonce=\"")?;
801 writer.write(nonce)?;
802 writer.write("\"")?;
803 }
804 writer.write(">")?;
805 write_webui_bootstrap(writer, scratch, bootstrap)?;
806 writer.write("</script>\n")
807}
808
809fn write_webui_template_json_map(
810 writer: &mut dyn ResponseWriter,
811 scratch: &mut Vec<u8>,
812 templates: &[WebUiTemplatePayload<'_>],
813) -> Result<()> {
814 writer.write("{")?;
815 let mut wrote = false;
816 for template in templates {
817 if template.template_json.is_empty() {
818 continue;
819 }
820 if wrote {
821 writer.write(",")?;
822 }
823 wrote = true;
824 write_script_safe_json(writer, scratch, template.tag_name)?;
825 writer.write(":")?;
826 write_script_safe_json_str(writer, template.template_json)?;
827 }
828 writer.write("}")
829}
830
831fn resolve_value_from_sources<'ctx, 'state>(
832 path: &str,
833 local_vars: &'ctx HashMap<String, Value>,
834 state: &'state Value,
835) -> Option<Cow<'ctx, Value>>
836where
837 'state: 'ctx,
838{
839 if let Some(first_part) = path.split('.').next() {
840 if let Some(local_value) = local_vars.get(first_part) {
841 if first_part.len() == path.len() {
842 return Some(Cow::Borrowed(local_value));
843 }
844 let remaining = &path[first_part.len() + 1..];
845 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
846 return Some(value);
847 }
848 }
849 }
850
851 find_value_by_dotted_path_ref(path, state)
852}
853
854impl WebUIHandler {
855 pub fn new() -> Self {
857 Self {
858 plugin_factory: None,
859 }
860 }
861
862 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
867 Self {
868 plugin_factory: Some(factory),
869 }
870 }
871
872 #[cfg(test)]
873 fn handle(
874 &self,
875 document: &WebUIProtocol,
876 state: &Value,
877 options: &RenderOptions<'_>,
878 writer: &mut dyn ResponseWriter,
879 ) -> Result<()> {
880 let protocol = Protocol::new(document.clone());
881 self.render(&protocol, state, options, writer)
882 }
883
884 fn process_fragment_id<'data>(
889 &self,
890 fragment_id: &str,
891 context: &mut WebUIProcessContext<'data, '_, '_>,
892 ) -> Result<()> {
893 if let Some(fragment_list) = context.protocol.fragments.get(fragment_id) {
894 self.process_fragment(&fragment_list.fragments, context)
895 } else {
896 Err(HandlerError::MissingFragment(fragment_id.to_string()))
897 }
898 }
899
900 fn process_fragment<'data>(
905 &self,
906 fragments: &'data [WebUIFragment],
907 context: &mut WebUIProcessContext<'data, '_, '_>,
908 ) -> Result<()> {
909 let best_route = route_renderer::find_best_route_match(
913 fragments,
914 context.request_path,
915 &context.route_base,
916 context.route_index,
917 );
918 self.process_fragment_range(fragments, 0..fragments.len(), &best_route, context)
919 }
920
921 fn process_fragment_range<'data>(
922 &self,
923 fragments: &'data [WebUIFragment],
924 range: std::ops::Range<usize>,
925 best_route: &Option<(String, route_matcher::RouteMatch)>,
926 context: &mut WebUIProcessContext<'data, '_, '_>,
927 ) -> Result<()> {
928 let Some(selected) = fragments.get(range.clone()) else {
929 return Err(invalid_fragment_range_error(&range, fragments.len()));
930 };
931 for (offset, item) in selected.iter().enumerate() {
932 let index = range.start + offset;
933 if context.streaming.is_some() {
934 validate_pending_streaming_root(item, context)?;
935 validate_streaming_root_opening(&fragments[..index], item)?;
936 }
937 match item.fragment.as_ref() {
938 Some(Fragment::Raw(raw)) => {
939 context.writer.write(&raw.value)?;
940 }
941 Some(Fragment::Component(component)) => {
942 self.process_component(
943 component,
944 ComponentHostOrigin::ParserProduced,
945 context,
946 )?;
947 }
948 Some(Fragment::ForLoop(for_loop)) => {
949 self.process_for_loop(for_loop, context)?;
950 }
951 Some(Fragment::Signal(signal)) => {
952 self.process_signal(signal, context)?;
953 }
954 Some(Fragment::IfCond(if_cond)) => {
955 self.process_if(if_cond, context)?;
956 }
957 Some(Fragment::Attribute(attr)) => {
958 self.process_attribute(attr, context)?;
959 }
960 Some(Fragment::Plugin(plugin_frag)) => {
961 if let Some(p) = &mut context.plugin {
962 p.on_element_data(&plugin_frag.data, context.writer)?;
963 }
964 }
965 Some(Fragment::Route(route_frag)) => {
966 self.process_route(route_frag, best_route, context)?;
967 }
968 Some(Fragment::Outlet(_)) => {
969 self.process_outlet(context)?;
970 }
971 None => {}
972 }
973 }
974 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
975 }
976
977 fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
983 let mut children = std::mem::take(&mut context.route_children);
984 if children.is_empty() {
985 return Ok(());
986 }
987
988 let request_segments = route_matcher::split_request_path(context.request_path);
990 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
991 for (idx, child) in children.iter().enumerate() {
992 if let Some(m) = route_matcher::match_route_indexed_with_segments(
993 context.route_index,
994 &child.path,
995 &context.route_base,
996 &request_segments,
997 child.exact,
998 ) {
999 let is_better = best
1000 .as_ref()
1001 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
1002 if is_better {
1003 best = Some((idx, m));
1004 }
1005 }
1006 }
1007
1008 let grandchildren = if let Some((idx, _)) = &best {
1012 std::mem::take(&mut children[*idx].children)
1013 } else {
1014 Vec::new()
1015 };
1016
1017 if let Some((idx, ref rm)) = best {
1018 let matched_child = &children[idx];
1019 let comp = &matched_child.fragment_id;
1020
1021 if !comp.is_empty() {
1022 let saved_route_base = context.route_base.clone();
1023 let saved_route_children = std::mem::take(&mut context.route_children);
1024
1025 if rm.consumed_segments > 0 {
1026 context.route_base = Cow::Owned(route_matcher::compute_route_base(
1027 context.request_path,
1028 rm.consumed_segments,
1029 ));
1030 }
1031
1032 context.route_children = grandchildren;
1033
1034 context.writer.write("<webui-route")?;
1036 if !matched_child.path.is_empty() {
1037 context.writer.write(" path=\"")?;
1038 context.writer.write(&matched_child.path)?;
1039 context.writer.write("\"")?;
1040 }
1041 context.writer.write(" component=\"")?;
1042 context.writer.write(comp)?;
1043 context.writer.write("\"")?;
1044 if matched_child.exact {
1045 context.writer.write(" exact")?;
1046 }
1047 route_renderer::write_route_pending_attrs(context.writer, matched_child)?;
1048 let ri = context.route_chain_index;
1050 context.route_chain_index += 1;
1051 context.writer.write(" data-ri=\"")?;
1052 write_usize(context.writer, ri)?;
1053 context.writer.write("\" active>")?;
1054
1055 context.writer.write("<")?;
1056 context.writer.write(comp)?;
1057 if let Some(p) = &context.plugin {
1058 p.write_route_component_state(context.state, context.writer)?;
1059 }
1060 prepare_generated_streaming_root(comp, context)?;
1061 context.writer.write(">")?;
1062
1063 self.process_component(
1064 &webui_protocol::WebUIFragmentComponent {
1065 fragment_id: comp.clone(),
1066 },
1067 ComponentHostOrigin::HandlerGenerated,
1068 context,
1069 )?;
1070
1071 context.writer.write("</")?;
1072 context.writer.write(comp)?;
1073 context.writer.write(">")?;
1074 context.writer.write("</webui-route>")?;
1075
1076 context.route_base = saved_route_base;
1077 context.route_children = saved_route_children;
1078 }
1079 }
1080
1081 for (idx, child) in children.iter().enumerate() {
1083 let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
1084 if !is_matched && !child.fragment_id.is_empty() {
1085 context.writer.write("<webui-route")?;
1086 if !child.path.is_empty() {
1087 context.writer.write(" path=\"")?;
1088 context.writer.write(&child.path)?;
1089 context.writer.write("\"")?;
1090 }
1091 context.writer.write(" component=\"")?;
1092 context.writer.write(&child.fragment_id)?;
1093 context.writer.write("\"")?;
1094 if child.exact {
1095 context.writer.write(" exact")?;
1096 }
1097 route_renderer::write_route_pending_attrs(context.writer, child)?;
1098 context
1099 .writer
1100 .write(" style=\"display:none\"></webui-route>")?;
1101 }
1102 }
1103
1104 Ok(())
1105 }
1106
1107 fn emit_css_module_importmap(
1118 &self,
1119 specifier: &str,
1120 css: &str,
1121 context: &mut WebUIProcessContext,
1122 ) -> Result<()> {
1123 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
1124 context.writer.write(&tag)?;
1125 Ok(())
1126 }
1127
1128 fn emit_css_module(
1138 &self,
1139 component: &webui_protocol::WebUIFragmentComponent,
1140 context: &mut WebUIProcessContext,
1141 ) -> Result<()> {
1142 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
1143 streaming_template_already_sent(
1144 streaming,
1145 context.component_index,
1146 &component.fragment_id,
1147 )
1148 });
1149 if !metadata_already_streamed
1150 && !context.rendered_components.contains(&component.fragment_id)
1151 {
1152 if let Some(css) = context
1153 .protocol
1154 .components
1155 .get(&component.fragment_id)
1156 .map(|c| c.css.as_str())
1157 .filter(|s| !s.is_empty())
1158 {
1159 self.emit_css_module_importmap(&component.fragment_id, css, context)?;
1160 }
1161 }
1162 Ok(())
1163 }
1164
1165 fn process_route(
1167 &self,
1168 route_frag: &webui_protocol::WebUiFragmentRoute,
1169 best_route: &Option<(String, route_matcher::RouteMatch)>,
1170 context: &mut WebUIProcessContext,
1171 ) -> Result<()> {
1172 let is_matched = best_route
1173 .as_ref()
1174 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
1175
1176 context.writer.write("<webui-route")?;
1177 if !route_frag.path.is_empty() {
1178 context.writer.write(" path=\"")?;
1179 context.writer.write(&route_frag.path)?;
1180 context.writer.write("\"")?;
1181 }
1182 if !route_frag.fragment_id.is_empty() {
1183 context.writer.write(" component=\"")?;
1184 context.writer.write(&route_frag.fragment_id)?;
1185 context.writer.write("\"")?;
1186 }
1187 if route_frag.exact {
1188 context.writer.write(" exact")?;
1189 }
1190 route_renderer::write_route_pending_attrs(context.writer, route_frag)?;
1191
1192 if is_matched {
1193 let ri = context.route_chain_index;
1195 context.route_chain_index += 1;
1196 context.writer.write(" data-ri=\"")?;
1197 write_usize(context.writer, ri)?;
1198 context.writer.write("\" active>")?;
1199
1200 if !route_frag.fragment_id.is_empty() {
1201 let saved_route_base = context.route_base.clone();
1202 let saved_route_children = std::mem::take(&mut context.route_children);
1203 if let Some((_, ref rm)) = best_route {
1204 context.route_base = Cow::Owned(route_matcher::compute_route_base(
1205 context.request_path,
1206 rm.consumed_segments,
1207 ));
1208 }
1209
1210 context.route_children = route_frag.children.clone();
1211
1212 let comp = webui_protocol::WebUIFragmentComponent {
1213 fragment_id: route_frag.fragment_id.clone(),
1214 };
1215
1216 context.writer.write("<")?;
1217 context.writer.write(&route_frag.fragment_id)?;
1218 if let Some(p) = &context.plugin {
1219 p.write_route_component_state(context.state, context.writer)?;
1220 }
1221 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
1222 context.writer.write(">")?;
1223
1224 self.process_component(&comp, ComponentHostOrigin::HandlerGenerated, context)?;
1225
1226 context.writer.write("</")?;
1227 context.writer.write(&route_frag.fragment_id)?;
1228 context.writer.write(">")?;
1229
1230 context.route_base = saved_route_base;
1231 context.route_children = saved_route_children;
1232 }
1233 } else {
1234 context.writer.write(" style=\"display:none\">")?;
1235 }
1236
1237 context.writer.write("</webui-route>")?;
1238 Ok(())
1239 }
1240
1241 fn process_component(
1243 &self,
1244 component: &webui_protocol::WebUIFragmentComponent,
1245 origin: ComponentHostOrigin,
1246 context: &mut WebUIProcessContext,
1247 ) -> Result<()> {
1248 if context.streaming.is_some() {
1249 consume_streaming_component_root(&component.fragment_id, origin, context)?;
1250 record_checkpoint_tag(context, &component.fragment_id);
1253 }
1254
1255 if !context.rendered_components.contains(&component.fragment_id) {
1261 self.emit_css_module(component, context)?;
1262 context
1263 .rendered_components
1264 .insert(component.fragment_id.clone());
1265 }
1266
1267 let saved_local_vars = std::mem::take(&mut context.local_vars);
1269 let saved_component_attrs = std::mem::replace(
1273 &mut context.component_attrs,
1274 take_scope_map(&mut context.scope_pool),
1275 );
1276 context.local_vars = saved_component_attrs;
1277
1278 if let Some(p) = &mut context.plugin {
1279 p.push_scope();
1280 }
1281
1282 self.process_fragment_id(&component.fragment_id, context)?;
1283
1284 if let Some(p) = &mut context.plugin {
1285 p.pop_scope();
1286 }
1287
1288 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
1291 recycle_scope_map(&mut context.scope_pool, used_locals);
1292 context.component_attrs.clear();
1295
1296 Ok(())
1297 }
1298
1299 fn resolve_value(
1301 &self,
1302 path: &str,
1303 context: &WebUIProcessContext<'_, '_, '_>,
1304 ) -> Option<Value> {
1305 resolve_value_from_sources(path, &context.local_vars, context.state).map(Cow::into_owned)
1306 }
1307
1308 fn evaluate_condition(
1314 &self,
1315 condition: &webui_protocol::ConditionExpr,
1316 context: &WebUIProcessContext,
1317 ) -> Result<bool> {
1318 let local_vars = &context.local_vars;
1319 let state = context.state;
1320 match evaluate_with_resolver(condition, |path| {
1321 resolve_value_from_sources(path, local_vars, state)
1322 }) {
1323 Ok(result) => Ok(result),
1324 Err(ExpressionError::MissingValue(_)) => Ok(false),
1325 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
1326 }
1327 }
1328
1329 fn process_for_loop(
1335 &self,
1336 for_loop: &webui_protocol::WebUIFragmentFor,
1337 context: &mut WebUIProcessContext,
1338 ) -> Result<()> {
1339 let collection_name = &for_loop.collection;
1340
1341 let items = match self.resolve_value(collection_name, context) {
1344 Some(Value::Array(arr)) => arr,
1345 Some(_) => {
1346 return Err(HandlerError::TypeError(format!(
1347 "Collection '{}' is not an array",
1348 collection_name
1349 )))
1350 }
1351 None => Vec::new(),
1352 };
1353
1354 if let Some(p) = &mut context.plugin {
1355 p.on_for_start(&for_loop.fragment_id, context.writer)?;
1356 }
1357
1358 let item_name = for_loop.item.as_str();
1366 let saved_value = context.local_vars.remove(item_name);
1367 if !items.is_empty() {
1371 context
1372 .local_vars
1373 .insert(item_name.to_string(), Value::Null);
1374 }
1375 for (i, item) in items.into_iter().enumerate() {
1376 if let Some(p) = &mut context.plugin {
1377 p.on_repeat_item_start(i, context.writer)?;
1378 p.push_scope();
1379 }
1380
1381 if let Some(slot) = context.local_vars.get_mut(item_name) {
1383 *slot = item;
1384 }
1385 self.process_fragment_id(&for_loop.fragment_id, context)?;
1386
1387 if let Some(p) = &mut context.plugin {
1388 p.pop_scope();
1389 p.on_repeat_item_end(i, context.writer)?;
1390 }
1391 }
1392 match saved_value {
1394 Some(value) => {
1395 context.local_vars.insert(item_name.to_string(), value);
1396 }
1397 None => {
1398 context.local_vars.remove(item_name);
1399 }
1400 }
1401
1402 if let Some(p) = &mut context.plugin {
1403 p.on_for_end(&for_loop.fragment_id, context.writer)?;
1404 }
1405
1406 Ok(())
1407 }
1408
1409 #[inline]
1415 fn process_signal<'data>(
1416 &self,
1417 signal: &'data webui_protocol::WebUIFragmentSignal,
1418 context: &mut WebUIProcessContext<'data, '_, '_>,
1419 ) -> Result<()> {
1420 if signal.raw {
1421 self.process_raw_signal(signal, context)
1422 } else {
1423 self.process_state_signal(signal, context)
1424 }
1425 }
1426
1427 #[inline(never)]
1428 fn process_raw_signal<'data>(
1429 &self,
1430 signal: &'data webui_protocol::WebUIFragmentSignal,
1431 context: &mut WebUIProcessContext<'data, '_, '_>,
1432 ) -> Result<()> {
1433 let structural_value = structural_signal_value(signal).or_else(|| {
1434 context
1435 .legacy_structural_signals
1436 .then(|| legacy_structural_signal_value(signal))
1437 .flatten()
1438 });
1439 let Some(structural_value) = structural_value else {
1440 return self.process_state_signal(signal, context);
1441 };
1442
1443 if context.streaming.is_some()
1444 && self.process_streaming_signal(structural_value, context)?
1445 {
1446 return Ok(());
1447 }
1448
1449 if structural_value == "head_end" && !context.head_end_emitted {
1453 context.head_end_emitted = true;
1454 if let Some(nonce) = context.nonce {
1455 context
1456 .writer
1457 .write("<meta name=\"webui-nonce\" content=\"")?;
1458 context
1459 .writer
1460 .write(&crate::html_encode::encode_safe(nonce))?;
1461 context.writer.write("\">")?;
1462 }
1463
1464 let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1472 let is_shadow = context.protocol.dom_strategy() == webui_protocol::DomStrategy::Shadow;
1473
1474 if is_link {
1475 let (needed_components, _) =
1476 crate::route_handler::get_needed_components_for_request(
1477 context.protocol,
1478 context.entry_id,
1479 context.request_path,
1480 "",
1481 (context.component_index, context.route_index),
1482 )?;
1483
1484 for name in &needed_components {
1485 if let Some(href) = context
1486 .protocol
1487 .components
1488 .get(name)
1489 .map(|c| c.css_href.as_str())
1490 .filter(|h| !h.is_empty())
1491 {
1492 if is_shadow {
1493 context.writer.write("<link rel=\"preload\" href=\"")?;
1494 context.writer.write(href)?;
1495 context
1496 .writer
1497 .write("\" as=\"style\" data-webui-ssr-preload=\"style\">")?;
1498 } else {
1499 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
1500 context.writer.write(href)?;
1501 context.writer.write("\">")?;
1502 }
1503 }
1504 }
1505 }
1506
1507 if !context.protocol.module_preloads.is_empty() {
1519 for href in &context.protocol.module_preloads {
1520 context
1521 .writer
1522 .write("<link rel=\"modulepreload\" href=\"")?;
1523 context.writer.write(href)?;
1524 context.writer.write("\">")?;
1525 }
1526 }
1527
1528 if let Some(html) = context.head_inject {
1534 context.writer.write(html)?;
1535 }
1536 }
1537
1538 if structural_value == "body_end" && !context.body_end_emitted {
1543 context.body_end_emitted = true;
1544 if context.plugin.is_some() {
1545 let reachable = crate::route_handler::collect_reachable_components_for_request(
1553 context.protocol,
1554 context.entry_id,
1555 context.request_path,
1556 context.route_index,
1557 );
1558 let state_selection =
1559 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
1560
1561 for name in &reachable {
1565 if !context.rendered_components.contains(name) {
1566 if let Some(css) = context
1567 .protocol
1568 .components
1569 .get(name)
1570 .map(|c| c.css.as_str())
1571 .filter(|s| !s.is_empty())
1572 {
1573 self.emit_css_module_importmap(name, css, context)?;
1574 }
1575 }
1576 }
1577
1578 let template_payloads = context
1582 .plugin
1583 .as_ref()
1584 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
1585
1586 if template_payloads.is_none() {
1587 if let Some(ref p) = context.plugin {
1589 p.emit_templates(
1590 context.protocol,
1591 &reachable,
1592 context.nonce,
1593 context.writer,
1594 )?;
1595 }
1596 }
1597
1598 let inventory_hex = crate::route_handler::encode_component_inventory(
1600 &context.rendered_components,
1601 context.component_index,
1602 );
1603
1604 let chain = crate::route_handler::collect_route_chain(
1606 context.protocol,
1607 context.entry_id,
1608 context.request_path,
1609 context.route_index,
1610 );
1611 let chain_json: Vec<Value> = chain
1612 .iter()
1613 .map(crate::route_handler::RouteChainEntry::to_json)
1614 .collect();
1615
1616 let is_link = context.protocol.css_strategy() == webui_protocol::CssStrategy::Link;
1618 let mut css_hrefs: Vec<&str> = Vec::new();
1619 if is_link {
1620 for name in &reachable {
1621 if let Some(href) = context
1622 .protocol
1623 .components
1624 .get(name)
1625 .map(|c| c.css_href.as_str())
1626 .filter(|h| !h.is_empty())
1627 {
1628 css_hrefs.push(href);
1629 }
1630 }
1631 }
1632
1633 let mut style_specs: Vec<&str> = Vec::new();
1635 for name in &reachable {
1636 if context
1637 .protocol
1638 .components
1639 .get(name)
1640 .map(|c| !c.css.is_empty())
1641 .unwrap_or(false)
1642 {
1643 style_specs.push(name);
1644 }
1645 }
1646
1647 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
1648 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
1649 write_webui_data_block(
1650 context.writer,
1651 &mut context.json_scratch,
1652 WebUiBootstrap {
1653 state: context.state,
1654 state_selection,
1655 chain: &chain_json,
1656 inventory: &inventory_hex,
1657 nonce: context.nonce,
1658 css_hrefs: &css_hrefs,
1659 style_specs: &style_specs,
1660 templates: payloads,
1661 },
1662 )?;
1663
1664 if let Some(ref plugin) = context.plugin {
1668 plugin.emit_bootstrap_extension(
1669 BootstrapExtensionContext {
1670 protocol: context.protocol,
1671 components: &reachable,
1672 payloads,
1673 nonce: context.nonce,
1674 },
1675 context.writer,
1676 )?;
1677 }
1678 }
1679
1680 if let Some(html) = context.body_inject {
1687 context.writer.write(html)?;
1688 }
1689 }
1690
1691 Ok(())
1695 }
1696
1697 #[inline]
1698 fn process_state_signal(
1699 &self,
1700 signal: &webui_protocol::WebUIFragmentSignal,
1701 context: &mut WebUIProcessContext,
1702 ) -> Result<()> {
1703 if let Some(p) = &mut context.plugin {
1704 p.on_binding_start(&signal.value, context.writer)?;
1705 }
1706
1707 if let Some(value) = self.resolve_value(&signal.value, context) {
1708 self.write_signal_value(&value, signal.raw, context.writer)?;
1709 }
1710
1711 if let Some(p) = &mut context.plugin {
1712 p.on_binding_end(&signal.value, context.writer)?;
1713 }
1714 Ok(())
1715 }
1716
1717 fn write_signal_value(
1720 &self,
1721 value: &Value,
1722 raw: bool,
1723 writer: &mut dyn ResponseWriter,
1724 ) -> Result<()> {
1725 if raw {
1726 match value {
1727 Value::String(s) => writer.write(s),
1728 _ => writer.write(&value.to_string()),
1729 }
1730 } else {
1731 match value {
1732 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
1733 _ => {
1734 let s = value.to_string();
1735 writer.write(&crate::html_encode::encode_safe(&s))
1736 }
1737 }
1738 }
1739 }
1740
1741 fn process_if(
1743 &self,
1744 if_cond: &webui_protocol::WebUIFragmentIf,
1745 context: &mut WebUIProcessContext,
1746 ) -> Result<()> {
1747 let condition = if_cond
1748 .condition
1749 .as_ref()
1750 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
1751 let condition_met = self.evaluate_condition(condition, context)?;
1752
1753 if let Some(p) = &mut context.plugin {
1754 p.on_if_start(&if_cond.fragment_id, context.writer)?;
1755 }
1756
1757 if condition_met {
1758 if let Some(p) = &mut context.plugin {
1759 p.push_scope();
1760 }
1761
1762 self.process_fragment_id(&if_cond.fragment_id, context)?;
1763
1764 if let Some(p) = &mut context.plugin {
1765 p.pop_scope();
1766 }
1767 }
1768
1769 if let Some(p) = &mut context.plugin {
1770 p.on_if_end(&if_cond.fragment_id, context.writer)?;
1771 }
1772
1773 Ok(())
1774 }
1775
1776 fn process_attribute(
1778 &self,
1779 attr: &webui_protocol::WebUIFragmentAttribute,
1780 context: &mut WebUIProcessContext,
1781 ) -> Result<()> {
1782 if attr.attr_start {
1784 context.component_attrs = HashMap::new();
1785 }
1786
1787 if let Some(condition) = &attr.condition_tree {
1789 let condition_met = self.evaluate_condition(condition, context)?;
1790
1791 if !attr.attr_skip {
1792 let name = component_attr_name(&attr.name);
1793 context
1794 .component_attrs
1795 .insert(name, Value::Bool(condition_met));
1796 }
1797
1798 if condition_met {
1799 context.writer.write(" ")?;
1800 context.writer.write(&attr.name)?;
1801 }
1802 return Ok(());
1803 }
1804
1805 if !attr.template.is_empty() {
1807 let raw_value = self.render_template_attr_value(&attr.template, context)?;
1808 let escaped = crate::html_encode::encode_safe(&raw_value);
1809 write_attr(context.writer, &attr.name, &escaped)?;
1810
1811 if !attr.attr_skip {
1812 let name = component_attr_name(&attr.name);
1813 context
1814 .component_attrs
1815 .insert(name, Value::String(raw_value));
1816 }
1817 return Ok(());
1818 }
1819
1820 if !attr.value.is_empty() {
1822 if attr.raw_value {
1823 write_attr(context.writer, &attr.name, &attr.value)?;
1825 if !attr.attr_skip {
1826 let name = component_attr_name(&attr.name);
1827 context
1828 .component_attrs
1829 .insert(name, Value::String(attr.value.clone()));
1830 }
1831 } else if attr.complex {
1832 if let Some(value) = self.resolve_value(&attr.value, context) {
1834 if !attr.attr_skip {
1835 let stripped = attr.name.strip_prefix(':').unwrap_or(&attr.name);
1836 let name = component_attr_name(stripped);
1837 context.component_attrs.insert(name, value);
1838 }
1839 }
1840 } else {
1841 let value = self.resolve_value(&attr.value, context);
1843 match &value {
1846 Some(Value::String(s)) => {
1847 write_attr(
1848 context.writer,
1849 &attr.name,
1850 &crate::html_encode::encode_safe(s),
1851 )?;
1852 }
1853 Some(Value::Null) | None => {
1854 write_attr(context.writer, &attr.name, "")?;
1855 }
1856 Some(other) => {
1857 let s = other.to_string();
1858 write_attr(
1859 context.writer,
1860 &attr.name,
1861 &crate::html_encode::encode_safe(&s),
1862 )?;
1863 }
1864 }
1865
1866 if !attr.attr_skip {
1867 let name = component_attr_name(&attr.name);
1868 context
1869 .component_attrs
1870 .insert(name, value.unwrap_or(Value::String(String::new())));
1871 }
1872 }
1873 }
1874
1875 Ok(())
1876 }
1877
1878 fn render_template_attr_value(
1880 &self,
1881 template_id: &str,
1882 context: &WebUIProcessContext,
1883 ) -> Result<String> {
1884 let fragments = context
1885 .protocol
1886 .fragments
1887 .get(template_id)
1888 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
1889 let mut raw_value = String::new();
1890 for frag in &fragments.fragments {
1891 match frag.fragment.as_ref() {
1892 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
1893 Some(Fragment::Signal(signal)) => {
1894 if let Some(value) = self.resolve_value(&signal.value, context) {
1895 match &value {
1896 Value::String(s) => raw_value.push_str(s),
1897 _ => raw_value.push_str(&value.to_string()),
1898 }
1899 }
1900 }
1901 _ => {}
1902 }
1903 }
1904 Ok(raw_value)
1905 }
1906
1907 pub fn render<'a>(
1909 &self,
1910 protocol: &'a Protocol,
1911 state: &'a Value,
1912 options: &RenderOptions<'a>,
1913 writer: &'a mut dyn ResponseWriter,
1914 ) -> Result<()> {
1915 let document = protocol.protocol();
1916 if !document.fragments.contains_key(options.entry_id) {
1917 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
1918 }
1919 let mut context = WebUIProcessContext {
1920 protocol: document,
1921 legacy_structural_signals: protocol.legacy_structural_signals(),
1922 state,
1923 writer,
1924 local_vars: HashMap::new(),
1925 component_attrs: HashMap::new(),
1926 request_path: options.request_path,
1927 route_base: Cow::Borrowed("/"),
1928 rendered_components: HashSet::new(),
1929 plugin: self.plugin_factory.map(|f| f()),
1930 route_children: Vec::new(),
1931 entry_id: options.entry_id,
1932 nonce: options.nonce.filter(|s| !s.is_empty()),
1935 head_inject: options.head_inject.filter(|s| !s.is_empty()),
1936 body_inject: options.body_inject.filter(|s| !s.is_empty()),
1937 head_end_emitted: false,
1938 component_index: protocol.component_index(),
1939 body_end_emitted: false,
1940 route_index: protocol.route_index(),
1941 route_chain_index: 0,
1942 streaming: None,
1943 json_scratch: Vec::new(),
1944 scope_pool: Vec::new(),
1945 };
1946
1947 self.process_fragment_id(options.entry_id, &mut context)?;
1948 writer.end()?;
1949
1950 Ok(())
1951 }
1952}
1953
1954impl Default for WebUIHandler {
1955 fn default() -> Self {
1956 Self::new()
1957 }
1958}
1959
1960fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
1962 writer.write(" ")?;
1963 writer.write(name)?;
1964 writer.write("=\"")?;
1965 writer.write(value)?;
1966 writer.write("\"")
1967}
1968
1969#[cfg(test)]
1970fn handle(
1971 protocol: &WebUIProtocol,
1972 state: &Value,
1973 options: &RenderOptions<'_>,
1974 writer: &mut dyn ResponseWriter,
1975) -> Result<()> {
1976 let handler = WebUIHandler::new();
1977 handler.handle(protocol, state, options, writer)
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983 use crate::streaming::STREAMING_MARKER;
1984 use std::cell::RefCell;
1985 use std::sync::Arc;
1986 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
1987 use webui_protocol::{
1988 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
1989 WebUIFragmentAttribute,
1990 };
1991 use webui_test_utils::test_json;
1992
1993 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
1994 WebUIFragment::signal(
1995 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
1996 true,
1997 )
1998 }
1999
2000 struct TestWriter {
2002 content: RefCell<String>,
2003 ended: RefCell<bool>,
2004 }
2005
2006 impl TestWriter {
2007 fn new() -> Self {
2008 Self {
2009 content: RefCell::new(String::new()),
2010 ended: RefCell::new(false),
2011 }
2012 }
2013
2014 fn get_content(&self) -> String {
2015 self.content.borrow().clone()
2016 }
2017
2018 fn is_ended(&self) -> bool {
2019 *self.ended.borrow()
2020 }
2021 }
2022
2023 impl ResponseWriter for TestWriter {
2024 fn write(&mut self, content: &str) -> Result<()> {
2025 self.content.borrow_mut().push_str(content);
2026 Ok(())
2027 }
2028
2029 fn end(&mut self) -> Result<()> {
2030 *self.ended.borrow_mut() = true;
2031 Ok(())
2032 }
2033 }
2034
2035 #[test]
2036 fn test_handle_raw() {
2037 let mut fragments = HashMap::new();
2039 fragments.insert(
2040 "index.html".to_string(),
2041 FragmentList {
2042 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
2043 },
2044 );
2045
2046 let protocol = WebUIProtocol::new(fragments);
2047 let state = test_json!({});
2048
2049 let mut writer = TestWriter::new();
2051
2052 assert!(
2054 handle(
2055 &protocol,
2056 &state,
2057 &RenderOptions::new("index.html", "/"),
2058 &mut writer
2059 )
2060 .is_ok(),
2061 "Failed to handle raw protocol"
2062 );
2063
2064 assert_eq!(writer.get_content(), "Hello, WebUI!");
2066 assert!(writer.is_ended());
2067 }
2068
2069 #[test]
2070 fn test_handle_signal() {
2071 let mut fragments = HashMap::new();
2073 fragments.insert(
2074 "index.html".to_string(),
2075 FragmentList {
2076 fragments: vec![
2077 WebUIFragment::raw("Hello, "),
2078 WebUIFragment::signal("name", false),
2079 WebUIFragment::raw("!"),
2080 ],
2081 },
2082 );
2083
2084 let protocol = WebUIProtocol::new(fragments);
2085 let state = test_json!({"name": "WebUI"});
2086
2087 let mut writer = TestWriter::new();
2089
2090 assert!(
2092 handle(
2093 &protocol,
2094 &state,
2095 &RenderOptions::new("index.html", "/"),
2096 &mut writer
2097 )
2098 .is_ok(),
2099 "Failed to handle signal protocol"
2100 );
2101
2102 assert_eq!(writer.get_content(), "Hello, WebUI!");
2104 assert!(writer.is_ended());
2105 }
2106
2107 #[test]
2108 fn test_handle_for_loop() {
2109 let mut fragments = HashMap::new();
2111 fragments.insert(
2112 "index.html".to_string(),
2113 FragmentList {
2114 fragments: vec![
2115 WebUIFragment::raw("People: "),
2116 WebUIFragment::for_loop("person", "people", "person-item"),
2117 ],
2118 },
2119 );
2120
2121 fragments.insert(
2122 "person-item".to_string(),
2123 FragmentList {
2124 fragments: vec![
2125 WebUIFragment::signal("person.name", false),
2126 WebUIFragment::raw(", "),
2127 ],
2128 },
2129 );
2130
2131 let protocol = WebUIProtocol::new(fragments);
2132 let state = test_json!({
2133 "people": [
2134 {"name": "Alice"},
2135 {"name": "Bob"},
2136 {"name": "Charlie"}
2137 ]
2138 });
2139
2140 let mut writer = TestWriter::new();
2142
2143 assert!(
2145 handle(
2146 &protocol,
2147 &state,
2148 &RenderOptions::new("index.html", "/"),
2149 &mut writer
2150 )
2151 .is_ok(),
2152 "Failed to handle for loop protocol"
2153 );
2154
2155 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
2157 assert!(writer.is_ended());
2158 }
2159
2160 #[test]
2161 fn test_handle_if_condition() {
2162 let mut fragments = HashMap::new();
2164 fragments.insert(
2165 "index.html".to_string(),
2166 FragmentList {
2167 fragments: vec![
2168 WebUIFragment::raw("Status: "),
2169 WebUIFragment::if_cond(
2170 webui_protocol::ConditionExpr::identifier("isActive"),
2171 "active-content",
2172 ),
2173 WebUIFragment::raw("End"),
2174 ],
2175 },
2176 );
2177
2178 fragments.insert(
2179 "active-content".to_string(),
2180 FragmentList {
2181 fragments: vec![WebUIFragment::raw("Active")],
2182 },
2183 );
2184
2185 let protocol = WebUIProtocol::new(fragments);
2186
2187 let state_true = test_json!({"isActive": true});
2189 let mut writer_true = TestWriter::new();
2190 assert!(
2191 handle(
2192 &protocol,
2193 &state_true,
2194 &RenderOptions::new("index.html", "/"),
2195 &mut writer_true
2196 )
2197 .is_ok(),
2198 "Failed to handle if condition (true case)"
2199 );
2200 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
2201 assert!(writer_true.is_ended());
2202
2203 let state_false = test_json!({"isActive": false});
2205 let mut writer_false = TestWriter::new();
2206 assert!(
2207 handle(
2208 &protocol,
2209 &state_false,
2210 &RenderOptions::new("index.html", "/"),
2211 &mut writer_false
2212 )
2213 .is_ok(),
2214 "Failed to handle if condition (false case)"
2215 );
2216 assert_eq!(writer_false.get_content(), "Status: End");
2217 assert!(writer_false.is_ended());
2218 }
2219
2220 #[test]
2221 fn test_handle_component() {
2222 let mut fragments = HashMap::new();
2224 fragments.insert(
2225 "index.html".to_string(),
2226 FragmentList {
2227 fragments: vec![
2228 WebUIFragment::raw("Component: "),
2229 WebUIFragment::component("my-component"),
2230 ],
2231 },
2232 );
2233
2234 fragments.insert(
2235 "my-component".to_string(),
2236 FragmentList {
2237 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
2238 },
2239 );
2240
2241 let protocol = WebUIProtocol::new(fragments);
2242 let state = test_json!({});
2243
2244 let mut writer = TestWriter::new();
2246
2247 assert!(
2249 handle(
2250 &protocol,
2251 &state,
2252 &RenderOptions::new("index.html", "/"),
2253 &mut writer
2254 )
2255 .is_ok(),
2256 "Failed to handle component protocol"
2257 );
2258
2259 assert_eq!(
2261 writer.get_content(),
2262 "Component: <div>Component Content</div>"
2263 );
2264 assert!(writer.is_ended());
2265 }
2266
2267 #[test]
2268 fn test_missing_fragment() {
2269 let mut fragments = HashMap::new();
2271 fragments.insert(
2272 "index.html".to_string(),
2273 FragmentList {
2274 fragments: vec![WebUIFragment::component("missing-component")],
2275 },
2276 );
2277
2278 let protocol = WebUIProtocol::new(fragments);
2279 let state = test_json!({});
2280
2281 let mut writer = TestWriter::new();
2283
2284 let result = handle(
2286 &protocol,
2287 &state,
2288 &RenderOptions::new("index.html", "/"),
2289 &mut writer,
2290 );
2291
2292 assert!(result.is_err());
2294 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
2295 assert_eq!(fragment_id, "missing-component");
2296 } else {
2297 panic!("Expected MissingFragment error");
2298 }
2299 }
2300
2301 #[test]
2302 fn test_missing_signal_renders_empty() {
2303 let mut fragments = HashMap::new();
2305 fragments.insert(
2306 "index.html".to_string(),
2307 FragmentList {
2308 fragments: vec![
2309 WebUIFragment::raw("Hello, "),
2310 WebUIFragment::signal("missing_field", false),
2311 WebUIFragment::raw("!"),
2312 ],
2313 },
2314 );
2315
2316 let protocol = WebUIProtocol::new(fragments);
2317 let state = test_json!({});
2318
2319 let mut writer = TestWriter::new();
2320
2321 assert!(
2322 handle(
2323 &protocol,
2324 &state,
2325 &RenderOptions::new("index.html", "/"),
2326 &mut writer
2327 )
2328 .is_ok(),
2329 "Missing signal should not produce an error"
2330 );
2331
2332 assert_eq!(writer.get_content(), "Hello, !");
2333 assert!(writer.is_ended());
2334 }
2335
2336 #[test]
2339 fn test_boolean_attr_true() {
2340 let mut fragments = HashMap::new();
2341 fragments.insert(
2342 "index.html".to_string(),
2343 FragmentList {
2344 fragments: vec![
2345 WebUIFragment::raw("<button"),
2346 WebUIFragment::attribute_boolean(
2347 "disabled",
2348 ConditionExpr::identifier("isDisabled"),
2349 ),
2350 WebUIFragment::raw(">Click</button>"),
2351 ],
2352 },
2353 );
2354 let protocol = WebUIProtocol::new(fragments);
2355 let state = test_json!({"isDisabled": true});
2356 let mut writer = TestWriter::new();
2357 handle(
2358 &protocol,
2359 &state,
2360 &RenderOptions::new("index.html", "/"),
2361 &mut writer,
2362 )
2363 .unwrap();
2364 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
2365 }
2366
2367 #[test]
2368 fn test_boolean_attr_false() {
2369 let mut fragments = HashMap::new();
2370 fragments.insert(
2371 "index.html".to_string(),
2372 FragmentList {
2373 fragments: vec![
2374 WebUIFragment::raw("<button"),
2375 WebUIFragment::attribute_boolean(
2376 "disabled",
2377 ConditionExpr::identifier("isDisabled"),
2378 ),
2379 WebUIFragment::raw(">Click</button>"),
2380 ],
2381 },
2382 );
2383 let protocol = WebUIProtocol::new(fragments);
2384 let state = test_json!({"isDisabled": false});
2385 let mut writer = TestWriter::new();
2386 handle(
2387 &protocol,
2388 &state,
2389 &RenderOptions::new("index.html", "/"),
2390 &mut writer,
2391 )
2392 .unwrap();
2393 assert_eq!(writer.get_content(), "<button>Click</button>");
2394 }
2395
2396 #[test]
2397 fn test_boolean_attr_missing() {
2398 let mut fragments = HashMap::new();
2399 fragments.insert(
2400 "index.html".to_string(),
2401 FragmentList {
2402 fragments: vec![
2403 WebUIFragment::raw("<input type=\"checkbox\""),
2404 WebUIFragment::attribute_boolean(
2405 "checked",
2406 ConditionExpr::identifier("checked"),
2407 ),
2408 WebUIFragment::raw(">"),
2409 ],
2410 },
2411 );
2412 let protocol = WebUIProtocol::new(fragments);
2413 let state = test_json!({});
2414 let mut writer = TestWriter::new();
2415 handle(
2416 &protocol,
2417 &state,
2418 &RenderOptions::new("index.html", "/"),
2419 &mut writer,
2420 )
2421 .unwrap();
2422 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
2423 }
2424
2425 #[test]
2426 fn test_boolean_attr_multiple() {
2427 let mut fragments = HashMap::new();
2428 fragments.insert(
2429 "index.html".to_string(),
2430 FragmentList {
2431 fragments: vec![
2432 WebUIFragment::raw("<input type=\"checkbox\""),
2433 WebUIFragment::attribute_boolean(
2434 "checked",
2435 ConditionExpr::identifier("checked"),
2436 ),
2437 WebUIFragment::attribute_boolean(
2438 "disabled",
2439 ConditionExpr::identifier("disabled"),
2440 ),
2441 WebUIFragment::raw(">"),
2442 ],
2443 },
2444 );
2445 let protocol = WebUIProtocol::new(fragments);
2446 let state = test_json!({"checked": true, "disabled": false});
2447 let mut writer = TestWriter::new();
2448 handle(
2449 &protocol,
2450 &state,
2451 &RenderOptions::new("index.html", "/"),
2452 &mut writer,
2453 )
2454 .unwrap();
2455 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
2456 }
2457
2458 #[test]
2461 fn test_attribute_with_value() {
2462 let mut fragments = HashMap::new();
2463 fragments.insert(
2464 "index.html".to_string(),
2465 FragmentList {
2466 fragments: vec![
2467 WebUIFragment::raw("<input"),
2468 WebUIFragment::attribute("value", "inputValue"),
2469 WebUIFragment::raw(">"),
2470 ],
2471 },
2472 );
2473 let protocol = WebUIProtocol::new(fragments);
2474 let state = test_json!({"inputValue": "Hello"});
2475 let mut writer = TestWriter::new();
2476 handle(
2477 &protocol,
2478 &state,
2479 &RenderOptions::new("index.html", "/"),
2480 &mut writer,
2481 )
2482 .unwrap();
2483 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
2484 }
2485
2486 #[test]
2487 fn test_attribute_with_falsy_numeric() {
2488 let mut fragments = HashMap::new();
2489 fragments.insert(
2490 "index.html".to_string(),
2491 FragmentList {
2492 fragments: vec![
2493 WebUIFragment::raw("<div name=\"test\""),
2494 WebUIFragment::attribute("handle", "number"),
2495 WebUIFragment::raw("></div>"),
2496 ],
2497 },
2498 );
2499 let protocol = WebUIProtocol::new(fragments);
2500 let state = test_json!({"number": 0});
2501 let mut writer = TestWriter::new();
2502 handle(
2503 &protocol,
2504 &state,
2505 &RenderOptions::new("index.html", "/"),
2506 &mut writer,
2507 )
2508 .unwrap();
2509 assert_eq!(
2510 writer.get_content(),
2511 "<div name=\"test\" handle=\"0\"></div>"
2512 );
2513 }
2514
2515 #[test]
2518 fn test_attribute_array_value_is_escaped() {
2519 let mut fragments = HashMap::new();
2520 fragments.insert(
2521 "index.html".to_string(),
2522 FragmentList {
2523 fragments: vec![
2524 WebUIFragment::raw("<a"),
2525 WebUIFragment::attribute("href", "value"),
2526 WebUIFragment::raw(">demo</a>"),
2527 ],
2528 },
2529 );
2530 let protocol = WebUIProtocol::new(fragments);
2531 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
2532 let mut writer = TestWriter::new();
2533 handle(
2534 &protocol,
2535 &state,
2536 &RenderOptions::new("index.html", "/"),
2537 &mut writer,
2538 )
2539 .unwrap();
2540 let content = writer.get_content();
2541 assert!(
2544 content.contains("""),
2545 "Double quotes inside attribute value must be escaped: {content}"
2546 );
2547 assert_eq!(
2550 content.matches("=\"").count(),
2551 1,
2552 "Only one attribute assignment expected: {content}"
2553 );
2554 }
2555
2556 #[test]
2557 fn test_attribute_object_value_is_escaped() {
2558 let mut fragments = HashMap::new();
2559 fragments.insert(
2560 "index.html".to_string(),
2561 FragmentList {
2562 fragments: vec![
2563 WebUIFragment::raw("<div"),
2564 WebUIFragment::attribute("data-cfg", "cfg"),
2565 WebUIFragment::raw("></div>"),
2566 ],
2567 },
2568 );
2569 let protocol = WebUIProtocol::new(fragments);
2570 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
2571 let mut writer = TestWriter::new();
2572 handle(
2573 &protocol,
2574 &state,
2575 &RenderOptions::new("index.html", "/"),
2576 &mut writer,
2577 )
2578 .unwrap();
2579 let content = writer.get_content();
2580 assert!(
2581 content.contains("""),
2582 "Double quotes inside attribute value must be escaped: {content}"
2583 );
2584 assert_eq!(
2585 content.matches("=\"").count(),
2586 1,
2587 "Only one attribute assignment expected: {content}"
2588 );
2589 }
2590
2591 #[test]
2594 fn test_mixed_attribute_template() {
2595 let mut fragments = HashMap::new();
2596 fragments.insert(
2597 "index.html".to_string(),
2598 FragmentList {
2599 fragments: vec![
2600 WebUIFragment::raw("<input"),
2601 WebUIFragment::attribute_template("value", "attr-1"),
2602 WebUIFragment::raw(">"),
2603 ],
2604 },
2605 );
2606 fragments.insert(
2607 "attr-1".to_string(),
2608 FragmentList {
2609 fragments: vec![
2610 WebUIFragment::raw("hello "),
2611 WebUIFragment::signal("item", false),
2612 ],
2613 },
2614 );
2615 let protocol = WebUIProtocol::new(fragments);
2616 let state = test_json!({"item": "world"});
2617 let mut writer = TestWriter::new();
2618 handle(
2619 &protocol,
2620 &state,
2621 &RenderOptions::new("index.html", "/"),
2622 &mut writer,
2623 )
2624 .unwrap();
2625 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
2626 }
2627
2628 #[test]
2631 fn test_raw_signal_not_escaped() {
2632 let mut fragments = HashMap::new();
2633 fragments.insert(
2634 "index.html".to_string(),
2635 FragmentList {
2636 fragments: vec![
2637 WebUIFragment::signal("html", false),
2638 WebUIFragment::signal("html", true),
2639 ],
2640 },
2641 );
2642 let protocol = WebUIProtocol::new(fragments);
2643 let state = test_json!({"html": "<strong>hi</strong>"});
2644 let mut writer = TestWriter::new();
2645 handle(
2646 &protocol,
2647 &state,
2648 &RenderOptions::new("index.html", "/"),
2649 &mut writer,
2650 )
2651 .unwrap();
2652 assert_eq!(
2653 writer.get_content(),
2654 "<strong>hi</strong><strong>hi</strong>"
2655 );
2656 }
2657
2658 #[test]
2661 fn test_nested_for_loop() {
2662 let mut fragments = HashMap::new();
2663 fragments.insert(
2664 "index.html".to_string(),
2665 FragmentList {
2666 fragments: vec![
2667 WebUIFragment::raw("<div>"),
2668 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
2669 WebUIFragment::raw("</div>"),
2670 ],
2671 },
2672 );
2673 fragments.insert(
2674 "outer".to_string(),
2675 FragmentList {
2676 fragments: vec![
2677 WebUIFragment::raw("<div>"),
2678 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
2679 WebUIFragment::raw("</div>"),
2680 ],
2681 },
2682 );
2683 fragments.insert(
2684 "inner".to_string(),
2685 FragmentList {
2686 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
2687 },
2688 );
2689 let protocol = WebUIProtocol::new(fragments);
2690 let state = test_json!({
2691 "outerItems": [
2692 {"innerItems": [{"name": "A"}, {"name": "B"}]},
2693 {"innerItems": [{"name": "C"}]}
2694 ]
2695 });
2696 let mut writer = TestWriter::new();
2697 handle(
2698 &protocol,
2699 &state,
2700 &RenderOptions::new("index.html", "/"),
2701 &mut writer,
2702 )
2703 .unwrap();
2704 assert_eq!(
2705 writer.get_content(),
2706 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_nested_for_with_signals() {
2712 let mut fragments = HashMap::new();
2713 fragments.insert(
2714 "index.html".to_string(),
2715 FragmentList {
2716 fragments: vec![
2717 WebUIFragment::raw("<div>"),
2718 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2719 WebUIFragment::raw("</div>"),
2720 ],
2721 },
2722 );
2723 fragments.insert(
2724 "outerTemplate".to_string(),
2725 FragmentList {
2726 fragments: vec![
2727 WebUIFragment::raw("<div>"),
2728 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2729 WebUIFragment::raw("</div>"),
2730 ],
2731 },
2732 );
2733 fragments.insert(
2734 "innerTemplate".to_string(),
2735 FragmentList {
2736 fragments: vec![
2737 WebUIFragment::raw("<span>"),
2738 WebUIFragment::signal("innerItem.name", false),
2739 WebUIFragment::raw("</span>"),
2740 ],
2741 },
2742 );
2743 let protocol = WebUIProtocol::new(fragments);
2744 let state = test_json!({
2745 "outerItems": [
2746 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2747 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2748 ]
2749 });
2750 let mut writer = TestWriter::new();
2751 handle(
2752 &protocol,
2753 &state,
2754 &RenderOptions::new("index.html", "/"),
2755 &mut writer,
2756 )
2757 .unwrap();
2758 assert_eq!(
2759 writer.get_content(),
2760 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
2761 );
2762 }
2763
2764 #[test]
2765 fn test_nested_for_with_global_state() {
2766 let mut fragments = HashMap::new();
2767 fragments.insert(
2768 "index.html".to_string(),
2769 FragmentList {
2770 fragments: vec![
2771 WebUIFragment::raw("<div>"),
2772 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
2773 WebUIFragment::raw("</div>"),
2774 ],
2775 },
2776 );
2777 fragments.insert(
2778 "outerTemplate".to_string(),
2779 FragmentList {
2780 fragments: vec![
2781 WebUIFragment::raw("<div>"),
2782 WebUIFragment::signal("globalOuter", false),
2783 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
2784 WebUIFragment::raw("</div>"),
2785 ],
2786 },
2787 );
2788 fragments.insert(
2789 "innerTemplate".to_string(),
2790 FragmentList {
2791 fragments: vec![
2792 WebUIFragment::raw("<span>"),
2793 WebUIFragment::signal("innerItem.name", false),
2794 WebUIFragment::signal("globalInner", false),
2795 WebUIFragment::raw("</span>"),
2796 ],
2797 },
2798 );
2799 let protocol = WebUIProtocol::new(fragments);
2800 let state = test_json!({
2801 "globalOuter": "GO",
2802 "globalInner": "GI",
2803 "outerItems": [
2804 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
2805 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
2806 ]
2807 });
2808 let mut writer = TestWriter::new();
2809 handle(
2810 &protocol,
2811 &state,
2812 &RenderOptions::new("index.html", "/"),
2813 &mut writer,
2814 )
2815 .unwrap();
2816 assert_eq!(
2817 writer.get_content(),
2818 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
2819 );
2820 }
2821
2822 #[test]
2825 fn test_if_in_for_uses_local_state() {
2826 let mut fragments = HashMap::new();
2827 fragments.insert(
2828 "index.html".to_string(),
2829 FragmentList {
2830 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2831 },
2832 );
2833 fragments.insert(
2834 "item-tpl".to_string(),
2835 FragmentList {
2836 fragments: vec![WebUIFragment::if_cond(
2837 ConditionExpr::identifier("item.visible"),
2838 "visible-tpl",
2839 )],
2840 },
2841 );
2842 fragments.insert(
2843 "visible-tpl".to_string(),
2844 FragmentList {
2845 fragments: vec![WebUIFragment::signal("item.name", false)],
2846 },
2847 );
2848 let protocol = WebUIProtocol::new(fragments);
2849 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
2850 let mut writer = TestWriter::new();
2851 handle(
2852 &protocol,
2853 &state,
2854 &RenderOptions::new("index.html", "/"),
2855 &mut writer,
2856 )
2857 .unwrap();
2858 assert_eq!(writer.get_content(), "Show");
2859 }
2860
2861 #[test]
2862 fn test_for_if_local_overrides_global() {
2863 let mut fragments = HashMap::new();
2864 fragments.insert(
2865 "index.html".to_string(),
2866 FragmentList {
2867 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
2868 },
2869 );
2870 fragments.insert(
2871 "item-tpl".to_string(),
2872 FragmentList {
2873 fragments: vec![WebUIFragment::if_cond(
2874 ConditionExpr::identifier("item.flag"),
2875 "show-tpl",
2876 )],
2877 },
2878 );
2879 fragments.insert(
2880 "show-tpl".to_string(),
2881 FragmentList {
2882 fragments: vec![WebUIFragment::raw("yes")],
2883 },
2884 );
2885 let protocol = WebUIProtocol::new(fragments);
2886 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
2888 let mut writer = TestWriter::new();
2889 handle(
2890 &protocol,
2891 &state,
2892 &RenderOptions::new("index.html", "/"),
2893 &mut writer,
2894 )
2895 .unwrap();
2896 assert_eq!(writer.get_content(), "yes");
2897 }
2898
2899 #[test]
2902 fn test_component_attr_state_simple() {
2903 let mut fragments = HashMap::new();
2904 fragments.insert(
2905 "index.html".to_string(),
2906 FragmentList {
2907 fragments: vec![
2908 WebUIFragment::raw("<my-comp"),
2909 WebUIFragment {
2910 fragment: Some(web_ui_fragment::Fragment::Attribute(
2911 WebUIFragmentAttribute {
2912 name: "title".into(),
2913 value: "Attribute Title".into(),
2914 attr_start: true,
2915 raw_value: true,
2916 ..Default::default()
2917 },
2918 )),
2919 },
2920 WebUIFragment::raw(">"),
2921 WebUIFragment::component("my-comp"),
2922 WebUIFragment::raw("</my-comp>"),
2923 ],
2924 },
2925 );
2926 fragments.insert(
2927 "my-comp".to_string(),
2928 FragmentList {
2929 fragments: vec![
2930 WebUIFragment::raw("<span>"),
2931 WebUIFragment::signal("title", false),
2932 WebUIFragment::raw("</span>"),
2933 ],
2934 },
2935 );
2936 let protocol = WebUIProtocol::new(fragments);
2937 let state = test_json!({"title": "Global Title"});
2938 let mut writer = TestWriter::new();
2939 handle(
2940 &protocol,
2941 &state,
2942 &RenderOptions::new("index.html", "/"),
2943 &mut writer,
2944 )
2945 .unwrap();
2946 assert_eq!(
2947 writer.get_content(),
2948 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
2949 );
2950 }
2951
2952 #[test]
2953 fn test_component_attr_state_template() {
2954 let mut fragments = HashMap::new();
2955 fragments.insert(
2956 "index.html".to_string(),
2957 FragmentList {
2958 fragments: vec![
2959 WebUIFragment::raw("<my-comp"),
2960 WebUIFragment {
2961 fragment: Some(web_ui_fragment::Fragment::Attribute(
2962 WebUIFragmentAttribute {
2963 name: "title".into(),
2964 template: "title-attr".into(),
2965 attr_start: true,
2966 ..Default::default()
2967 },
2968 )),
2969 },
2970 WebUIFragment::raw(">"),
2971 WebUIFragment::component("my-comp"),
2972 WebUIFragment::raw("</my-comp>"),
2973 ],
2974 },
2975 );
2976 fragments.insert(
2977 "title-attr".to_string(),
2978 FragmentList {
2979 fragments: vec![
2980 WebUIFragment::raw("hello "),
2981 WebUIFragment::signal("item", false),
2982 ],
2983 },
2984 );
2985 fragments.insert(
2986 "my-comp".to_string(),
2987 FragmentList {
2988 fragments: vec![
2989 WebUIFragment::raw("<span>"),
2990 WebUIFragment::signal("title", false),
2991 WebUIFragment::raw("</span>"),
2992 ],
2993 },
2994 );
2995 let protocol = WebUIProtocol::new(fragments);
2996 let state = test_json!({"item": "<world>"});
2997 let mut writer = TestWriter::new();
2998 handle(
2999 &protocol,
3000 &state,
3001 &RenderOptions::new("index.html", "/"),
3002 &mut writer,
3003 )
3004 .unwrap();
3005 assert_eq!(
3006 writer.get_content(),
3007 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
3008 );
3009 }
3010
3011 #[test]
3012 fn test_component_attr_camel_case() {
3013 let mut fragments = HashMap::new();
3014 fragments.insert(
3015 "index.html".to_string(),
3016 FragmentList {
3017 fragments: vec![
3018 WebUIFragment::raw("<my-comp"),
3019 WebUIFragment {
3020 fragment: Some(web_ui_fragment::Fragment::Attribute(
3021 WebUIFragmentAttribute {
3022 name: "data-title".into(),
3023 template: "dt-attr".into(),
3024 attr_start: true,
3025 ..Default::default()
3026 },
3027 )),
3028 },
3029 WebUIFragment::raw(">"),
3030 WebUIFragment::component("my-comp"),
3031 WebUIFragment::raw("</my-comp>"),
3032 ],
3033 },
3034 );
3035 fragments.insert(
3036 "dt-attr".to_string(),
3037 FragmentList {
3038 fragments: vec![
3039 WebUIFragment::raw("prefix "),
3040 WebUIFragment::signal("item", false),
3041 ],
3042 },
3043 );
3044 fragments.insert(
3045 "my-comp".to_string(),
3046 FragmentList {
3047 fragments: vec![
3048 WebUIFragment::raw("<span>"),
3049 WebUIFragment::signal("dataTitle", false),
3050 WebUIFragment::raw("</span>"),
3051 ],
3052 },
3053 );
3054 let protocol = WebUIProtocol::new(fragments);
3055 let state = test_json!({"item": "a&b"});
3056 let mut writer = TestWriter::new();
3057 handle(
3058 &protocol,
3059 &state,
3060 &RenderOptions::new("index.html", "/"),
3061 &mut writer,
3062 )
3063 .unwrap();
3064 assert_eq!(
3065 writer.get_content(),
3066 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
3067 );
3068 }
3069
3070 #[test]
3071 fn test_component_complex_attr() {
3072 let mut fragments = HashMap::new();
3073 fragments.insert(
3074 "index.html".to_string(),
3075 FragmentList {
3076 fragments: vec![
3077 WebUIFragment::raw("<my-comp"),
3078 WebUIFragment {
3079 fragment: Some(web_ui_fragment::Fragment::Attribute(
3080 WebUIFragmentAttribute {
3081 name: ":item".into(),
3082 value: "complexItem".into(),
3083 attr_start: true,
3084 complex: true,
3085 ..Default::default()
3086 },
3087 )),
3088 },
3089 WebUIFragment::raw(">"),
3090 WebUIFragment::component("my-comp"),
3091 WebUIFragment::raw("</my-comp>"),
3092 ],
3093 },
3094 );
3095 fragments.insert(
3096 "my-comp".to_string(),
3097 FragmentList {
3098 fragments: vec![
3099 WebUIFragment::raw("<span>"),
3100 WebUIFragment::signal("item.foo", false),
3101 WebUIFragment::raw("</span><p>"),
3102 WebUIFragment::signal("item.bar", false),
3103 WebUIFragment::raw("</p>"),
3104 ],
3105 },
3106 );
3107 let protocol = WebUIProtocol::new(fragments);
3108 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
3109 let mut writer = TestWriter::new();
3110 handle(
3111 &protocol,
3112 &state,
3113 &RenderOptions::new("index.html", "/"),
3114 &mut writer,
3115 )
3116 .unwrap();
3117 assert_eq!(
3118 writer.get_content(),
3119 "<my-comp><span>1</span><p>true</p></my-comp>"
3120 );
3121 }
3122
3123 #[test]
3124 fn test_component_no_parent_pollution() {
3125 let mut fragments = HashMap::new();
3126 fragments.insert(
3127 "index.html".to_string(),
3128 FragmentList {
3129 fragments: vec![
3130 WebUIFragment::raw("<parent"),
3131 WebUIFragment {
3132 fragment: Some(web_ui_fragment::Fragment::Attribute(
3133 WebUIFragmentAttribute {
3134 name: "var".into(),
3135 value: "var".into(),
3136 attr_start: true,
3137 ..Default::default()
3138 },
3139 )),
3140 },
3141 WebUIFragment::raw(">"),
3142 WebUIFragment::component("parent"),
3143 WebUIFragment::raw("</parent>"),
3144 ],
3145 },
3146 );
3147 fragments.insert(
3148 "parent".to_string(),
3149 FragmentList {
3150 fragments: vec![
3151 WebUIFragment::raw("Before: "),
3152 WebUIFragment::signal("var", false),
3153 WebUIFragment::raw("<child foo"),
3154 WebUIFragment {
3155 fragment: Some(web_ui_fragment::Fragment::Attribute(
3156 WebUIFragmentAttribute {
3157 name: "var".into(),
3158 value: "replaced".into(),
3159 raw_value: true,
3160 ..Default::default()
3161 },
3162 )),
3163 },
3164 WebUIFragment::raw(">"),
3165 WebUIFragment::component("child"),
3166 WebUIFragment::raw("Label</child>After: "),
3167 WebUIFragment::signal("var", false),
3168 ],
3169 },
3170 );
3171 fragments.insert("child".to_string(), FragmentList { fragments: vec![] });
3172 let protocol = WebUIProtocol::new(fragments);
3173 let state = test_json!({"var": "original"});
3174 let mut writer = TestWriter::new();
3175 handle(
3176 &protocol,
3177 &state,
3178 &RenderOptions::new("index.html", "/"),
3179 &mut writer,
3180 )
3181 .unwrap();
3182 assert_eq!(
3183 writer.get_content(),
3184 "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
3185 );
3186 }
3187
3188 #[test]
3189 fn test_component_boolean_attr_state() {
3190 let mut fragments = HashMap::new();
3191 fragments.insert(
3192 "index.html".to_string(),
3193 FragmentList {
3194 fragments: vec![
3195 WebUIFragment::raw("<my-comp"),
3196 WebUIFragment {
3197 fragment: Some(web_ui_fragment::Fragment::Attribute(
3198 WebUIFragmentAttribute {
3199 name: "disabled".into(),
3200 attr_start: true,
3201 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
3202 ..Default::default()
3203 },
3204 )),
3205 },
3206 WebUIFragment::raw(">"),
3207 WebUIFragment::component("my-comp"),
3208 WebUIFragment::raw("</my-comp>"),
3209 ],
3210 },
3211 );
3212 fragments.insert(
3213 "my-comp".to_string(),
3214 FragmentList {
3215 fragments: vec![WebUIFragment::if_cond(
3216 ConditionExpr::identifier("disabled"),
3217 "show",
3218 )],
3219 },
3220 );
3221 fragments.insert(
3222 "show".to_string(),
3223 FragmentList {
3224 fragments: vec![WebUIFragment::raw("disabled!")],
3225 },
3226 );
3227 let protocol = WebUIProtocol::new(fragments);
3228 let state = test_json!({"isDisabled": true});
3229 let mut writer = TestWriter::new();
3230 handle(
3231 &protocol,
3232 &state,
3233 &RenderOptions::new("index.html", "/"),
3234 &mut writer,
3235 )
3236 .unwrap();
3237 assert_eq!(
3238 writer.get_content(),
3239 "<my-comp disabled>disabled!</my-comp>"
3240 );
3241 }
3242
3243 fn render_signal(value: &str) -> String {
3247 let mut fragments = HashMap::new();
3248 fragments.insert(
3249 "index.html".to_string(),
3250 FragmentList {
3251 fragments: vec![WebUIFragment::signal("v", false)],
3252 },
3253 );
3254 let protocol = WebUIProtocol::new(fragments);
3255 let state = test_json!({"v": value});
3256 let mut writer = TestWriter::new();
3257 handle(
3258 &protocol,
3259 &state,
3260 &RenderOptions::new("index.html", "/"),
3261 &mut writer,
3262 )
3263 .unwrap();
3264 writer.get_content()
3265 }
3266
3267 #[test]
3268 fn test_escape_ampersand() {
3269 assert_eq!(render_signal("&"), "&");
3270 }
3271
3272 #[test]
3273 fn test_escape_less_than() {
3274 assert_eq!(render_signal("<"), "<");
3275 }
3276
3277 #[test]
3278 fn test_escape_greater_than() {
3279 assert_eq!(render_signal(">"), ">");
3280 }
3281
3282 #[test]
3283 fn test_escape_double_quote() {
3284 assert_eq!(render_signal("\""), """);
3285 }
3286
3287 #[test]
3288 fn test_escape_single_quote() {
3289 let result = render_signal("'");
3291 assert_eq!(
3292 result, "'",
3293 "Expected ' for single quote, got: {result}"
3294 );
3295 }
3296
3297 #[test]
3298 fn test_escape_multiple_special_chars() {
3299 let result = render_signal("<script>alert('xss');</script>");
3300 assert!(
3301 result.contains("<") && result.contains(">"),
3302 "Expected escaped HTML, got: {}",
3303 result
3304 );
3305 assert!(
3306 !result.contains("<script>"),
3307 "Should not contain raw <script> tag"
3308 );
3309 }
3310
3311 #[test]
3312 fn test_escape_no_special_chars() {
3313 assert_eq!(render_signal("Hello World"), "Hello World");
3314 }
3315
3316 #[test]
3317 fn test_escape_empty_string() {
3318 assert_eq!(render_signal(""), "");
3319 }
3320
3321 #[test]
3322 fn test_escape_special_at_beginning() {
3323 let result = render_signal("<Hello");
3324 assert!(
3325 result.starts_with("<"),
3326 "Expected < at start, got: {}",
3327 result
3328 );
3329 }
3330
3331 #[test]
3332 fn test_escape_special_at_end() {
3333 let result = render_signal("Hello>");
3334 assert!(
3335 result.ends_with(">"),
3336 "Expected > at end, got: {}",
3337 result
3338 );
3339 }
3340
3341 #[test]
3342 fn test_escape_special_in_middle() {
3343 let result = render_signal("Hel&lo");
3344 assert!(
3345 result.contains("&"),
3346 "Expected & in middle, got: {}",
3347 result
3348 );
3349 }
3350
3351 #[test]
3354 fn test_boolean_attr_truthy_values() {
3355 {
3357 let mut fragments = HashMap::new();
3358 fragments.insert(
3359 "index.html".to_string(),
3360 FragmentList {
3361 fragments: vec![
3362 WebUIFragment::raw("<input"),
3363 WebUIFragment::attribute_boolean(
3364 "checked",
3365 ConditionExpr::identifier("checked"),
3366 ),
3367 WebUIFragment::raw(">"),
3368 ],
3369 },
3370 );
3371 let protocol = WebUIProtocol::new(fragments);
3372 let state = test_json!({"checked": 1});
3373 let mut writer = TestWriter::new();
3374 handle(
3375 &protocol,
3376 &state,
3377 &RenderOptions::new("index.html", "/"),
3378 &mut writer,
3379 )
3380 .unwrap();
3381 assert_eq!(writer.get_content(), "<input checked>");
3382 }
3383 {
3385 let mut fragments = HashMap::new();
3386 fragments.insert(
3387 "index.html".to_string(),
3388 FragmentList {
3389 fragments: vec![
3390 WebUIFragment::raw("<input"),
3391 WebUIFragment::attribute_boolean(
3392 "checked",
3393 ConditionExpr::identifier("checked"),
3394 ),
3395 WebUIFragment::raw(">"),
3396 ],
3397 },
3398 );
3399 let protocol = WebUIProtocol::new(fragments);
3400 let state = test_json!({"checked": "yes"});
3401 let mut writer = TestWriter::new();
3402 handle(
3403 &protocol,
3404 &state,
3405 &RenderOptions::new("index.html", "/"),
3406 &mut writer,
3407 )
3408 .unwrap();
3409 assert_eq!(writer.get_content(), "<input checked>");
3410 }
3411 {
3413 let mut fragments = HashMap::new();
3414 fragments.insert(
3415 "index.html".to_string(),
3416 FragmentList {
3417 fragments: vec![
3418 WebUIFragment::raw("<input"),
3419 WebUIFragment::attribute_boolean(
3420 "checked",
3421 ConditionExpr::identifier("checked"),
3422 ),
3423 WebUIFragment::raw(">"),
3424 ],
3425 },
3426 );
3427 let protocol = WebUIProtocol::new(fragments);
3428 let state = test_json!({"checked": {}});
3429 let mut writer = TestWriter::new();
3430 handle(
3431 &protocol,
3432 &state,
3433 &RenderOptions::new("index.html", "/"),
3434 &mut writer,
3435 )
3436 .unwrap();
3437 assert_eq!(writer.get_content(), "<input>");
3439 }
3440 {
3442 let mut fragments = HashMap::new();
3443 fragments.insert(
3444 "index.html".to_string(),
3445 FragmentList {
3446 fragments: vec![
3447 WebUIFragment::raw("<input"),
3448 WebUIFragment::attribute_boolean(
3449 "checked",
3450 ConditionExpr::identifier("checked"),
3451 ),
3452 WebUIFragment::raw(">"),
3453 ],
3454 },
3455 );
3456 let protocol = WebUIProtocol::new(fragments);
3457 let state = test_json!({"checked": "false"});
3458 let mut writer = TestWriter::new();
3459 handle(
3460 &protocol,
3461 &state,
3462 &RenderOptions::new("index.html", "/"),
3463 &mut writer,
3464 )
3465 .unwrap();
3466 assert_eq!(writer.get_content(), "<input checked>");
3467 }
3468 }
3469
3470 #[test]
3471 fn test_boolean_attr_falsy_values() {
3472 {
3474 let mut fragments = HashMap::new();
3475 fragments.insert(
3476 "index.html".to_string(),
3477 FragmentList {
3478 fragments: vec![
3479 WebUIFragment::raw("<input"),
3480 WebUIFragment::attribute_boolean(
3481 "checked",
3482 ConditionExpr::identifier("checked"),
3483 ),
3484 WebUIFragment::raw(">"),
3485 ],
3486 },
3487 );
3488 let protocol = WebUIProtocol::new(fragments);
3489 let state = test_json!({"checked": 0});
3490 let mut writer = TestWriter::new();
3491 handle(
3492 &protocol,
3493 &state,
3494 &RenderOptions::new("index.html", "/"),
3495 &mut writer,
3496 )
3497 .unwrap();
3498 assert_eq!(writer.get_content(), "<input>");
3499 }
3500 {
3502 let mut fragments = HashMap::new();
3503 fragments.insert(
3504 "index.html".to_string(),
3505 FragmentList {
3506 fragments: vec![
3507 WebUIFragment::raw("<input"),
3508 WebUIFragment::attribute_boolean(
3509 "checked",
3510 ConditionExpr::identifier("checked"),
3511 ),
3512 WebUIFragment::raw(">"),
3513 ],
3514 },
3515 );
3516 let protocol = WebUIProtocol::new(fragments);
3517 let state = test_json!({"checked": ""});
3518 let mut writer = TestWriter::new();
3519 handle(
3520 &protocol,
3521 &state,
3522 &RenderOptions::new("index.html", "/"),
3523 &mut writer,
3524 )
3525 .unwrap();
3526 assert_eq!(writer.get_content(), "<input>");
3527 }
3528 {
3530 let mut fragments = HashMap::new();
3531 fragments.insert(
3532 "index.html".to_string(),
3533 FragmentList {
3534 fragments: vec![
3535 WebUIFragment::raw("<input"),
3536 WebUIFragment::attribute_boolean(
3537 "checked",
3538 ConditionExpr::identifier("checked"),
3539 ),
3540 WebUIFragment::raw(">"),
3541 ],
3542 },
3543 );
3544 let protocol = WebUIProtocol::new(fragments);
3545 let state = test_json!({"checked": false});
3546 let mut writer = TestWriter::new();
3547 handle(
3548 &protocol,
3549 &state,
3550 &RenderOptions::new("index.html", "/"),
3551 &mut writer,
3552 )
3553 .unwrap();
3554 assert_eq!(writer.get_content(), "<input>");
3555 }
3556 {
3558 let mut fragments = HashMap::new();
3559 fragments.insert(
3560 "index.html".to_string(),
3561 FragmentList {
3562 fragments: vec![
3563 WebUIFragment::raw("<input"),
3564 WebUIFragment::attribute_boolean(
3565 "checked",
3566 ConditionExpr::identifier("checked"),
3567 ),
3568 WebUIFragment::raw(">"),
3569 ],
3570 },
3571 );
3572 let protocol = WebUIProtocol::new(fragments);
3573 let state = test_json!({});
3574 let mut writer = TestWriter::new();
3575 handle(
3576 &protocol,
3577 &state,
3578 &RenderOptions::new("index.html", "/"),
3579 &mut writer,
3580 )
3581 .unwrap();
3582 assert_eq!(writer.get_content(), "<input>");
3583 }
3584 }
3585
3586 #[test]
3587 fn test_boolean_attr_expression_true() {
3588 let mut fragments = HashMap::new();
3589 fragments.insert(
3590 "index.html".to_string(),
3591 FragmentList {
3592 fragments: vec![
3593 WebUIFragment::raw("<button"),
3594 WebUIFragment::attribute_boolean(
3595 "disabled",
3596 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3597 ),
3598 WebUIFragment::raw(">Click</button>"),
3599 ],
3600 },
3601 );
3602 let protocol = WebUIProtocol::new(fragments);
3603 let state = test_json!({"itemCount": 5});
3604 let mut writer = TestWriter::new();
3605 handle(
3606 &protocol,
3607 &state,
3608 &RenderOptions::new("index.html", "/"),
3609 &mut writer,
3610 )
3611 .unwrap();
3612 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
3613 }
3614
3615 #[test]
3616 fn test_boolean_attr_expression_false() {
3617 let mut fragments = HashMap::new();
3618 fragments.insert(
3619 "index.html".to_string(),
3620 FragmentList {
3621 fragments: vec![
3622 WebUIFragment::raw("<button"),
3623 WebUIFragment::attribute_boolean(
3624 "disabled",
3625 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
3626 ),
3627 WebUIFragment::raw(">Click</button>"),
3628 ],
3629 },
3630 );
3631 let protocol = WebUIProtocol::new(fragments);
3632 let state = test_json!({"itemCount": 3});
3633 let mut writer = TestWriter::new();
3634 handle(
3635 &protocol,
3636 &state,
3637 &RenderOptions::new("index.html", "/"),
3638 &mut writer,
3639 )
3640 .unwrap();
3641 assert_eq!(writer.get_content(), "<button>Click</button>");
3642 }
3643
3644 #[test]
3647 fn test_nested_component_attr_capture() {
3648 let mut fragments = HashMap::new();
3649 fragments.insert(
3650 "index.html".to_string(),
3651 FragmentList {
3652 fragments: vec![
3653 WebUIFragment::raw("<parent-component"),
3654 WebUIFragment {
3655 fragment: Some(web_ui_fragment::Fragment::Attribute(
3656 WebUIFragmentAttribute {
3657 name: "title".into(),
3658 template: "parent-title".into(),
3659 attr_start: true,
3660 ..Default::default()
3661 },
3662 )),
3663 },
3664 WebUIFragment::raw(">"),
3665 WebUIFragment::component("parent-component"),
3666 WebUIFragment::raw("</parent-component>"),
3667 ],
3668 },
3669 );
3670 fragments.insert(
3671 "parent-title".to_string(),
3672 FragmentList {
3673 fragments: vec![
3674 WebUIFragment::raw("Hello "),
3675 WebUIFragment::signal("who", false),
3676 ],
3677 },
3678 );
3679 fragments.insert(
3680 "parent-component".to_string(),
3681 FragmentList {
3682 fragments: vec![
3683 WebUIFragment::raw("<child-component"),
3684 WebUIFragment {
3685 fragment: Some(web_ui_fragment::Fragment::Attribute(
3686 WebUIFragmentAttribute {
3687 name: "title".into(),
3688 template: "child-title".into(),
3689 attr_start: true,
3690 ..Default::default()
3691 },
3692 )),
3693 },
3694 WebUIFragment::raw(">"),
3695 WebUIFragment::component("child-component"),
3696 WebUIFragment::raw("</child-component>"),
3697 ],
3698 },
3699 );
3700 fragments.insert(
3701 "child-title".to_string(),
3702 FragmentList {
3703 fragments: vec![
3704 WebUIFragment::raw("Child of "),
3705 WebUIFragment::signal("title", false),
3706 ],
3707 },
3708 );
3709 fragments.insert(
3710 "child-component".to_string(),
3711 FragmentList {
3712 fragments: vec![
3713 WebUIFragment::raw("<span>"),
3714 WebUIFragment::signal("title", false),
3715 WebUIFragment::raw("</span>"),
3716 ],
3717 },
3718 );
3719 let protocol = WebUIProtocol::new(fragments);
3720 let state = test_json!({"who": "<world>"});
3721 let mut writer = TestWriter::new();
3722 handle(
3723 &protocol,
3724 &state,
3725 &RenderOptions::new("index.html", "/"),
3726 &mut writer,
3727 )
3728 .unwrap();
3729 assert_eq!(
3730 writer.get_content(),
3731 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
3732 );
3733 }
3734
3735 #[test]
3736 fn test_grandchild_attr_propagation() {
3737 let mut fragments = HashMap::new();
3738 fragments.insert(
3739 "index.html".to_string(),
3740 FragmentList {
3741 fragments: vec![
3742 WebUIFragment::raw("<parent-component"),
3743 WebUIFragment {
3744 fragment: Some(web_ui_fragment::Fragment::Attribute(
3745 WebUIFragmentAttribute {
3746 name: "title".into(),
3747 template: "p-title".into(),
3748 attr_start: true,
3749 ..Default::default()
3750 },
3751 )),
3752 },
3753 WebUIFragment::raw(">"),
3754 WebUIFragment::component("parent-component"),
3755 WebUIFragment::raw("</parent-component>"),
3756 ],
3757 },
3758 );
3759 fragments.insert(
3760 "p-title".to_string(),
3761 FragmentList {
3762 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
3763 },
3764 );
3765 fragments.insert(
3766 "parent-component".to_string(),
3767 FragmentList {
3768 fragments: vec![
3769 WebUIFragment::raw("<child-component"),
3770 WebUIFragment {
3771 fragment: Some(web_ui_fragment::Fragment::Attribute(
3772 WebUIFragmentAttribute {
3773 name: "title".into(),
3774 template: "c-title".into(),
3775 attr_start: true,
3776 ..Default::default()
3777 },
3778 )),
3779 },
3780 WebUIFragment::raw(">"),
3781 WebUIFragment::component("child-component"),
3782 WebUIFragment::raw("</child-component>"),
3783 ],
3784 },
3785 );
3786 fragments.insert(
3787 "c-title".to_string(),
3788 FragmentList {
3789 fragments: vec![
3790 WebUIFragment::raw("C("),
3791 WebUIFragment::signal("title", false),
3792 WebUIFragment::raw(")-"),
3793 WebUIFragment::signal("cExtra", false),
3794 ],
3795 },
3796 );
3797 fragments.insert(
3798 "child-component".to_string(),
3799 FragmentList {
3800 fragments: vec![
3801 WebUIFragment::raw("<grandchild-component"),
3802 WebUIFragment {
3803 fragment: Some(web_ui_fragment::Fragment::Attribute(
3804 WebUIFragmentAttribute {
3805 name: "title".into(),
3806 value: "title".into(),
3807 attr_start: true,
3808 ..Default::default()
3809 },
3810 )),
3811 },
3812 WebUIFragment::raw(">"),
3813 WebUIFragment::component("grandchild-component"),
3814 WebUIFragment::raw("</grandchild-component>"),
3815 ],
3816 },
3817 );
3818 fragments.insert(
3819 "grandchild-component".to_string(),
3820 FragmentList {
3821 fragments: vec![
3822 WebUIFragment::raw("<span>"),
3823 WebUIFragment::signal("title", false),
3824 WebUIFragment::raw("</span>"),
3825 ],
3826 },
3827 );
3828 let protocol = WebUIProtocol::new(fragments);
3829 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
3830 let mut writer = TestWriter::new();
3831 handle(
3832 &protocol,
3833 &state,
3834 &RenderOptions::new("index.html", "/"),
3835 &mut writer,
3836 )
3837 .unwrap();
3838 assert_eq!(
3839 writer.get_content(),
3840 "<parent-component title=\"P:<p>\"><child-component title=\"C(P:<p>)-x&y\"><grandchild-component title=\"C(P:<p>)-x&y\"><span>C(P:<p>)-x&y</span></grandchild-component></child-component></parent-component>"
3841 );
3842 }
3843
3844 #[test]
3845 fn test_for_loop_component_attr() {
3846 let mut fragments = HashMap::new();
3847 fragments.insert(
3848 "index.html".to_string(),
3849 FragmentList {
3850 fragments: vec![
3851 WebUIFragment::raw("<parent-component"),
3852 WebUIFragment {
3853 fragment: Some(web_ui_fragment::Fragment::Attribute(
3854 WebUIFragmentAttribute {
3855 name: "title".into(),
3856 template: "parent-title-loop".into(),
3857 attr_start: true,
3858 ..Default::default()
3859 },
3860 )),
3861 },
3862 WebUIFragment::raw(">"),
3863 WebUIFragment::component("parent-component"),
3864 WebUIFragment::raw("</parent-component>"),
3865 ],
3866 },
3867 );
3868 fragments.insert(
3869 "parent-title-loop".to_string(),
3870 FragmentList {
3871 fragments: vec![
3872 WebUIFragment::raw("Parent:"),
3873 WebUIFragment::signal("who", false),
3874 ],
3875 },
3876 );
3877 fragments.insert(
3878 "parent-component".to_string(),
3879 FragmentList {
3880 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
3881 },
3882 );
3883 fragments.insert(
3884 "child-loop".to_string(),
3885 FragmentList {
3886 fragments: vec![
3887 WebUIFragment::raw("<child-component"),
3888 WebUIFragment {
3889 fragment: Some(web_ui_fragment::Fragment::Attribute(
3890 WebUIFragmentAttribute {
3891 name: "title".into(),
3892 template: "child-title-loop".into(),
3893 attr_start: true,
3894 ..Default::default()
3895 },
3896 )),
3897 },
3898 WebUIFragment::raw(">"),
3899 WebUIFragment::component("child-component"),
3900 WebUIFragment::raw("</child-component>"),
3901 ],
3902 },
3903 );
3904 fragments.insert(
3905 "child-title-loop".to_string(),
3906 FragmentList {
3907 fragments: vec![
3908 WebUIFragment::raw("Hi "),
3909 WebUIFragment::signal("item.name", false),
3910 WebUIFragment::raw(" / "),
3911 WebUIFragment::signal("title", false),
3912 ],
3913 },
3914 );
3915 fragments.insert(
3916 "child-component".to_string(),
3917 FragmentList {
3918 fragments: vec![
3919 WebUIFragment::raw("<span>"),
3920 WebUIFragment::signal("title", false),
3921 WebUIFragment::raw("</span>"),
3922 ],
3923 },
3924 );
3925 let protocol = WebUIProtocol::new(fragments);
3926 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
3927 let mut writer = TestWriter::new();
3928 handle(
3929 &protocol,
3930 &state,
3931 &RenderOptions::new("index.html", "/"),
3932 &mut writer,
3933 )
3934 .unwrap();
3935 assert_eq!(
3936 writer.get_content(),
3937 "<parent-component title=\"Parent:Bob\"><child-component title=\"Hi A<1> / Parent:Bob\"><span>Hi A<1> / Parent:Bob</span></child-component><child-component title=\"Hi B&2 / Parent:Bob\"><span>Hi B&2 / Parent:Bob</span></child-component></parent-component>"
3938 );
3939 }
3940
3941 #[test]
3942 fn test_multiple_template_attrs() {
3943 let mut fragments = HashMap::new();
3944 fragments.insert(
3945 "index.html".to_string(),
3946 FragmentList {
3947 fragments: vec![
3948 WebUIFragment::raw("<my-component"),
3949 WebUIFragment {
3950 fragment: Some(web_ui_fragment::Fragment::Attribute(
3951 WebUIFragmentAttribute {
3952 name: "title".into(),
3953 template: "attr-title".into(),
3954 attr_start: true,
3955 ..Default::default()
3956 },
3957 )),
3958 },
3959 WebUIFragment {
3960 fragment: Some(web_ui_fragment::Fragment::Attribute(
3961 WebUIFragmentAttribute {
3962 name: "data-title".into(),
3963 template: "attr-data-title".into(),
3964 attr_start: false,
3965 ..Default::default()
3966 },
3967 )),
3968 },
3969 WebUIFragment {
3970 fragment: Some(web_ui_fragment::Fragment::Attribute(
3971 WebUIFragmentAttribute {
3972 name: "aria-label".into(),
3973 template: "attr-aria-label".into(),
3974 attr_start: false,
3975 ..Default::default()
3976 },
3977 )),
3978 },
3979 WebUIFragment::raw(">"),
3980 WebUIFragment::component("my-component"),
3981 WebUIFragment::raw("</my-component>"),
3982 ],
3983 },
3984 );
3985 fragments.insert(
3986 "attr-title".to_string(),
3987 FragmentList {
3988 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
3989 },
3990 );
3991 fragments.insert(
3992 "attr-data-title".to_string(),
3993 FragmentList {
3994 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
3995 },
3996 );
3997 fragments.insert(
3998 "attr-aria-label".to_string(),
3999 FragmentList {
4000 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
4001 },
4002 );
4003 fragments.insert(
4004 "my-component".to_string(),
4005 FragmentList {
4006 fragments: vec![
4007 WebUIFragment::raw("<span>"),
4008 WebUIFragment::signal("title", false),
4009 WebUIFragment::raw("|"),
4010 WebUIFragment::signal("dataTitle", false),
4011 WebUIFragment::raw("|"),
4012 WebUIFragment::signal("ariaLabel", false),
4013 WebUIFragment::raw("</span>"),
4014 ],
4015 },
4016 );
4017 let protocol = WebUIProtocol::new(fragments);
4018 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
4019 let mut writer = TestWriter::new();
4020 handle(
4021 &protocol,
4022 &state,
4023 &RenderOptions::new("index.html", "/"),
4024 &mut writer,
4025 )
4026 .unwrap();
4027 assert_eq!(
4028 writer.get_content(),
4029 "<my-component title=\"T:<t&1>\" data-title=\"D:d<2>\" aria-label=\"A:a&3\"><span>T:<t&1>|D:d<2>|A:a&3</span></my-component>"
4030 );
4031 }
4032
4033 #[test]
4034 fn test_attr_priority_over_global() {
4035 let mut fragments = HashMap::new();
4036 fragments.insert(
4037 "index.html".to_string(),
4038 FragmentList {
4039 fragments: vec![
4040 WebUIFragment::raw("<my-component"),
4041 WebUIFragment {
4042 fragment: Some(web_ui_fragment::Fragment::Attribute(
4043 WebUIFragmentAttribute {
4044 name: "title".into(),
4045 value: "Attribute Title".into(),
4046 raw_value: true,
4047 attr_start: true,
4048 ..Default::default()
4049 },
4050 )),
4051 },
4052 WebUIFragment::raw(">"),
4053 WebUIFragment::component("my-component"),
4054 WebUIFragment::raw("</my-component>"),
4055 ],
4056 },
4057 );
4058 fragments.insert(
4059 "my-component".to_string(),
4060 FragmentList {
4061 fragments: vec![
4062 WebUIFragment::raw("<span>"),
4063 WebUIFragment::signal("title", false),
4064 WebUIFragment::raw("</span>"),
4065 ],
4066 },
4067 );
4068 let protocol = WebUIProtocol::new(fragments);
4069 let state = test_json!({"title": "Global Title"});
4070 let mut writer = TestWriter::new();
4071 handle(
4072 &protocol,
4073 &state,
4074 &RenderOptions::new("index.html", "/"),
4075 &mut writer,
4076 )
4077 .unwrap();
4078 assert_eq!(
4079 writer.get_content(),
4080 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4081 );
4082 }
4083
4084 #[test]
4085 fn test_attr_priority_over_local_and_global() {
4086 let mut fragments = HashMap::new();
4087 fragments.insert(
4088 "index.html".to_string(),
4089 FragmentList {
4090 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
4091 },
4092 );
4093 fragments.insert(
4094 "loop".to_string(),
4095 FragmentList {
4096 fragments: vec![
4097 WebUIFragment::raw("<my-component"),
4098 WebUIFragment {
4099 fragment: Some(web_ui_fragment::Fragment::Attribute(
4100 WebUIFragmentAttribute {
4101 name: "title".into(),
4102 value: "Attribute Title".into(),
4103 raw_value: true,
4104 attr_start: true,
4105 ..Default::default()
4106 },
4107 )),
4108 },
4109 WebUIFragment::raw(">"),
4110 WebUIFragment::component("my-component"),
4111 WebUIFragment::raw("</my-component>"),
4112 ],
4113 },
4114 );
4115 fragments.insert(
4116 "my-component".to_string(),
4117 FragmentList {
4118 fragments: vec![
4119 WebUIFragment::raw("<span>"),
4120 WebUIFragment::signal("title", false),
4121 WebUIFragment::raw("</span>"),
4122 ],
4123 },
4124 );
4125 let protocol = WebUIProtocol::new(fragments);
4126 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
4127 let mut writer = TestWriter::new();
4128 handle(
4129 &protocol,
4130 &state,
4131 &RenderOptions::new("index.html", "/"),
4132 &mut writer,
4133 )
4134 .unwrap();
4135 assert_eq!(
4136 writer.get_content(),
4137 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
4138 );
4139 }
4140
4141 #[test]
4142 fn test_boolean_attr_first_component_attr() {
4143 let mut fragments = HashMap::new();
4144 fragments.insert(
4145 "index.html".to_string(),
4146 FragmentList {
4147 fragments: vec![
4148 WebUIFragment::raw("<my-component"),
4149 WebUIFragment {
4150 fragment: Some(web_ui_fragment::Fragment::Attribute(
4151 WebUIFragmentAttribute {
4152 name: "disabled".into(),
4153 attr_start: true,
4154 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4155 ..Default::default()
4156 },
4157 )),
4158 },
4159 WebUIFragment {
4160 fragment: Some(web_ui_fragment::Fragment::Attribute(
4161 WebUIFragmentAttribute {
4162 name: "label".into(),
4163 value: "Component Label".into(),
4164 raw_value: true,
4165 attr_start: false,
4166 ..Default::default()
4167 },
4168 )),
4169 },
4170 WebUIFragment::raw(">"),
4171 WebUIFragment::component("my-component"),
4172 WebUIFragment::raw("</my-component>"),
4173 ],
4174 },
4175 );
4176 fragments.insert(
4177 "my-component".to_string(),
4178 FragmentList {
4179 fragments: vec![
4180 WebUIFragment::if_cond(
4181 ConditionExpr::identifier("disabled"),
4182 "disabledTemplate",
4183 ),
4184 WebUIFragment::raw("<span>"),
4185 WebUIFragment::signal("label", false),
4186 WebUIFragment::raw("</span>"),
4187 ],
4188 },
4189 );
4190 fragments.insert(
4191 "disabledTemplate".to_string(),
4192 FragmentList {
4193 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
4194 },
4195 );
4196 let protocol = WebUIProtocol::new(fragments);
4197 let state = test_json!({"isDisabled": true});
4198 let mut writer = TestWriter::new();
4199 handle(
4200 &protocol,
4201 &state,
4202 &RenderOptions::new("index.html", "/"),
4203 &mut writer,
4204 )
4205 .unwrap();
4206 assert_eq!(
4207 writer.get_content(),
4208 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
4209 );
4210 }
4211
4212 #[test]
4213 fn test_hyphenated_attr_camelcase() {
4214 let mut fragments = HashMap::new();
4215 fragments.insert(
4216 "index.html".to_string(),
4217 FragmentList {
4218 fragments: vec![
4219 WebUIFragment::raw("<my-component"),
4220 WebUIFragment {
4221 fragment: Some(web_ui_fragment::Fragment::Attribute(
4222 WebUIFragmentAttribute {
4223 name: "key-hyphen".into(),
4224 value: "Local Value".into(),
4225 raw_value: true,
4226 attr_start: true,
4227 ..Default::default()
4228 },
4229 )),
4230 },
4231 WebUIFragment::raw(">"),
4232 WebUIFragment::component("my-component"),
4233 WebUIFragment::raw("</my-component>"),
4234 ],
4235 },
4236 );
4237 fragments.insert(
4238 "my-component".to_string(),
4239 FragmentList {
4240 fragments: vec![
4241 WebUIFragment::raw("<span>"),
4242 WebUIFragment::signal("keyHyphen", false),
4243 WebUIFragment::raw("</span>"),
4244 ],
4245 },
4246 );
4247 let protocol = WebUIProtocol::new(fragments);
4248 let state = test_json!({"keyHyphen": "Global Value"});
4249 let mut writer = TestWriter::new();
4250 handle(
4251 &protocol,
4252 &state,
4253 &RenderOptions::new("index.html", "/"),
4254 &mut writer,
4255 )
4256 .unwrap();
4257 assert_eq!(
4258 writer.get_content(),
4259 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
4260 );
4261 }
4262
4263 #[test]
4264 fn test_skipped_component_attrs() {
4265 let mut fragments = HashMap::new();
4269 fragments.insert(
4270 "index.html".to_string(),
4271 FragmentList {
4272 fragments: vec![
4273 WebUIFragment::raw("<test-component"),
4274 WebUIFragment {
4276 fragment: Some(web_ui_fragment::Fragment::Attribute(
4277 WebUIFragmentAttribute {
4278 name: "class".into(),
4279 value: "skippedClass".into(),
4280 attr_start: true,
4281 attr_skip: true,
4282 ..Default::default()
4283 },
4284 )),
4285 },
4286 WebUIFragment {
4288 fragment: Some(web_ui_fragment::Fragment::Attribute(
4289 WebUIFragmentAttribute {
4290 name: "style".into(),
4291 value: "skippedStyle".into(),
4292 attr_skip: true,
4293 ..Default::default()
4294 },
4295 )),
4296 },
4297 WebUIFragment {
4299 fragment: Some(web_ui_fragment::Fragment::Attribute(
4300 WebUIFragmentAttribute {
4301 name: "role".into(),
4302 value: "skippedRole".into(),
4303 attr_skip: true,
4304 ..Default::default()
4305 },
4306 )),
4307 },
4308 WebUIFragment {
4310 fragment: Some(web_ui_fragment::Fragment::Attribute(
4311 WebUIFragmentAttribute {
4312 name: "data-testid".into(),
4313 value: "skippedDataTestid".into(),
4314 attr_skip: true,
4315 ..Default::default()
4316 },
4317 )),
4318 },
4319 WebUIFragment {
4321 fragment: Some(web_ui_fragment::Fragment::Attribute(
4322 WebUIFragmentAttribute {
4323 name: "aria-label".into(),
4324 value: "skippedAriaLabel".into(),
4325 attr_skip: true,
4326 ..Default::default()
4327 },
4328 )),
4329 },
4330 WebUIFragment {
4332 fragment: Some(web_ui_fragment::Fragment::Attribute(
4333 WebUIFragmentAttribute {
4334 name: "title".into(),
4335 value: "title".into(),
4336 ..Default::default()
4337 },
4338 )),
4339 },
4340 WebUIFragment::raw(">"),
4341 WebUIFragment::component("test-component"),
4342 WebUIFragment::raw("</test-component>"),
4343 ],
4344 },
4345 );
4346 fragments.insert(
4347 "test-component".to_string(),
4348 FragmentList {
4349 fragments: vec![
4350 WebUIFragment::raw("<span>"),
4351 WebUIFragment::signal("title", false),
4352 WebUIFragment::raw("-"),
4353 WebUIFragment::signal("class", false),
4354 WebUIFragment::raw("-"),
4355 WebUIFragment::signal("style", false),
4356 WebUIFragment::raw("-"),
4357 WebUIFragment::signal("role", false),
4358 WebUIFragment::raw("-"),
4359 WebUIFragment::signal("dataTestid", false),
4360 WebUIFragment::raw("-"),
4361 WebUIFragment::signal("ariaLabel", false),
4362 WebUIFragment::raw("</span>"),
4363 ],
4364 },
4365 );
4366 let protocol = WebUIProtocol::new(fragments);
4367 let state = test_json!({
4368 "title": "Hello",
4369 "skippedClass": "my-class",
4370 "skippedStyle": "color:red",
4371 "skippedRole": "button",
4372 "skippedDataTestid": "test-id",
4373 "skippedAriaLabel": "label-text"
4374 });
4375 let mut writer = TestWriter::new();
4376 handle(
4377 &protocol,
4378 &state,
4379 &RenderOptions::new("index.html", "/"),
4380 &mut writer,
4381 )
4382 .unwrap();
4383 assert_eq!(
4387 writer.get_content(),
4388 "<test-component class=\"my-class\" style=\"color:red\" role=\"button\" data-testid=\"test-id\" aria-label=\"label-text\" title=\"Hello\"><span>Hello-----</span></test-component>"
4389 );
4390 }
4391
4392 #[test]
4395 fn test_attr_inherit_parent_to_child() {
4396 let mut fragments = HashMap::new();
4397 fragments.insert(
4398 "index.html".to_string(),
4399 FragmentList {
4400 fragments: vec![
4401 WebUIFragment::raw("<parent-component"),
4402 WebUIFragment {
4403 fragment: Some(web_ui_fragment::Fragment::Attribute(
4404 WebUIFragmentAttribute {
4405 name: "title".into(),
4406 value: "Parent Title".into(),
4407 raw_value: true,
4408 attr_start: true,
4409 ..Default::default()
4410 },
4411 )),
4412 },
4413 WebUIFragment::raw(">"),
4414 WebUIFragment::component("parent-component"),
4415 WebUIFragment::raw("</parent-component>"),
4416 ],
4417 },
4418 );
4419 fragments.insert(
4420 "parent-component".to_string(),
4421 FragmentList {
4422 fragments: vec![
4423 WebUIFragment::raw("<h1>"),
4424 WebUIFragment::signal("title", false),
4425 WebUIFragment::raw("</h1><child-component"),
4426 WebUIFragment {
4427 fragment: Some(web_ui_fragment::Fragment::Attribute(
4428 WebUIFragmentAttribute {
4429 name: "title".into(),
4430 value: "title".into(),
4431 attr_start: true,
4432 ..Default::default()
4433 },
4434 )),
4435 },
4436 WebUIFragment::raw(">"),
4437 WebUIFragment::component("child-component"),
4438 WebUIFragment::raw("</child-component>"),
4439 ],
4440 },
4441 );
4442 fragments.insert(
4443 "child-component".to_string(),
4444 FragmentList {
4445 fragments: vec![
4446 WebUIFragment::raw("<h2>"),
4447 WebUIFragment::signal("title", false),
4448 WebUIFragment::raw("</h2>"),
4449 ],
4450 },
4451 );
4452 let protocol = WebUIProtocol::new(fragments);
4453 let state = test_json!({});
4454 let mut writer = TestWriter::new();
4455 handle(
4456 &protocol,
4457 &state,
4458 &RenderOptions::new("index.html", "/"),
4459 &mut writer,
4460 )
4461 .unwrap();
4462 assert_eq!(
4463 writer.get_content(),
4464 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
4465 );
4466 }
4467
4468 #[test]
4469 fn test_attr_inherit_deep() {
4470 let mut fragments = HashMap::new();
4471 fragments.insert(
4472 "index.html".to_string(),
4473 FragmentList {
4474 fragments: vec![
4475 WebUIFragment::raw("<parent-component"),
4476 WebUIFragment {
4477 fragment: Some(web_ui_fragment::Fragment::Attribute(
4478 WebUIFragmentAttribute {
4479 name: "title".into(),
4480 value: "Parent Title".into(),
4481 raw_value: true,
4482 attr_start: true,
4483 ..Default::default()
4484 },
4485 )),
4486 },
4487 WebUIFragment::raw(">"),
4488 WebUIFragment::component("parent-component"),
4489 WebUIFragment::raw("</parent-component>"),
4490 ],
4491 },
4492 );
4493 fragments.insert(
4494 "parent-component".to_string(),
4495 FragmentList {
4496 fragments: vec![
4497 WebUIFragment::raw("<child-component"),
4498 WebUIFragment {
4499 fragment: Some(web_ui_fragment::Fragment::Attribute(
4500 WebUIFragmentAttribute {
4501 name: "title".into(),
4502 value: "Child Title".into(),
4503 raw_value: true,
4504 attr_start: true,
4505 ..Default::default()
4506 },
4507 )),
4508 },
4509 WebUIFragment::raw(">"),
4510 WebUIFragment::component("child-component"),
4511 WebUIFragment::raw("</child-component>"),
4512 ],
4513 },
4514 );
4515 fragments.insert(
4516 "child-component".to_string(),
4517 FragmentList {
4518 fragments: vec![
4519 WebUIFragment::raw("<grandchild-component"),
4520 WebUIFragment {
4521 fragment: Some(web_ui_fragment::Fragment::Attribute(
4522 WebUIFragmentAttribute {
4523 name: "title".into(),
4524 value: "title".into(),
4525 attr_start: true,
4526 ..Default::default()
4527 },
4528 )),
4529 },
4530 WebUIFragment::raw(">"),
4531 WebUIFragment::component("grandchild-component"),
4532 WebUIFragment::raw("</grandchild-component>"),
4533 ],
4534 },
4535 );
4536 fragments.insert(
4537 "grandchild-component".to_string(),
4538 FragmentList {
4539 fragments: vec![
4540 WebUIFragment::raw("<h3>"),
4541 WebUIFragment::signal("title", false),
4542 WebUIFragment::raw("</h3>"),
4543 ],
4544 },
4545 );
4546 let protocol = WebUIProtocol::new(fragments);
4547 let state = test_json!({});
4548 let mut writer = TestWriter::new();
4549 handle(
4550 &protocol,
4551 &state,
4552 &RenderOptions::new("index.html", "/"),
4553 &mut writer,
4554 )
4555 .unwrap();
4556 assert_eq!(
4557 writer.get_content(),
4558 "<parent-component title=\"Parent Title\"><child-component title=\"Child Title\"><grandchild-component title=\"Child Title\"><h3>Child Title</h3></grandchild-component></child-component></parent-component>"
4559 );
4560 }
4561
4562 #[test]
4563 fn test_complex_attr_access() {
4564 let mut fragments = HashMap::new();
4565 fragments.insert(
4566 "index.html".to_string(),
4567 FragmentList {
4568 fragments: vec![
4569 WebUIFragment::raw("<my-component"),
4570 WebUIFragment {
4571 fragment: Some(web_ui_fragment::Fragment::Attribute(
4572 WebUIFragmentAttribute {
4573 name: ":item".into(),
4574 value: "complexItem".into(),
4575 attr_start: true,
4576 complex: true,
4577 ..Default::default()
4578 },
4579 )),
4580 },
4581 WebUIFragment::raw(">"),
4582 WebUIFragment::component("my-component"),
4583 WebUIFragment::raw("</my-component>"),
4584 ],
4585 },
4586 );
4587 fragments.insert(
4588 "my-component".to_string(),
4589 FragmentList {
4590 fragments: vec![
4591 WebUIFragment::raw("<span>"),
4592 WebUIFragment::signal("item.foo", false),
4593 WebUIFragment::raw("</span><p>"),
4594 WebUIFragment::signal("item.bar", false),
4595 WebUIFragment::raw("</p>"),
4596 ],
4597 },
4598 );
4599 let protocol = WebUIProtocol::new(fragments);
4600 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
4601 let mut writer = TestWriter::new();
4602 handle(
4603 &protocol,
4604 &state,
4605 &RenderOptions::new("index.html", "/"),
4606 &mut writer,
4607 )
4608 .unwrap();
4609 assert_eq!(
4610 writer.get_content(),
4611 "<my-component><span>1</span><p>true</p></my-component>"
4612 );
4613 }
4614
4615 #[test]
4616 fn test_complex_attr_for_loop() {
4617 let mut fragments = HashMap::new();
4618 fragments.insert(
4619 "index.html".to_string(),
4620 FragmentList {
4621 fragments: vec![WebUIFragment::for_loop(
4622 "item",
4623 "list.items",
4624 "listTemplate",
4625 )],
4626 },
4627 );
4628 fragments.insert(
4629 "listTemplate".to_string(),
4630 FragmentList {
4631 fragments: vec![
4632 WebUIFragment {
4633 fragment: Some(web_ui_fragment::Fragment::Attribute(
4634 WebUIFragmentAttribute {
4635 name: ":item".into(),
4636 value: "item".into(),
4637 attr_start: true,
4638 complex: true,
4639 ..Default::default()
4640 },
4641 )),
4642 },
4643 WebUIFragment::component("item_component"),
4644 ],
4645 },
4646 );
4647 fragments.insert(
4648 "item_component".to_string(),
4649 FragmentList {
4650 fragments: vec![
4651 WebUIFragment::raw("<span>"),
4652 WebUIFragment::signal("item.name", false),
4653 WebUIFragment::raw("</span>"),
4654 ],
4655 },
4656 );
4657 let protocol = WebUIProtocol::new(fragments);
4658 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
4659 let mut writer = TestWriter::new();
4660 handle(
4661 &protocol,
4662 &state,
4663 &RenderOptions::new("index.html", "/"),
4664 &mut writer,
4665 )
4666 .unwrap();
4667 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
4668 }
4669
4670 #[test]
4671 fn test_complex_attr_nested_for() {
4672 let mut fragments = HashMap::new();
4673 fragments.insert(
4674 "index.html".to_string(),
4675 FragmentList {
4676 fragments: vec![WebUIFragment::for_loop(
4677 "outer",
4678 "data.outer",
4679 "outerTemplate",
4680 )],
4681 },
4682 );
4683 fragments.insert(
4684 "outerTemplate".to_string(),
4685 FragmentList {
4686 fragments: vec![WebUIFragment::for_loop(
4687 "middle",
4688 "outer.middle",
4689 "middleTemplate",
4690 )],
4691 },
4692 );
4693 fragments.insert(
4694 "middleTemplate".to_string(),
4695 FragmentList {
4696 fragments: vec![WebUIFragment::for_loop(
4697 "inner",
4698 "middle.inner",
4699 "innerTemplate",
4700 )],
4701 },
4702 );
4703 fragments.insert(
4704 "innerTemplate".to_string(),
4705 FragmentList {
4706 fragments: vec![
4707 WebUIFragment::raw("<card"),
4708 WebUIFragment {
4709 fragment: Some(web_ui_fragment::Fragment::Attribute(
4710 WebUIFragmentAttribute {
4711 name: ":outer".into(),
4712 value: "outer".into(),
4713 attr_start: true,
4714 complex: true,
4715 ..Default::default()
4716 },
4717 )),
4718 },
4719 WebUIFragment {
4720 fragment: Some(web_ui_fragment::Fragment::Attribute(
4721 WebUIFragmentAttribute {
4722 name: ":middle".into(),
4723 value: "middle".into(),
4724 attr_start: false,
4725 complex: true,
4726 ..Default::default()
4727 },
4728 )),
4729 },
4730 WebUIFragment {
4731 fragment: Some(web_ui_fragment::Fragment::Attribute(
4732 WebUIFragmentAttribute {
4733 name: ":inner".into(),
4734 value: "inner".into(),
4735 attr_start: false,
4736 complex: true,
4737 ..Default::default()
4738 },
4739 )),
4740 },
4741 WebUIFragment::raw(">"),
4742 WebUIFragment::component("card_component"),
4743 WebUIFragment::raw("</card>"),
4744 ],
4745 },
4746 );
4747 fragments.insert(
4748 "card_component".to_string(),
4749 FragmentList {
4750 fragments: vec![
4751 WebUIFragment::raw("<p>"),
4752 WebUIFragment::signal("outer.label", false),
4753 WebUIFragment::raw(" / "),
4754 WebUIFragment::signal("middle.label", false),
4755 WebUIFragment::raw(" / "),
4756 WebUIFragment::signal("inner.label", false),
4757 WebUIFragment::raw("</p>"),
4758 ],
4759 },
4760 );
4761 let protocol = WebUIProtocol::new(fragments);
4762 let state = test_json!({"data": {"outer": [
4763 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
4764 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
4765 ]}});
4766 let mut writer = TestWriter::new();
4767 handle(
4768 &protocol,
4769 &state,
4770 &RenderOptions::new("index.html", "/"),
4771 &mut writer,
4772 )
4773 .unwrap();
4774 assert_eq!(
4775 writer.get_content(),
4776 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
4777 );
4778 }
4779
4780 #[test]
4783 fn test_bool_component_state_true() {
4784 let mut fragments = HashMap::new();
4785 fragments.insert(
4786 "index.html".to_string(),
4787 FragmentList {
4788 fragments: vec![
4789 WebUIFragment::raw("<my-component"),
4790 WebUIFragment {
4791 fragment: Some(web_ui_fragment::Fragment::Attribute(
4792 WebUIFragmentAttribute {
4793 name: "disabled".into(),
4794 attr_start: true,
4795 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4796 ..Default::default()
4797 },
4798 )),
4799 },
4800 WebUIFragment::raw(">"),
4801 WebUIFragment::component("my-component"),
4802 WebUIFragment::raw("</my-component>"),
4803 ],
4804 },
4805 );
4806 fragments.insert(
4807 "my-component".to_string(),
4808 FragmentList {
4809 fragments: vec![
4810 WebUIFragment::if_cond(
4811 ConditionExpr::identifier("disabled"),
4812 "disabledTemplate",
4813 ),
4814 WebUIFragment::if_cond(
4815 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4816 "enabledTemplate",
4817 ),
4818 ],
4819 },
4820 );
4821 fragments.insert(
4822 "disabledTemplate".to_string(),
4823 FragmentList {
4824 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4825 },
4826 );
4827 fragments.insert(
4828 "enabledTemplate".to_string(),
4829 FragmentList {
4830 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4831 },
4832 );
4833 let protocol = WebUIProtocol::new(fragments);
4834 let state = test_json!({"isDisabled": true});
4835 let mut writer = TestWriter::new();
4836 handle(
4837 &protocol,
4838 &state,
4839 &RenderOptions::new("index.html", "/"),
4840 &mut writer,
4841 )
4842 .unwrap();
4843 assert_eq!(
4844 writer.get_content(),
4845 "<my-component disabled><span>Disabled</span></my-component>"
4846 );
4847 }
4848
4849 #[test]
4850 fn test_bool_component_state_false() {
4851 let mut fragments = HashMap::new();
4852 fragments.insert(
4853 "index.html".to_string(),
4854 FragmentList {
4855 fragments: vec![
4856 WebUIFragment::raw("<my-component"),
4857 WebUIFragment {
4858 fragment: Some(web_ui_fragment::Fragment::Attribute(
4859 WebUIFragmentAttribute {
4860 name: "disabled".into(),
4861 attr_start: true,
4862 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4863 ..Default::default()
4864 },
4865 )),
4866 },
4867 WebUIFragment::raw(">"),
4868 WebUIFragment::component("my-component"),
4869 WebUIFragment::raw("</my-component>"),
4870 ],
4871 },
4872 );
4873 fragments.insert(
4874 "my-component".to_string(),
4875 FragmentList {
4876 fragments: vec![
4877 WebUIFragment::if_cond(
4878 ConditionExpr::identifier("disabled"),
4879 "disabledTemplate",
4880 ),
4881 WebUIFragment::if_cond(
4882 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4883 "enabledTemplate",
4884 ),
4885 ],
4886 },
4887 );
4888 fragments.insert(
4889 "disabledTemplate".to_string(),
4890 FragmentList {
4891 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
4892 },
4893 );
4894 fragments.insert(
4895 "enabledTemplate".to_string(),
4896 FragmentList {
4897 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
4898 },
4899 );
4900 let protocol = WebUIProtocol::new(fragments);
4901 let state = test_json!({"isDisabled": false});
4902 let mut writer = TestWriter::new();
4903 handle(
4904 &protocol,
4905 &state,
4906 &RenderOptions::new("index.html", "/"),
4907 &mut writer,
4908 )
4909 .unwrap();
4910 assert_eq!(
4911 writer.get_content(),
4912 "<my-component><span>Enabled</span></my-component>"
4913 );
4914 }
4915
4916 #[test]
4917 fn test_bool_component_state_forward() {
4918 let mut fragments = HashMap::new();
4919 fragments.insert(
4920 "index.html".to_string(),
4921 FragmentList {
4922 fragments: vec![
4923 WebUIFragment::raw("<parent-component"),
4924 WebUIFragment {
4925 fragment: Some(web_ui_fragment::Fragment::Attribute(
4926 WebUIFragmentAttribute {
4927 name: "disabled".into(),
4928 attr_start: true,
4929 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
4930 ..Default::default()
4931 },
4932 )),
4933 },
4934 WebUIFragment::raw(">"),
4935 WebUIFragment::component("parent-component"),
4936 WebUIFragment::raw("</parent-component>"),
4937 ],
4938 },
4939 );
4940 fragments.insert(
4941 "parent-component".to_string(),
4942 FragmentList {
4943 fragments: vec![
4944 WebUIFragment::if_cond(
4945 ConditionExpr::identifier("disabled"),
4946 "parentDisabledTemplate",
4947 ),
4948 WebUIFragment::raw("<child-component"),
4949 WebUIFragment {
4950 fragment: Some(web_ui_fragment::Fragment::Attribute(
4951 WebUIFragmentAttribute {
4952 name: "disabled".into(),
4953 attr_start: true,
4954 condition_tree: Some(ConditionExpr::identifier("disabled")),
4955 ..Default::default()
4956 },
4957 )),
4958 },
4959 WebUIFragment::raw(">"),
4960 WebUIFragment::component("child-component"),
4961 WebUIFragment::raw("</child-component>"),
4962 ],
4963 },
4964 );
4965 fragments.insert(
4966 "parentDisabledTemplate".to_string(),
4967 FragmentList {
4968 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
4969 },
4970 );
4971 fragments.insert(
4972 "child-component".to_string(),
4973 FragmentList {
4974 fragments: vec![
4975 WebUIFragment::if_cond(
4976 ConditionExpr::identifier("disabled"),
4977 "childDisabledTemplate",
4978 ),
4979 WebUIFragment::if_cond(
4980 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
4981 "childEnabledTemplate",
4982 ),
4983 ],
4984 },
4985 );
4986 fragments.insert(
4987 "childDisabledTemplate".to_string(),
4988 FragmentList {
4989 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
4990 },
4991 );
4992 fragments.insert(
4993 "childEnabledTemplate".to_string(),
4994 FragmentList {
4995 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
4996 },
4997 );
4998
4999 {
5001 let protocol = WebUIProtocol::new(fragments.clone());
5002 let state = test_json!({"isDisabled": true});
5003 let mut writer = TestWriter::new();
5004 handle(
5005 &protocol,
5006 &state,
5007 &RenderOptions::new("index.html", "/"),
5008 &mut writer,
5009 )
5010 .unwrap();
5011 assert_eq!(
5012 writer.get_content(),
5013 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
5014 );
5015 }
5016
5017 {
5019 let protocol = WebUIProtocol::new(fragments.clone());
5020 let state = test_json!({"isDisabled": false});
5021 let mut writer = TestWriter::new();
5022 handle(
5023 &protocol,
5024 &state,
5025 &RenderOptions::new("index.html", "/"),
5026 &mut writer,
5027 )
5028 .unwrap();
5029 assert_eq!(
5030 writer.get_content(),
5031 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
5032 );
5033 }
5034 }
5035
5036 #[test]
5043 fn test_component_with_template() {
5044 let mut fragments = HashMap::new();
5045 fragments.insert(
5046 "index.html".to_string(),
5047 FragmentList {
5048 fragments: vec![
5049 WebUIFragment::raw("<custom-element>"),
5050 WebUIFragment::component("custom-element"),
5051 WebUIFragment::raw("</custom-element>"),
5052 ],
5053 },
5054 );
5055 fragments.insert(
5056 "custom-element".to_string(),
5057 FragmentList {
5058 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
5059 },
5060 );
5061 let protocol = WebUIProtocol::new(fragments);
5062 let state = test_json!({});
5063 let mut writer = TestWriter::new();
5064 handle(
5065 &protocol,
5066 &state,
5067 &RenderOptions::new("index.html", "/"),
5068 &mut writer,
5069 )
5070 .unwrap();
5071 assert_eq!(
5072 writer.get_content(),
5073 "<custom-element><div>Custom Element</div></custom-element>"
5074 );
5075 assert!(writer.is_ended());
5076 }
5077
5078 #[test]
5079 fn test_component_with_slots() {
5080 let mut fragments = HashMap::new();
5081 fragments.insert(
5082 "index.html".to_string(),
5083 FragmentList {
5084 fragments: vec![
5085 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
5086 WebUIFragment::component("custom-element"),
5087 WebUIFragment::raw("Hello World</custom-element>"),
5088 ],
5089 },
5090 );
5091 fragments.insert(
5092 "custom-element".to_string(),
5093 FragmentList {
5094 fragments: vec![WebUIFragment::raw("<slot></slot>")],
5095 },
5096 );
5097 let protocol = WebUIProtocol::new(fragments);
5098 let state = test_json!({});
5099 let mut writer = TestWriter::new();
5100 handle(
5101 &protocol,
5102 &state,
5103 &RenderOptions::new("index.html", "/"),
5104 &mut writer,
5105 )
5106 .unwrap();
5107 assert_eq!(
5108 writer.get_content(),
5109 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
5110 );
5111 assert!(writer.is_ended());
5112 }
5113
5114 #[test]
5115 fn test_multiple_nested_components() {
5116 let mut fragments = HashMap::new();
5117 fragments.insert(
5118 "index.html".to_string(),
5119 FragmentList {
5120 fragments: vec![
5121 WebUIFragment::raw("<div>"),
5122 WebUIFragment::for_loop("item", "items", "templateRepeat"),
5123 WebUIFragment::raw("</div>"),
5124 ],
5125 },
5126 );
5127 fragments.insert(
5128 "custom-button".to_string(),
5129 FragmentList {
5130 fragments: vec![WebUIFragment::raw("<slot></slot>")],
5131 },
5132 );
5133 fragments.insert(
5134 "custom-element".to_string(),
5135 FragmentList {
5136 fragments: vec![
5137 WebUIFragment::raw("<custom-child>"),
5138 WebUIFragment::component("custom-child"),
5139 WebUIFragment::raw("</custom-child><slot></slot>"),
5140 ],
5141 },
5142 );
5143 fragments.insert(
5144 "custom-child".to_string(),
5145 FragmentList {
5146 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
5147 },
5148 );
5149 fragments.insert(
5150 "templateRepeat".to_string(),
5151 FragmentList {
5152 fragments: vec![
5153 WebUIFragment::raw("<custom-element>"),
5154 WebUIFragment::component("custom-element"),
5155 WebUIFragment::raw("<custom-button>"),
5156 WebUIFragment::component("custom-button"),
5157 WebUIFragment::raw("Ok</custom-button></custom-element>"),
5158 ],
5159 },
5160 );
5161 let protocol = WebUIProtocol::new(fragments);
5162 let state = test_json!({"items": [{"name": "Item1"}]});
5163 let mut writer = TestWriter::new();
5164 handle(
5165 &protocol,
5166 &state,
5167 &RenderOptions::new("index.html", "/"),
5168 &mut writer,
5169 )
5170 .unwrap();
5171 assert_eq!(
5172 writer.get_content(),
5173 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
5174 );
5175 assert!(writer.is_ended());
5176 }
5177
5178 #[test]
5181 fn test_if_with_binary_expression() {
5182 let mut fragments = HashMap::new();
5183 fragments.insert(
5184 "index.html".to_string(),
5185 FragmentList {
5186 fragments: vec![
5187 WebUIFragment::raw("<div>"),
5188 WebUIFragment::if_cond(
5189 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
5190 "if-1",
5191 ),
5192 WebUIFragment::raw("</div>"),
5193 ],
5194 },
5195 );
5196 fragments.insert(
5197 "if-1".to_string(),
5198 FragmentList {
5199 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
5200 },
5201 );
5202 let protocol = WebUIProtocol::new(fragments);
5203
5204 let state_true = test_json!({"x": 10});
5206 let mut writer_true = TestWriter::new();
5207 handle(
5208 &protocol,
5209 &state_true,
5210 &RenderOptions::new("index.html", "/"),
5211 &mut writer_true,
5212 )
5213 .unwrap();
5214 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
5215
5216 let state_false = test_json!({"x": 1});
5218 let mut writer_false = TestWriter::new();
5219 handle(
5220 &protocol,
5221 &state_false,
5222 &RenderOptions::new("index.html", "/"),
5223 &mut writer_false,
5224 )
5225 .unwrap();
5226 assert_eq!(writer_false.get_content(), "<div></div>");
5227 }
5228
5229 #[test]
5230 fn test_for_if_overlapping_local_state() {
5231 let mut fragments = HashMap::new();
5232 fragments.insert(
5233 "index.html".to_string(),
5234 FragmentList {
5235 fragments: vec![
5236 WebUIFragment::raw("<div>"),
5237 WebUIFragment::for_loop("item", "items", "template1"),
5238 WebUIFragment::raw("</div>"),
5239 ],
5240 },
5241 );
5242 fragments.insert(
5243 "template1".to_string(),
5244 FragmentList {
5245 fragments: vec![
5246 WebUIFragment::raw("<div>"),
5247 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5248 WebUIFragment::raw("</div>"),
5249 ],
5250 },
5251 );
5252 fragments.insert(
5253 "ifBlock".to_string(),
5254 FragmentList {
5255 fragments: vec![
5256 WebUIFragment::raw("<span>"),
5257 WebUIFragment::signal("item.label", false),
5258 WebUIFragment::raw("</span>"),
5259 ],
5260 },
5261 );
5262 let protocol = WebUIProtocol::new(fragments);
5263 let state = test_json!({
5264 "flag": false,
5265 "items": [
5266 {"label": "A", "flag": true},
5267 {"label": "B", "flag": false},
5268 {"label": "C", "flag": true}
5269 ]
5270 });
5271 let mut writer = TestWriter::new();
5272 handle(
5273 &protocol,
5274 &state,
5275 &RenderOptions::new("index.html", "/"),
5276 &mut writer,
5277 )
5278 .unwrap();
5279 assert_eq!(
5280 writer.get_content(),
5281 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
5282 );
5283 }
5284
5285 #[test]
5286 fn test_for_if_global_flag_no_effect() {
5287 let mut fragments = HashMap::new();
5288 fragments.insert(
5289 "index.html".to_string(),
5290 FragmentList {
5291 fragments: vec![
5292 WebUIFragment::raw("<div>"),
5293 WebUIFragment::for_loop("item", "items", "template1"),
5294 WebUIFragment::raw("</div>"),
5295 ],
5296 },
5297 );
5298 fragments.insert(
5299 "template1".to_string(),
5300 FragmentList {
5301 fragments: vec![
5302 WebUIFragment::raw("<div>"),
5303 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
5304 WebUIFragment::raw("</div>"),
5305 ],
5306 },
5307 );
5308 fragments.insert(
5309 "ifBlock".to_string(),
5310 FragmentList {
5311 fragments: vec![
5312 WebUIFragment::raw("<span>"),
5313 WebUIFragment::signal("item.label", false),
5314 WebUIFragment::raw("</span>"),
5315 ],
5316 },
5317 );
5318 let protocol = WebUIProtocol::new(fragments);
5319 let state = test_json!({
5320 "item": {"flag": true},
5321 "items": [
5322 {"label": "A", "flag": false},
5323 {"label": "B", "flag": true},
5324 {"label": "C", "flag": false}
5325 ]
5326 });
5327 let mut writer = TestWriter::new();
5328 handle(
5329 &protocol,
5330 &state,
5331 &RenderOptions::new("index.html", "/"),
5332 &mut writer,
5333 )
5334 .unwrap();
5335 assert_eq!(
5336 writer.get_content(),
5337 "<div><div></div><div><span>B</span></div><div></div></div>"
5338 );
5339 }
5340
5341 #[test]
5344 fn test_recursive_template_refs() {
5345 let mut fragments = HashMap::new();
5346 fragments.insert(
5347 "index.html".to_string(),
5348 FragmentList {
5349 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
5350 },
5351 );
5352 fragments.insert(
5353 "static".to_string(),
5354 FragmentList {
5355 fragments: vec![
5356 WebUIFragment::raw("<div expanded=\""),
5357 WebUIFragment::signal("item.expanded", false),
5358 WebUIFragment::raw("\" class=\""),
5359 WebUIFragment::signal("testScenario", false),
5360 WebUIFragment::raw("\"><span>"),
5361 WebUIFragment::signal("item.name", false),
5362 WebUIFragment::raw("</span>"),
5363 WebUIFragment::for_loop("item", "item.children", "static"),
5364 WebUIFragment::raw("</div>"),
5365 ],
5366 },
5367 );
5368 let protocol = WebUIProtocol::new(fragments);
5369 let state = test_json!({
5370 "testScenario": "RecursiveTemplatesWithGlobalState",
5371 "items": [
5372 {"name": "A", "expanded": "false", "children": []},
5373 {"name": "B", "expanded": "true", "children": [
5374 {"name": "C", "expanded": "false"},
5375 {"name": "D", "expanded": "false"}
5376 ]},
5377 {"name": "E", "expanded": "false"}
5378 ]
5379 });
5380 let mut writer = TestWriter::new();
5381 handle(
5382 &protocol,
5383 &state,
5384 &RenderOptions::new("index.html", "/"),
5385 &mut writer,
5386 )
5387 .unwrap();
5388 assert_eq!(
5389 writer.get_content(),
5390 "<div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>A</span></div><div expanded=\"true\" class=\"RecursiveTemplatesWithGlobalState\"><span>B</span><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>C</span></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>D</span></div></div><div expanded=\"false\" class=\"RecursiveTemplatesWithGlobalState\"><span>E</span></div>"
5391 );
5392 }
5393
5394 #[test]
5397 fn test_component_in_for_no_local_access() {
5398 let mut fragments = HashMap::new();
5399 fragments.insert(
5400 "index.html".to_string(),
5401 FragmentList {
5402 fragments: vec![
5403 WebUIFragment::raw("<div>"),
5404 WebUIFragment::for_loop("item", "items", "templateComponent"),
5405 WebUIFragment::raw("</div>"),
5406 ],
5407 },
5408 );
5409 fragments.insert(
5410 "templateComponent".to_string(),
5411 FragmentList {
5412 fragments: vec![
5413 WebUIFragment::raw("<component-tag>"),
5414 WebUIFragment::component("my-component"),
5415 WebUIFragment::raw("</component-tag>"),
5416 ],
5417 },
5418 );
5419 fragments.insert(
5420 "my-component".to_string(),
5421 FragmentList {
5422 fragments: vec![
5423 WebUIFragment::raw("<span>"),
5424 WebUIFragment::signal("name", false),
5425 WebUIFragment::raw("</span>"),
5426 ],
5427 },
5428 );
5429 let protocol = WebUIProtocol::new(fragments);
5430 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
5431 let mut writer = TestWriter::new();
5432 handle(
5433 &protocol,
5434 &state,
5435 &RenderOptions::new("index.html", "/"),
5436 &mut writer,
5437 )
5438 .unwrap();
5439 assert_eq!(
5440 writer.get_content(),
5441 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
5442 );
5443 }
5444
5445 #[test]
5446 fn test_nested_for_hierarchical_state() {
5447 let mut fragments = HashMap::new();
5448 fragments.insert(
5449 "index.html".to_string(),
5450 FragmentList {
5451 fragments: vec![
5452 WebUIFragment::raw("<div>"),
5453 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5454 WebUIFragment::raw("</div>"),
5455 ],
5456 },
5457 );
5458 fragments.insert(
5459 "outerTemplate".to_string(),
5460 FragmentList {
5461 fragments: vec![
5462 WebUIFragment::raw("<section>"),
5463 WebUIFragment::signal("globalPrefix", false),
5464 WebUIFragment::signal("outerItem.outerLabel", false),
5465 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5466 WebUIFragment::raw("</section>"),
5467 ],
5468 },
5469 );
5470 fragments.insert(
5471 "innerTemplate".to_string(),
5472 FragmentList {
5473 fragments: vec![
5474 WebUIFragment::raw("<p>"),
5475 WebUIFragment::signal("globalPrefix", false),
5476 WebUIFragment::signal("outerItem.outerLabel", false),
5477 WebUIFragment::raw(": "),
5478 WebUIFragment::signal("innerItem.innerLabel", false),
5479 WebUIFragment::raw("</p>"),
5480 ],
5481 },
5482 );
5483 let protocol = WebUIProtocol::new(fragments);
5484 let state = test_json!({
5485 "globalPrefix": "Prefix: ",
5486 "outerItems": [
5487 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5488 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
5489 ]
5490 });
5491 let mut writer = TestWriter::new();
5492 handle(
5493 &protocol,
5494 &state,
5495 &RenderOptions::new("index.html", "/"),
5496 &mut writer,
5497 )
5498 .unwrap();
5499 assert_eq!(
5500 writer.get_content(),
5501 "<div><section>Prefix: O1<p>Prefix: O1: I1</p><p>Prefix: O1: I2</p></section><section>Prefix: O2<p>Prefix: O2: I3</p></section></div>"
5502 );
5503 }
5504
5505 #[test]
5506 fn test_component_in_for_global_only() {
5507 let mut fragments = HashMap::new();
5508 fragments.insert(
5509 "index.html".to_string(),
5510 FragmentList {
5511 fragments: vec![
5512 WebUIFragment::raw("<div>"),
5513 WebUIFragment::for_loop("item", "items", "templateComponent"),
5514 WebUIFragment::raw("</div>"),
5515 ],
5516 },
5517 );
5518 fragments.insert(
5519 "templateComponent".to_string(),
5520 FragmentList {
5521 fragments: vec![
5522 WebUIFragment::raw("<component-tag>"),
5523 WebUIFragment::component("my-component"),
5524 WebUIFragment::raw("</component-tag>"),
5525 ],
5526 },
5527 );
5528 fragments.insert(
5529 "my-component".to_string(),
5530 FragmentList {
5531 fragments: vec![
5532 WebUIFragment::raw("<span>"),
5533 WebUIFragment::signal("name", false),
5534 WebUIFragment::raw("-"),
5535 WebUIFragment::signal("globalSuffix", false),
5536 WebUIFragment::raw("</span>"),
5537 ],
5538 },
5539 );
5540 let protocol = WebUIProtocol::new(fragments);
5541 let state =
5542 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5543 let mut writer = TestWriter::new();
5544 handle(
5545 &protocol,
5546 &state,
5547 &RenderOptions::new("index.html", "/"),
5548 &mut writer,
5549 )
5550 .unwrap();
5551 assert_eq!(
5552 writer.get_content(),
5553 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5554 );
5555 }
5556
5557 #[test]
5558 fn test_component_no_item_moniker() {
5559 let mut fragments = HashMap::new();
5560 fragments.insert(
5561 "index.html".to_string(),
5562 FragmentList {
5563 fragments: vec![
5564 WebUIFragment::raw("<div>"),
5565 WebUIFragment::for_loop("item", "items", "templateComponent"),
5566 WebUIFragment::raw("</div>"),
5567 ],
5568 },
5569 );
5570 fragments.insert(
5571 "templateComponent".to_string(),
5572 FragmentList {
5573 fragments: vec![
5574 WebUIFragment::raw("<component-tag>"),
5575 WebUIFragment::component("my-component"),
5576 WebUIFragment::raw("</component-tag>"),
5577 ],
5578 },
5579 );
5580 fragments.insert(
5581 "my-component".to_string(),
5582 FragmentList {
5583 fragments: vec![
5584 WebUIFragment::raw("<span>"),
5585 WebUIFragment::signal("item.name", false),
5586 WebUIFragment::raw("-"),
5587 WebUIFragment::signal("globalSuffix", false),
5588 WebUIFragment::raw("</span>"),
5589 ],
5590 },
5591 );
5592 let protocol = WebUIProtocol::new(fragments);
5593 let state =
5594 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
5595 let mut writer = TestWriter::new();
5596 handle(
5597 &protocol,
5598 &state,
5599 &RenderOptions::new("index.html", "/"),
5600 &mut writer,
5601 )
5602 .unwrap();
5603 assert_eq!(
5604 writer.get_content(),
5605 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
5606 );
5607 }
5608
5609 #[test]
5610 fn test_for_nonqualified_uses_global() {
5611 let mut fragments = HashMap::new();
5612 fragments.insert(
5613 "index.html".to_string(),
5614 FragmentList {
5615 fragments: vec![
5616 WebUIFragment::raw("<div>"),
5617 WebUIFragment::for_loop("item", "items", "template1"),
5618 WebUIFragment::raw("</div>"),
5619 ],
5620 },
5621 );
5622 fragments.insert(
5623 "template1".to_string(),
5624 FragmentList {
5625 fragments: vec![
5626 WebUIFragment::raw("<span>"),
5627 WebUIFragment::signal("name", false),
5628 WebUIFragment::raw("</span>"),
5629 ],
5630 },
5631 );
5632 let protocol = WebUIProtocol::new(fragments);
5633 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
5634 let mut writer = TestWriter::new();
5635 handle(
5636 &protocol,
5637 &state,
5638 &RenderOptions::new("index.html", "/"),
5639 &mut writer,
5640 )
5641 .unwrap();
5642 assert_eq!(
5643 writer.get_content(),
5644 "<div><span>GlobalName</span><span>GlobalName</span></div>"
5645 );
5646 }
5647
5648 #[test]
5649 fn test_nested_for_if_interleaved() {
5650 let mut fragments = HashMap::new();
5651 fragments.insert(
5652 "index.html".to_string(),
5653 FragmentList {
5654 fragments: vec![
5655 WebUIFragment::raw("<div>"),
5656 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5657 WebUIFragment::raw("</div>"),
5658 ],
5659 },
5660 );
5661 fragments.insert(
5662 "outerTemplate".to_string(),
5663 FragmentList {
5664 fragments: vec![
5665 WebUIFragment::raw("<section>"),
5666 WebUIFragment::signal("globalPrefix", false),
5667 WebUIFragment::signal("outerItem.outerLabel", false),
5668 WebUIFragment::if_cond(
5669 ConditionExpr::identifier("outerItem.include"),
5670 "ifTemplate",
5671 ),
5672 WebUIFragment::raw("</section>"),
5673 ],
5674 },
5675 );
5676 fragments.insert(
5677 "ifTemplate".to_string(),
5678 FragmentList {
5679 fragments: vec![
5680 WebUIFragment::raw("<div>"),
5681 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5682 WebUIFragment::raw("</div>"),
5683 ],
5684 },
5685 );
5686 fragments.insert(
5687 "innerTemplate".to_string(),
5688 FragmentList {
5689 fragments: vec![
5690 WebUIFragment::raw("<p>"),
5691 WebUIFragment::signal("globalSuffix", false),
5692 WebUIFragment::raw(": "),
5693 WebUIFragment::signal("innerItem.innerLabel", false),
5694 WebUIFragment::raw("</p>"),
5695 ],
5696 },
5697 );
5698 let protocol = WebUIProtocol::new(fragments);
5699 let state = test_json!({
5700 "globalPrefix": "Prefix: ",
5701 "globalSuffix": "Suffix",
5702 "outerItems": [
5703 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
5704 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
5705 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
5706 ]
5707 });
5708 let mut writer = TestWriter::new();
5709 handle(
5710 &protocol,
5711 &state,
5712 &RenderOptions::new("index.html", "/"),
5713 &mut writer,
5714 )
5715 .unwrap();
5716 assert_eq!(
5717 writer.get_content(),
5718 "<div><section>Prefix: O1<div><p>Suffix: I1</p><p>Suffix: I2</p></div></section><section>Prefix: O2</section><section>Prefix: O3<div><p>Suffix: I3</p></div></section></div>"
5719 );
5720 }
5721
5722 #[test]
5723 fn test_nested_for_if_outer_state() {
5724 let mut fragments = HashMap::new();
5725 fragments.insert(
5726 "index.html".to_string(),
5727 FragmentList {
5728 fragments: vec![
5729 WebUIFragment::raw("<div>"),
5730 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5731 WebUIFragment::raw("</div>"),
5732 ],
5733 },
5734 );
5735 fragments.insert(
5736 "outerTemplate".to_string(),
5737 FragmentList {
5738 fragments: vec![
5739 WebUIFragment::raw("<section>"),
5740 WebUIFragment::signal("globalPrefix", false),
5741 WebUIFragment::signal("outerItem.label", false),
5742 WebUIFragment::for_loop(
5743 "middleItem",
5744 "outerItem.middleItems",
5745 "middleTemplate",
5746 ),
5747 WebUIFragment::raw("</section>"),
5748 ],
5749 },
5750 );
5751 fragments.insert(
5752 "middleTemplate".to_string(),
5753 FragmentList {
5754 fragments: vec![
5755 WebUIFragment::raw("<div>"),
5756 WebUIFragment::if_cond(
5757 ConditionExpr::identifier("outerItem.active"),
5758 "ifTemplate",
5759 ),
5760 WebUIFragment::raw("</div>"),
5761 ],
5762 },
5763 );
5764 fragments.insert(
5765 "ifTemplate".to_string(),
5766 FragmentList {
5767 fragments: vec![
5768 WebUIFragment::raw("<p>"),
5769 WebUIFragment::signal("middleItem.value", false),
5770 WebUIFragment::raw("</p>"),
5771 ],
5772 },
5773 );
5774 let protocol = WebUIProtocol::new(fragments);
5775 let state = test_json!({
5776 "globalPrefix": "GP-",
5777 "outerItems": [
5778 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
5779 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
5780 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
5781 ]
5782 });
5783 let mut writer = TestWriter::new();
5784 handle(
5785 &protocol,
5786 &state,
5787 &RenderOptions::new("index.html", "/"),
5788 &mut writer,
5789 )
5790 .unwrap();
5791 assert_eq!(
5792 writer.get_content(),
5793 "<div><section>GP-O1<div><p>M1</p></div><div><p>M2</p></div></section><section>GP-O2<div></div></section><section>GP-O3<div><p>M4</p></div></section></div>"
5794 );
5795 }
5796
5797 #[test]
5798 fn test_nested_for_if_inner_state() {
5799 let mut fragments = HashMap::new();
5800 fragments.insert(
5801 "index.html".to_string(),
5802 FragmentList {
5803 fragments: vec![
5804 WebUIFragment::raw("<div>"),
5805 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5806 WebUIFragment::raw("</div>"),
5807 ],
5808 },
5809 );
5810 fragments.insert(
5811 "outerTemplate".to_string(),
5812 FragmentList {
5813 fragments: vec![
5814 WebUIFragment::raw("<section>"),
5815 WebUIFragment::signal("outerItem.label", false),
5816 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5817 WebUIFragment::raw("</section>"),
5818 ],
5819 },
5820 );
5821 fragments.insert(
5822 "innerTemplate".to_string(),
5823 FragmentList {
5824 fragments: vec![
5825 WebUIFragment::raw("<article>"),
5826 WebUIFragment::if_cond(
5827 ConditionExpr::identifier("innerItem.show"),
5828 "ifTemplate",
5829 ),
5830 WebUIFragment::raw("</article>"),
5831 ],
5832 },
5833 );
5834 fragments.insert(
5835 "ifTemplate".to_string(),
5836 FragmentList {
5837 fragments: vec![
5838 WebUIFragment::raw("<p>"),
5839 WebUIFragment::signal("innerItem.detail", false),
5840 WebUIFragment::raw("</p>"),
5841 ],
5842 },
5843 );
5844 let protocol = WebUIProtocol::new(fragments);
5845 let state = test_json!({
5846 "outerItems": [
5847 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
5848 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
5849 ]
5850 });
5851 let mut writer = TestWriter::new();
5852 handle(
5853 &protocol,
5854 &state,
5855 &RenderOptions::new("index.html", "/"),
5856 &mut writer,
5857 )
5858 .unwrap();
5859 assert_eq!(
5860 writer.get_content(),
5861 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
5862 );
5863 }
5864
5865 #[test]
5866 fn test_for_merge_local_global_monikers() {
5867 let mut fragments = HashMap::new();
5868 fragments.insert(
5869 "index.html".to_string(),
5870 FragmentList {
5871 fragments: vec![
5872 WebUIFragment::raw("<div>"),
5873 WebUIFragment::for_loop("item", "items", "template1"),
5874 WebUIFragment::raw("</div>"),
5875 ],
5876 },
5877 );
5878 fragments.insert(
5879 "template1".to_string(),
5880 FragmentList {
5881 fragments: vec![
5882 WebUIFragment::raw("<span>"),
5883 WebUIFragment::signal("item.name", false),
5884 WebUIFragment::raw("-"),
5885 WebUIFragment::signal("item.globalValue", false),
5886 WebUIFragment::raw("-"),
5887 WebUIFragment::signal("item.localOnly", false),
5888 WebUIFragment::raw("-"),
5889 WebUIFragment::signal("item.otherVal", false),
5890 WebUIFragment::raw("</span>"),
5891 ],
5892 },
5893 );
5894 let protocol = WebUIProtocol::new(fragments);
5895 let state = test_json!({
5896 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5897 "items": [
5898 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5899 {"name": "Local2", "localOnly": "Only2"}
5900 ]
5901 });
5902 let mut writer = TestWriter::new();
5903 handle(
5904 &protocol,
5905 &state,
5906 &RenderOptions::new("index.html", "/"),
5907 &mut writer,
5908 )
5909 .unwrap();
5910 assert_eq!(
5911 writer.get_content(),
5912 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
5913 );
5914 }
5915
5916 #[test]
5917 fn test_component_in_for_global_moniker_shadow() {
5918 let mut fragments = HashMap::new();
5919 fragments.insert(
5920 "index.html".to_string(),
5921 FragmentList {
5922 fragments: vec![
5923 WebUIFragment::raw("<div>"),
5924 WebUIFragment::for_loop("item", "items", "templateComponent"),
5925 WebUIFragment::raw("</div>"),
5926 ],
5927 },
5928 );
5929 fragments.insert(
5930 "templateComponent".to_string(),
5931 FragmentList {
5932 fragments: vec![
5933 WebUIFragment::raw("<component-tag>"),
5934 WebUIFragment::component("my-component"),
5935 WebUIFragment::raw("</component-tag>"),
5936 ],
5937 },
5938 );
5939 fragments.insert(
5940 "my-component".to_string(),
5941 FragmentList {
5942 fragments: vec![
5943 WebUIFragment::raw("<span>"),
5944 WebUIFragment::signal("name", false),
5945 WebUIFragment::raw("-"),
5946 WebUIFragment::signal("item.globalValue", false),
5947 WebUIFragment::raw("-"),
5948 WebUIFragment::signal("localOnly", false),
5949 WebUIFragment::raw("-"),
5950 WebUIFragment::signal("item.otherVal", false),
5951 WebUIFragment::raw("</span>"),
5952 ],
5953 },
5954 );
5955 let protocol = WebUIProtocol::new(fragments);
5956 let state = test_json!({
5957 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
5958 "items": [
5959 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
5960 {"name": "Local2", "localOnly": "Only2"}
5961 ]
5962 });
5963 let mut writer = TestWriter::new();
5964 handle(
5965 &protocol,
5966 &state,
5967 &RenderOptions::new("index.html", "/"),
5968 &mut writer,
5969 )
5970 .unwrap();
5971 assert_eq!(
5972 writer.get_content(),
5973 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
5974 );
5975 }
5976
5977 #[test]
5978 fn test_if_in_nested_for_local_flag() {
5979 let mut fragments = HashMap::new();
5980 fragments.insert(
5981 "index.html".to_string(),
5982 FragmentList {
5983 fragments: vec![
5984 WebUIFragment::raw("<div>"),
5985 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
5986 WebUIFragment::raw("</div>"),
5987 ],
5988 },
5989 );
5990 fragments.insert(
5991 "outerTemplate".to_string(),
5992 FragmentList {
5993 fragments: vec![
5994 WebUIFragment::raw("<section>"),
5995 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
5996 WebUIFragment::raw("</section>"),
5997 ],
5998 },
5999 );
6000 fragments.insert(
6001 "innerTemplate".to_string(),
6002 FragmentList {
6003 fragments: vec![WebUIFragment::if_cond(
6004 ConditionExpr::identifier("inner_item.flag"),
6005 "ifInner",
6006 )],
6007 },
6008 );
6009 fragments.insert(
6010 "ifInner".to_string(),
6011 FragmentList {
6012 fragments: vec![
6013 WebUIFragment::raw("<p>"),
6014 WebUIFragment::signal("inner_item.value", false),
6015 WebUIFragment::raw("</p>"),
6016 ],
6017 },
6018 );
6019 let protocol = WebUIProtocol::new(fragments);
6020 let state = test_json!({
6021 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
6022 "inner_item": {"flag": false}
6023 });
6024 let mut writer = TestWriter::new();
6025 handle(
6026 &protocol,
6027 &state,
6028 &RenderOptions::new("index.html", "/"),
6029 &mut writer,
6030 )
6031 .unwrap();
6032 assert_eq!(
6033 writer.get_content(),
6034 "<div><section><p>X</p></section></div>"
6035 );
6036 }
6037
6038 #[test]
6039 fn test_if_in_nested_for_global_fallback() {
6040 let mut fragments = HashMap::new();
6041 fragments.insert(
6042 "index.html".to_string(),
6043 FragmentList {
6044 fragments: vec![
6045 WebUIFragment::raw("<div>"),
6046 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
6047 WebUIFragment::raw("</div>"),
6048 ],
6049 },
6050 );
6051 fragments.insert(
6052 "outerTemplate".to_string(),
6053 FragmentList {
6054 fragments: vec![
6055 WebUIFragment::raw("<section>"),
6056 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
6057 WebUIFragment::raw("</section>"),
6058 ],
6059 },
6060 );
6061 fragments.insert(
6062 "innerTemplate".to_string(),
6063 FragmentList {
6064 fragments: vec![WebUIFragment::if_cond(
6065 ConditionExpr::identifier("inner_item.flag"),
6066 "ifInner",
6067 )],
6068 },
6069 );
6070 fragments.insert(
6071 "ifInner".to_string(),
6072 FragmentList {
6073 fragments: vec![
6074 WebUIFragment::raw("<p>"),
6075 WebUIFragment::signal("inner_item.value", false),
6076 WebUIFragment::raw("</p>"),
6077 ],
6078 },
6079 );
6080 let protocol = WebUIProtocol::new(fragments);
6081 let state = test_json!({
6082 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
6083 "inner_item": {"flag": true}
6084 });
6085 let mut writer = TestWriter::new();
6086 handle(
6087 &protocol,
6088 &state,
6089 &RenderOptions::new("index.html", "/"),
6090 &mut writer,
6091 )
6092 .unwrap();
6093 assert_eq!(
6094 writer.get_content(),
6095 "<div><section><p>X</p><p>Y</p></section></div>"
6096 );
6097 }
6098
6099 #[test]
6100 fn test_if_mixed_for_monikers() {
6101 let mut fragments = HashMap::new();
6102 fragments.insert(
6103 "index.html".to_string(),
6104 FragmentList {
6105 fragments: vec![
6106 WebUIFragment::raw("<div>"),
6107 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
6108 WebUIFragment::raw("</div>"),
6109 ],
6110 },
6111 );
6112 fragments.insert(
6113 "outerTemplate".to_string(),
6114 FragmentList {
6115 fragments: vec![
6116 WebUIFragment::raw("<section>"),
6117 WebUIFragment::signal("outer.outerLabel", false),
6118 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
6119 WebUIFragment::raw("</section>"),
6120 ],
6121 },
6122 );
6123 fragments.insert(
6124 "innerTemplate".to_string(),
6125 FragmentList {
6126 fragments: vec![WebUIFragment::if_cond(
6127 ConditionExpr::compound(
6128 ConditionExpr::identifier("outer.active"),
6129 LogicalOperator::And,
6130 ConditionExpr::predicate(
6131 "inner.value",
6132 ComparisonOperator::GreaterThan,
6133 "globalLimit",
6134 ),
6135 ),
6136 "ifInner",
6137 )],
6138 },
6139 );
6140 fragments.insert(
6141 "ifInner".to_string(),
6142 FragmentList {
6143 fragments: vec![
6144 WebUIFragment::raw("<p>"),
6145 WebUIFragment::signal("inner.value", false),
6146 WebUIFragment::raw("</p>"),
6147 ],
6148 },
6149 );
6150 let protocol = WebUIProtocol::new(fragments);
6151 let state = test_json!({
6152 "globalLimit": 10,
6153 "list": {"outerItems": [
6154 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
6155 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
6156 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
6157 ]}
6158 });
6159 let mut writer = TestWriter::new();
6160 handle(
6161 &protocol,
6162 &state,
6163 &RenderOptions::new("index.html", "/"),
6164 &mut writer,
6165 )
6166 .unwrap();
6167 assert_eq!(
6168 writer.get_content(),
6169 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
6170 );
6171 }
6172
6173 fn make_route_protocol() -> WebUIProtocol {
6176 use webui_protocol::WebUiFragmentRoute;
6177
6178 let mut fragments = HashMap::new();
6179
6180 fragments.insert(
6182 "index.html".to_string(),
6183 FragmentList {
6184 fragments: vec![
6185 WebUIFragment::raw("<h1>Shell</h1>"),
6186 WebUIFragment::route_from(WebUiFragmentRoute {
6187 path: "/".into(),
6188 fragment_id: "dash-page".into(),
6189 exact: true,
6190 keep_alive: false,
6191 ..Default::default()
6192 }),
6193 WebUIFragment::route_from(WebUiFragmentRoute {
6194 path: "/contacts/:id".into(),
6195 fragment_id: "detail-page".into(),
6196 exact: true,
6197 keep_alive: false,
6198 ..Default::default()
6199 }),
6200 ],
6201 },
6202 );
6203
6204 fragments.insert(
6206 "dash-page".to_string(),
6207 FragmentList {
6208 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
6209 },
6210 );
6211
6212 fragments.insert(
6214 "detail-page".to_string(),
6215 FragmentList {
6216 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
6217 },
6218 );
6219
6220 WebUIProtocol::new(fragments)
6221 }
6222
6223 fn make_nested_route_protocol() -> WebUIProtocol {
6224 use webui_protocol::WebUiFragmentRoute;
6225
6226 let mut fragments = HashMap::new();
6227
6228 fragments.insert(
6229 "index.html".to_string(),
6230 FragmentList {
6231 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
6232 path: "/".into(),
6233 fragment_id: "app-shell".into(),
6234 exact: false,
6235 children: vec![WebUiFragmentRoute {
6236 path: "sections/:id".into(),
6237 fragment_id: "section-comp".into(),
6238 exact: false,
6239 children: vec![WebUiFragmentRoute {
6240 path: "topics/:topicId".into(),
6241 fragment_id: "topic-comp".into(),
6242 exact: true,
6243 children: vec![],
6244 keep_alive: false,
6245 ..Default::default()
6246 }],
6247 keep_alive: false,
6248 ..Default::default()
6249 }],
6250 keep_alive: false,
6251 ..Default::default()
6252 })],
6253 },
6254 );
6255
6256 fragments.insert(
6257 "app-shell".to_string(),
6258 FragmentList {
6259 fragments: vec![
6260 WebUIFragment::raw("<h1>Shell</h1>"),
6261 WebUIFragment::outlet(),
6262 ],
6263 },
6264 );
6265
6266 fragments.insert(
6267 "section-comp".to_string(),
6268 FragmentList {
6269 fragments: vec![
6270 WebUIFragment::raw("<h2>Section</h2>"),
6271 WebUIFragment::outlet(),
6272 ],
6273 },
6274 );
6275
6276 fragments.insert(
6277 "topic-comp".to_string(),
6278 FragmentList {
6279 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
6280 },
6281 );
6282
6283 WebUIProtocol::new(fragments)
6284 }
6285
6286 #[test]
6287 fn test_route_renders_shell_always() {
6288 let protocol = make_route_protocol();
6289 let state = test_json!({});
6290 let mut writer = TestWriter::new();
6291 handle(
6292 &protocol,
6293 &state,
6294 &RenderOptions::new("index.html", "/"),
6295 &mut writer,
6296 )
6297 .unwrap();
6298 let html = writer.get_content();
6299
6300 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
6302 assert!(html.contains(" active>"), "matched route should be active");
6304 assert!(
6306 html.contains("style=\"display:none\""),
6307 "non-matched routes should be hidden"
6308 );
6309 }
6310
6311 #[test]
6312 fn test_route_matched_renders_visible() {
6313 let protocol = make_route_protocol();
6314 let state = test_json!({});
6315 let mut writer = TestWriter::new();
6316 handle(
6317 &protocol,
6318 &state,
6319 &RenderOptions::new("index.html", "/"),
6320 &mut writer,
6321 )
6322 .unwrap();
6323 let html = writer.get_content();
6324
6325 assert!(
6327 html.contains("<webui-route path=\"/\""),
6328 "dashboard route should exist"
6329 );
6330 assert!(
6331 html.contains("active>") && html.contains("<dash-page>"),
6332 "matched route should be active with component tag: {html}"
6333 );
6334 assert!(
6335 html.contains("<p>Dashboard</p>"),
6336 "matched route should have content"
6337 );
6338 }
6339
6340 #[test]
6341 fn test_route_non_matched_renders_hidden_empty() {
6342 let protocol = make_route_protocol();
6343 let state = test_json!({});
6344 let mut writer = TestWriter::new();
6345 handle(
6346 &protocol,
6347 &state,
6348 &RenderOptions::new("index.html", "/"),
6349 &mut writer,
6350 )
6351 .unwrap();
6352 let html = writer.get_content();
6353
6354 assert!(
6356 html.contains("<webui-route path=\"/contacts/:id\""),
6357 "detail route element should exist"
6358 );
6359 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
6361 let after_detail = &html[detail_start..];
6362 assert!(
6363 after_detail.contains("style=\"display:none\">")
6364 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
6365 "non-matched route should be hidden: {after_detail}"
6366 );
6367 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
6369 let detail_body = &after_detail[..detail_end];
6370 assert!(
6371 !detail_body.contains("<detail-page>"),
6372 "non-matched route should not render component content: {detail_body}"
6373 );
6374 }
6375
6376 #[test]
6377 fn test_route_parameterized_match() {
6378 let protocol = make_route_protocol();
6379 let state = test_json!({});
6380 let mut writer = TestWriter::new();
6381 handle(
6382 &protocol,
6383 &state,
6384 &RenderOptions::new("index.html", "/contacts/42"),
6385 &mut writer,
6386 )
6387 .unwrap();
6388 let html = writer.get_content();
6389
6390 assert!(
6392 html.contains("active>") && html.contains("<detail-page>"),
6393 "detail route should be active: {html}"
6394 );
6395 assert!(html.contains("<p>Detail</p>"), "detail should have content");
6396 let dash_start = html
6398 .find("component=\"dash-page\"")
6399 .expect("dashboard route");
6400 let after_dash = &html[dash_start..];
6401 assert!(
6402 after_dash.contains("style=\"display:none\">"),
6403 "dashboard should be hidden when detail matches: {after_dash}"
6404 );
6405 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
6406 let dash_body = &after_dash[..dash_end];
6407 assert!(
6408 !dash_body.contains("<dash-page>"),
6409 "dashboard should not render component content: {dash_body}"
6410 );
6411 }
6412
6413 #[test]
6414 fn test_route_no_match_all_hidden_empty() {
6415 let protocol = make_route_protocol();
6416 let state = test_json!({});
6417 let mut writer = TestWriter::new();
6418 handle(
6419 &protocol,
6420 &state,
6421 &RenderOptions::new("index.html", "/nonexistent"),
6422 &mut writer,
6423 )
6424 .unwrap();
6425 let html = writer.get_content();
6426
6427 assert!(html.contains("<h1>Shell</h1>"));
6429 assert!(
6431 !html.contains("<p>Dashboard</p>"),
6432 "no route content when nothing matches"
6433 );
6434 assert!(
6435 !html.contains("<p>Detail</p>"),
6436 "no route content when nothing matches"
6437 );
6438 }
6439
6440 #[test]
6441 fn test_route_component_attr_emitted() {
6442 let protocol = make_route_protocol();
6443 let state = test_json!({});
6444 let mut writer = TestWriter::new();
6445 handle(
6446 &protocol,
6447 &state,
6448 &RenderOptions::new("index.html", "/"),
6449 &mut writer,
6450 )
6451 .unwrap();
6452 let html = writer.get_content();
6453 assert!(
6455 html.contains("component=\"dash-page\""),
6456 "component attr should be on webui-route: {html}"
6457 );
6458 assert!(
6459 html.contains("component=\"detail-page\""),
6460 "component attr should be on webui-route: {html}"
6461 );
6462 }
6463
6464 #[test]
6465 fn test_no_plugin_no_state_attributes() {
6466 let protocol = make_route_protocol();
6467 let state = test_json!({
6468 "title": "Fish & Chips",
6469 "cartOpen": true,
6470 "items": [{"name": "A&B"}]
6471 });
6472 let mut writer = TestWriter::new();
6473
6474 handle(
6475 &protocol,
6476 &state,
6477 &RenderOptions::new("index.html", "/"),
6478 &mut writer,
6479 )
6480 .unwrap();
6481
6482 let html = writer.get_content();
6483 assert!(
6485 !html.contains("data-state"),
6486 "no data-state without plugin: {html}"
6487 );
6488 assert!(
6489 !html.contains(r#"title="Fish"#),
6490 "no scalar attrs without plugin: {html}"
6491 );
6492 }
6493
6494 #[test]
6495 fn test_nested_routes_render_webui_route_as_light_dom() {
6496 let protocol = make_nested_route_protocol();
6497 let state = test_json!({"title": "Test"});
6498 let handler = WebUIHandler::new();
6499 let mut writer = TestWriter::new();
6500
6501 handler
6502 .handle(
6503 &protocol,
6504 &state,
6505 &RenderOptions::new("index.html", "/sections/frontend"),
6506 &mut writer,
6507 )
6508 .expect("render failed");
6509
6510 let html = writer.get_content();
6511
6512 assert!(
6513 html.contains("component=\"app-shell\"") && html.contains("active>"),
6514 "root route should be active: {html}"
6515 );
6516 assert!(
6518 !html.contains("<template shadowrootmode"),
6519 "webui-route should be light DOM (no shadow template): {html}"
6520 );
6521 }
6522
6523 #[test]
6524 fn test_nested_routes_render_outlet_as_light_dom() {
6525 let protocol = make_nested_route_protocol();
6526 let state = test_json!({"title": "Test"});
6527 let handler = WebUIHandler::new();
6528 let mut writer = TestWriter::new();
6529
6530 handler
6531 .handle(
6532 &protocol,
6533 &state,
6534 &RenderOptions::new("index.html", "/sections/frontend"),
6535 &mut writer,
6536 )
6537 .expect("render failed");
6538
6539 let html = writer.get_content();
6540
6541 assert!(
6543 !html.contains("<webui-outlet>"),
6544 "should not contain webui-outlet wrapper: {html}"
6545 );
6546 assert!(
6548 html.contains("<webui-route"),
6549 "should contain webui-route elements: {html}"
6550 );
6551 }
6552
6553 #[test]
6554 fn test_nested_routes_match_child_at_outlet() {
6555 let protocol = make_nested_route_protocol();
6556 let state = test_json!({"title": "Test"});
6557 let handler = WebUIHandler::new();
6558 let mut writer = TestWriter::new();
6559
6560 handler
6561 .handle(
6562 &protocol,
6563 &state,
6564 &RenderOptions::new("index.html", "/sections/frontend"),
6565 &mut writer,
6566 )
6567 .expect("render failed");
6568
6569 let html = writer.get_content();
6570
6571 assert!(
6572 html.contains("component=\"section-comp\"") && html.contains("active>"),
6573 "section route should be active: {html}"
6574 );
6575 assert!(
6576 html.contains("<h2>Section</h2>"),
6577 "section content should be present: {html}"
6578 );
6579 }
6580
6581 #[test]
6582 fn test_nested_routes_three_levels_deep() {
6583 let protocol = make_nested_route_protocol();
6584 let state = test_json!({"title": "Test"});
6585 let handler = WebUIHandler::new();
6586 let mut writer = TestWriter::new();
6587
6588 handler
6589 .handle(
6590 &protocol,
6591 &state,
6592 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
6593 &mut writer,
6594 )
6595 .expect("render failed");
6596
6597 let html = writer.get_content();
6598
6599 assert!(
6600 html.contains("component=\"app-shell\"") && html.contains("active>"),
6601 "root active: {html}"
6602 );
6603 assert!(
6604 html.contains("component=\"section-comp\"") && html.contains("active>"),
6605 "section active: {html}"
6606 );
6607 assert!(
6608 html.contains("component=\"topic-comp\"")
6609 && html.contains("exact")
6610 && html.contains("active>"),
6611 "topic active: {html}"
6612 );
6613 assert!(
6614 html.contains("<p>Topic content</p>"),
6615 "leaf content present: {html}"
6616 );
6617 }
6618
6619 #[test]
6620 fn test_nested_routes_nonmatched_siblings_hidden() {
6621 let protocol = make_nested_route_protocol();
6622 let state = test_json!({"title": "Test"});
6623 let handler = WebUIHandler::new();
6624 let mut writer = TestWriter::new();
6625
6626 handler
6627 .handle(
6628 &protocol,
6629 &state,
6630 &RenderOptions::new("index.html", "/sections/frontend"),
6631 &mut writer,
6632 )
6633 .expect("render failed");
6634
6635 let html = writer.get_content();
6636
6637 assert!(
6638 html.contains(r#"component="topic-comp" exact style="display:none">"#),
6639 "topic should be hidden: {html}"
6640 );
6641 }
6642
6643 #[test]
6644 fn test_nested_routes_root_only() {
6645 let protocol = make_nested_route_protocol();
6646 let state = test_json!({"title": "Test"});
6647 let handler = WebUIHandler::new();
6648 let mut writer = TestWriter::new();
6649
6650 handler
6651 .handle(
6652 &protocol,
6653 &state,
6654 &RenderOptions::new("index.html", "/"),
6655 &mut writer,
6656 )
6657 .expect("render failed");
6658
6659 let html = writer.get_content();
6660
6661 assert!(
6662 html.contains("component=\"app-shell\"") && html.contains("active>"),
6663 "root active at /: {html}"
6664 );
6665 assert!(
6666 html.contains("<h1>Shell</h1>"),
6667 "shell renders at /: {html}"
6668 );
6669 assert!(
6670 html.contains(r#"component="section-comp" style="display:none">"#),
6671 "section hidden at /: {html}"
6672 );
6673 }
6674
6675 #[test]
6678 fn test_css_module_emitted_once_inline_in_component() {
6679 let template = r#"<p><slot></slot></p>"#;
6682
6683 let mut fragments = HashMap::new();
6684 fragments.insert(
6685 "index.html".to_string(),
6686 FragmentList {
6687 fragments: vec![
6688 WebUIFragment::raw("<html><head>".to_string()),
6689 structural_fragment("head_end"),
6690 WebUIFragment::raw("</head><body><div>".to_string()),
6691 WebUIFragment::component("my-card"),
6692 WebUIFragment::raw("A".to_string()),
6693 WebUIFragment::component("my-card"),
6694 WebUIFragment::raw("B</div>".to_string()),
6695 structural_fragment("body_end"),
6696 WebUIFragment::raw("</body></html>".to_string()),
6697 ],
6698 },
6699 );
6700 fragments.insert(
6701 "my-card".to_string(),
6702 FragmentList {
6703 fragments: vec![WebUIFragment::raw(template.to_string())],
6704 },
6705 );
6706
6707 let mut protocol = WebUIProtocol::new(fragments);
6708 protocol
6709 .components
6710 .entry("my-card".to_string())
6711 .or_default()
6712 .css = "p{color:red}".to_string();
6713 let state = test_json!({});
6714 let mut writer = TestWriter::new();
6715
6716 handle(
6717 &protocol,
6718 &state,
6719 &RenderOptions::new("index.html", "/"),
6720 &mut writer,
6721 )
6722 .unwrap();
6723
6724 let html = writer.get_content();
6725
6726 let count = html.matches(r#"<script type="importmap""#).count();
6728 assert_eq!(
6729 count, 1,
6730 "CSS module importmap should be emitted once, got {count} in: {html}"
6731 );
6732 assert!(
6733 html.contains(r#""my-card":"data:text/css,"#),
6734 "Importmap must register my-card under a data: URI: {html}"
6735 );
6736
6737 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
6739 assert_eq!(
6740 tmpl_count, 2,
6741 "Template should render twice, got {tmpl_count} in: {html}"
6742 );
6743
6744 let css_pos = html
6746 .find(r#"<script type="importmap""#)
6747 .expect("CSS module importmap missing");
6748 let body_pos = html.find("<body>").expect("<body> missing");
6749 assert!(
6750 css_pos > body_pos,
6751 "CSS module should be inline in component, not in <head>: {html}"
6752 );
6753 }
6754
6755 #[test]
6756 fn test_component_without_css_renders_normally() {
6757 let mut fragments = HashMap::new();
6759 fragments.insert(
6760 "index.html".to_string(),
6761 FragmentList {
6762 fragments: vec![WebUIFragment::component("my-card")],
6763 },
6764 );
6765 fragments.insert(
6766 "my-card".to_string(),
6767 FragmentList {
6768 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
6769 },
6770 );
6771
6772 let protocol = WebUIProtocol::new(fragments);
6773 let state = test_json!({});
6774 let mut writer = TestWriter::new();
6775
6776 handle(
6777 &protocol,
6778 &state,
6779 &RenderOptions::new("index.html", "/"),
6780 &mut writer,
6781 )
6782 .unwrap();
6783
6784 let html = writer.get_content();
6785 assert!(
6786 html.contains("<p>hello</p>"),
6787 "Non-module component should render normally: {html}"
6788 );
6789 }
6790
6791 #[test]
6792 fn test_non_module_strategy_no_css_in_head() {
6793 let template = r#"<p>hello</p>"#;
6796
6797 let mut fragments = HashMap::new();
6798 fragments.insert(
6799 "index.html".to_string(),
6800 FragmentList {
6801 fragments: vec![
6802 WebUIFragment::raw("<html><head>".to_string()),
6803 structural_fragment("head_end"),
6804 WebUIFragment::raw("</head><body>".to_string()),
6805 WebUIFragment::component("my-card"),
6806 WebUIFragment::raw("</body></html>".to_string()),
6807 ],
6808 },
6809 );
6810 fragments.insert(
6811 "my-card".to_string(),
6812 FragmentList {
6813 fragments: vec![WebUIFragment::raw(template.to_string())],
6814 },
6815 );
6816
6817 let protocol = WebUIProtocol::new(fragments);
6819 let state = test_json!({});
6820 let mut writer = TestWriter::new();
6821
6822 handle(
6823 &protocol,
6824 &state,
6825 &RenderOptions::new("index.html", "/"),
6826 &mut writer,
6827 )
6828 .unwrap();
6829
6830 let html = writer.get_content();
6831
6832 assert!(
6833 !html.contains(r#"<style type="module""#),
6834 "Non-module strategy should not emit legacy CSS module tags in <head>: {html}"
6835 );
6836 assert!(
6837 !html.contains(r#"<script type="importmap""#),
6838 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
6839 );
6840 assert!(
6841 html.contains("<p>hello</p>"),
6842 "Component should still render: {html}"
6843 );
6844 }
6845
6846 #[test]
6847 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
6848 let mut fragments = HashMap::new();
6849 fragments.insert(
6850 "index.html".to_string(),
6851 FragmentList {
6852 fragments: vec![
6853 WebUIFragment::raw("<html><head>".to_string()),
6854 structural_fragment("head_end"),
6855 WebUIFragment::raw("</head><body><my-card>".to_string()),
6856 WebUIFragment::component("my-card"),
6857 WebUIFragment::raw("</my-card></body></html>".to_string()),
6858 ],
6859 },
6860 );
6861 fragments.insert(
6862 "my-card".to_string(),
6863 FragmentList {
6864 fragments: vec![WebUIFragment::raw(
6865 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
6866 .to_string(),
6867 )],
6868 },
6869 );
6870
6871 let protocol = WebUIProtocol::new(fragments);
6872 let state = test_json!({});
6873 let mut writer = TestWriter::new();
6874
6875 handle(
6876 &protocol,
6877 &state,
6878 &RenderOptions::new("index.html", "/"),
6879 &mut writer,
6880 )
6881 .unwrap();
6882
6883 let html = writer.get_content();
6884
6885 assert!(
6886 html.contains("<style>.card{color:red}</style>"),
6887 "Style strategy should embed inline CSS in shadow template: {html}"
6888 );
6889 assert!(
6890 !html.contains(r#"<style type="module""#),
6891 "Style strategy should not emit legacy module CSS in <head>: {html}"
6892 );
6893 assert!(
6894 !html.contains(r#"<script type="importmap""#),
6895 "Style strategy should not emit CSS module importmaps in <head>: {html}"
6896 );
6897 }
6898
6899 #[test]
6900 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
6901 let mut fragments = HashMap::new();
6904 fragments.insert(
6905 "index.html".to_string(),
6906 FragmentList {
6907 fragments: vec![
6908 WebUIFragment::raw("<html><head>".to_string()),
6909 structural_fragment("head_end"),
6910 WebUIFragment::raw("</head><body><my-card>".to_string()),
6911 WebUIFragment::component("my-card"),
6912 WebUIFragment::raw("</my-card>".to_string()),
6913 structural_fragment("body_end"),
6914 WebUIFragment::raw("</body></html>".to_string()),
6915 ],
6916 },
6917 );
6918 fragments.insert(
6919 "my-card".to_string(),
6920 FragmentList {
6921 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
6922 },
6923 );
6924
6925 let mut protocol = WebUIProtocol::new(fragments);
6926 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
6927 protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
6928
6929 let comp = protocol
6930 .components
6931 .entry("my-card".to_string())
6932 .or_default();
6933 comp.css_href = "my-card.css".to_string();
6934 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
6935
6936 let state = test_json!({});
6937 let mut writer = TestWriter::new();
6938
6939 handle(
6940 &protocol,
6941 &state,
6942 &RenderOptions::new("index.html", "/"),
6943 &mut writer,
6944 )
6945 .unwrap();
6946
6947 let html = writer.get_content();
6948
6949 let head_end = html.find("</head>").expect("</head> missing");
6950 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css">"#);
6951 assert!(
6952 link_pos.is_some_and(|p| p < head_end),
6953 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
6954 );
6955 assert!(
6956 !html.contains(r#"<link rel="preload""#),
6957 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
6958 );
6959 assert!(
6960 !html.contains(r#"<style type="module""#),
6961 "Link strategy should not emit legacy module CSS: {html}"
6962 );
6963 assert!(
6964 !html.contains(r#"<script type="importmap""#),
6965 "Link strategy should not emit CSS module importmaps: {html}"
6966 );
6967 }
6968
6969 #[test]
6970 fn test_link_strategy_shadow_dom_emits_preload_in_head() {
6971 let mut fragments = HashMap::new();
6975 fragments.insert(
6976 "index.html".to_string(),
6977 FragmentList {
6978 fragments: vec![
6979 WebUIFragment::raw("<html><head>".to_string()),
6980 structural_fragment("head_end"),
6981 WebUIFragment::raw("</head><body><o-loading-state>".to_string()),
6982 WebUIFragment::component("o-loading-state"),
6983 WebUIFragment::raw("</o-loading-state><my-card>".to_string()),
6984 WebUIFragment::component("my-card"),
6985 WebUIFragment::raw("</my-card>".to_string()),
6986 structural_fragment("body_end"),
6987 WebUIFragment::raw("</body></html>".to_string()),
6988 ],
6989 },
6990 );
6991 fragments.insert(
6992 "o-loading-state".to_string(),
6993 FragmentList {
6994 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
6995 },
6996 );
6997 fragments.insert(
6998 "my-card".to_string(),
6999 FragmentList {
7000 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
7001 },
7002 );
7003
7004 let mut protocol = WebUIProtocol::new(fragments);
7005 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7006 protocol.set_dom_strategy(webui_protocol::DomStrategy::Shadow);
7007
7008 let comp1 = protocol
7009 .components
7010 .entry("o-loading-state".to_string())
7011 .or_default();
7012 comp1.css_href = "o-loading-state.css".to_string();
7013 comp1.template_json = r#"{"h":"<div>loading</div>"}"#.to_string();
7014
7015 let comp2 = protocol
7016 .components
7017 .entry("my-card".to_string())
7018 .or_default();
7019 comp2.css_href = "my-card.css".to_string();
7020 comp2.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
7021
7022 let state = test_json!({});
7023 let mut writer = TestWriter::new();
7024
7025 handle(
7026 &protocol,
7027 &state,
7028 &RenderOptions::new("index.html", "/"),
7029 &mut writer,
7030 )
7031 .unwrap();
7032
7033 let html = writer.get_content();
7034 let head_end = html.find("</head>").expect("</head> missing");
7035 let head_section = &html[..head_end];
7036
7037 assert!(
7039 head_section.contains(
7040 r#"<link rel="preload" href="o-loading-state.css" as="style" data-webui-ssr-preload="style">"#
7041 ),
7042 "Missing preload for o-loading-state.css in <head>: {html}"
7043 );
7044 assert!(
7045 head_section.contains(
7046 r#"<link rel="preload" href="my-card.css" as="style" data-webui-ssr-preload="style">"#
7047 ),
7048 "Missing preload for my-card.css in <head>: {html}"
7049 );
7050 assert!(
7052 !head_section.contains(r#"<link rel="stylesheet""#),
7053 "Shadow DOM should NOT emit <link rel=stylesheet> in <head>: {html}"
7054 );
7055 }
7056
7057 #[test]
7058 fn test_module_preloads_emit_in_head_in_compiler_order() {
7059 let mut fragments = HashMap::new();
7063 fragments.insert(
7064 "index.html".to_string(),
7065 FragmentList {
7066 fragments: vec![
7067 WebUIFragment::raw(
7068 r#"<html><head><script type="module" async src="/index.js"></script>"#
7069 .to_string(),
7070 ),
7071 structural_fragment("head_end"),
7072 WebUIFragment::raw("</head><body>".to_string()),
7073 structural_fragment("body_end"),
7074 WebUIFragment::raw("</body></html>".to_string()),
7075 ],
7076 },
7077 );
7078
7079 let mut protocol = WebUIProtocol::new(fragments);
7080 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
7081
7082 let state = test_json!({});
7083 let mut writer = TestWriter::new();
7084 handle(
7085 &protocol,
7086 &state,
7087 &RenderOptions::new("index.html", "/"),
7088 &mut writer,
7089 )
7090 .unwrap();
7091
7092 let html = writer.get_content();
7093 let head_end = html.find("</head>").expect("</head> missing");
7094 let head = &html[..head_end];
7095 assert!(
7096 head.contains(
7097 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
7098 ),
7099 "hints must appear in <head> in the compiler's order: {html}"
7100 );
7101 }
7102
7103 #[test]
7104 fn test_no_module_preloads_emits_nothing() {
7105 let mut fragments = HashMap::new();
7106 fragments.insert(
7107 "index.html".to_string(),
7108 FragmentList {
7109 fragments: vec![
7110 WebUIFragment::raw("<html><head>".to_string()),
7111 structural_fragment("head_end"),
7112 WebUIFragment::raw("</head><body>".to_string()),
7113 structural_fragment("body_end"),
7114 WebUIFragment::raw("</body></html>".to_string()),
7115 ],
7116 },
7117 );
7118 let protocol = WebUIProtocol::new(fragments);
7119
7120 let state = test_json!({});
7121 let mut writer = TestWriter::new();
7122 handle(
7123 &protocol,
7124 &state,
7125 &RenderOptions::new("index.html", "/"),
7126 &mut writer,
7127 )
7128 .unwrap();
7129
7130 assert!(
7131 !writer.get_content().contains("modulepreload"),
7132 "a build without hints must be byte-identical to before"
7133 );
7134 }
7135
7136 #[test]
7137 fn test_link_strategy_head_links_follow_document_order() {
7138 let mut fragments = HashMap::new();
7143 fragments.insert(
7144 "index.html".to_string(),
7145 FragmentList {
7146 fragments: vec![
7147 WebUIFragment::raw("<html><head>".to_string()),
7148 structural_fragment("head_end"),
7149 WebUIFragment::raw("</head><body><z-widget>".to_string()),
7150 WebUIFragment::component("z-widget"),
7151 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
7152 WebUIFragment::component("a-widget"),
7153 WebUIFragment::raw("</a-widget>".to_string()),
7154 structural_fragment("body_end"),
7155 WebUIFragment::raw("</body></html>".to_string()),
7156 ],
7157 },
7158 );
7159 fragments.insert(
7160 "z-widget".to_string(),
7161 FragmentList {
7162 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
7163 },
7164 );
7165 fragments.insert(
7166 "a-widget".to_string(),
7167 FragmentList {
7168 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
7169 },
7170 );
7171
7172 let mut protocol = WebUIProtocol::new(fragments);
7173 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7174 protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7175
7176 let z = protocol
7177 .components
7178 .entry("z-widget".to_string())
7179 .or_default();
7180 z.css_href = "z-widget.css".to_string();
7181 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
7182
7183 let a = protocol
7184 .components
7185 .entry("a-widget".to_string())
7186 .or_default();
7187 a.css_href = "a-widget.css".to_string();
7188 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
7189
7190 let state = test_json!({});
7191 let mut writer = TestWriter::new();
7192
7193 handle(
7194 &protocol,
7195 &state,
7196 &RenderOptions::new("index.html", "/"),
7197 &mut writer,
7198 )
7199 .unwrap();
7200
7201 let html = writer.get_content();
7202 let head_end = html.find("</head>").expect("</head> missing");
7203 let head_section = &html[..head_end];
7204
7205 let z_pos = head_section
7206 .find(r#"<link rel="stylesheet" href="z-widget.css">"#)
7207 .expect("z-widget stylesheet link missing from <head>");
7208 let a_pos = head_section
7209 .find(r#"<link rel="stylesheet" href="a-widget.css">"#)
7210 .expect("a-widget stylesheet link missing from <head>");
7211
7212 assert!(
7213 z_pos < a_pos,
7214 "CSS <link> tags must follow document order (z-widget before \
7215 a-widget), not alphabetical order: {html}"
7216 );
7217 }
7218
7219 #[test]
7220 fn test_css_module_emitted_in_component_light_dom() {
7221 let mut fragments = HashMap::new();
7225 fragments.insert(
7226 "index.html".to_string(),
7227 FragmentList {
7228 fragments: vec![
7229 WebUIFragment::raw("<html><head>".to_string()),
7230 structural_fragment("head_end"),
7231 WebUIFragment::raw("</head><body><my-card>".to_string()),
7232 WebUIFragment::component("my-card"),
7233 WebUIFragment::raw("</my-card>".to_string()),
7234 WebUIFragment::raw("</body></html>".to_string()),
7235 ],
7236 },
7237 );
7238 fragments.insert(
7239 "my-card".to_string(),
7240 FragmentList {
7241 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
7242 },
7243 );
7244
7245 let mut protocol = WebUIProtocol::new(fragments);
7246 protocol
7247 .components
7248 .entry("my-card".to_string())
7249 .or_default()
7250 .css = "p{color:red}".to_string();
7251 let state = test_json!({});
7252 let mut writer = TestWriter::new();
7253
7254 handle(
7255 &protocol,
7256 &state,
7257 &RenderOptions::new("index.html", "/"),
7258 &mut writer,
7259 )
7260 .unwrap();
7261
7262 let html = writer.get_content();
7263
7264 let tag_open = html.find("<my-card>").expect("<my-card> missing");
7266 let css_pos = html
7267 .find(r#"<script type="importmap""#)
7268 .expect("CSS module importmap missing");
7269 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
7270 assert!(
7271 css_pos > tag_open && css_pos < tag_close,
7272 "CSS module should be inside component light DOM: {html}"
7273 );
7274
7275 let head_end = html.find("</head>").expect("</head> missing");
7277 assert!(
7278 css_pos > head_end,
7279 "CSS module should not be in <head>: {html}"
7280 );
7281 }
7282
7283 #[test]
7284 fn test_css_module_emitted_for_route_components() {
7285 let template = r#"<h1>Dashboard</h1>"#;
7287
7288 let mut fragments = HashMap::new();
7289 fragments.insert(
7290 "index.html".to_string(),
7291 FragmentList {
7292 fragments: vec![
7293 WebUIFragment::raw("<html><head>".to_string()),
7294 structural_fragment("head_end"),
7295 WebUIFragment::raw("</head><body>".to_string()),
7296 WebUIFragment::route("/", "dash-page"),
7297 structural_fragment("body_end"),
7298 WebUIFragment::raw("</body></html>".to_string()),
7299 ],
7300 },
7301 );
7302 fragments.insert(
7303 "dash-page".to_string(),
7304 FragmentList {
7305 fragments: vec![WebUIFragment::raw(template.to_string())],
7306 },
7307 );
7308
7309 let mut protocol = WebUIProtocol::new(fragments);
7310 let comp = protocol
7311 .components
7312 .entry("dash-page".to_string())
7313 .or_default();
7314 comp.css = "h1{font-size:2rem}".to_string();
7315 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7316 let state = test_json!({});
7317 let mut writer = TestWriter::new();
7318
7319 handle(
7320 &protocol,
7321 &state,
7322 &RenderOptions::new("index.html", "/"),
7323 &mut writer,
7324 )
7325 .unwrap();
7326
7327 let html = writer.get_content();
7328
7329 assert!(
7330 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
7331 "Route component should have CSS module importmap with data: URI: {html}"
7332 );
7333 assert!(
7334 html.contains("<h1>Dashboard</h1>"),
7335 "Route component should render content: {html}"
7336 );
7337 }
7338
7339 #[test]
7340 fn test_head_css_link_skipped_for_components_without_css() {
7341 let mut fragments = HashMap::new();
7344 fragments.insert(
7345 "index.html".to_string(),
7346 FragmentList {
7347 fragments: vec![
7348 WebUIFragment::raw("<html><head>".to_string()),
7349 structural_fragment("head_end"),
7350 WebUIFragment::raw("</head><body>".to_string()),
7351 WebUIFragment::component("has-css"),
7352 WebUIFragment::component("no-css"),
7353 structural_fragment("body_end"),
7354 WebUIFragment::raw("</body></html>".to_string()),
7355 ],
7356 },
7357 );
7358 fragments.insert(
7359 "has-css".to_string(),
7360 FragmentList {
7361 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
7362 },
7363 );
7364 fragments.insert(
7365 "no-css".to_string(),
7366 FragmentList {
7367 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
7368 },
7369 );
7370
7371 let mut protocol = WebUIProtocol::new(fragments);
7372 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
7373 protocol.set_dom_strategy(webui_protocol::DomStrategy::Light);
7374
7375 protocol
7377 .components
7378 .entry("has-css".to_string())
7379 .or_default()
7380 .css_href = "has-css.css".to_string();
7381
7382 let state = test_json!({});
7383 let mut writer = TestWriter::new();
7384
7385 let handler = WebUIHandler::with_plugin(|| {
7386 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7387 });
7388 handler
7389 .handle(
7390 &protocol,
7391 &state,
7392 &RenderOptions::new("index.html", "/"),
7393 &mut writer,
7394 )
7395 .unwrap();
7396
7397 let html = writer.get_content();
7398 assert!(
7399 html.contains(r#"<link rel="stylesheet" href="has-css.css">"#),
7400 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
7401 );
7402 assert!(
7403 !html.contains("no-css.css"),
7404 "Component without CSS must NOT get a <link> in <head>: {html}"
7405 );
7406 }
7407
7408 #[test]
7409 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
7410 let mut fragments = HashMap::new();
7418 fragments.insert(
7419 "index.html".to_string(),
7420 FragmentList {
7421 fragments: vec![
7422 WebUIFragment::raw("<html><head>".to_string()),
7423 structural_fragment("head_end"),
7424 WebUIFragment::raw("</head><body><app-shell>".to_string()),
7425 WebUIFragment::component("app-shell"),
7426 WebUIFragment::raw("</app-shell>".to_string()),
7427 structural_fragment("body_end"),
7428 WebUIFragment::raw("</body></html>".to_string()),
7429 ],
7430 },
7431 );
7432 fragments.insert(
7434 "app-shell".to_string(),
7435 FragmentList {
7436 fragments: vec![
7437 WebUIFragment::raw("<div>Shell</div>".to_string()),
7438 WebUIFragment::component("cart-panel"),
7439 ],
7440 },
7441 );
7442 fragments.insert(
7444 "cart-panel".to_string(),
7445 FragmentList {
7446 fragments: vec![
7447 WebUIFragment::raw("<aside>".to_string()),
7448 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
7449 WebUIFragment::raw("</aside>".to_string()),
7450 ],
7451 },
7452 );
7453 fragments.insert(
7455 "cart-items".to_string(),
7456 FragmentList {
7457 fragments: vec![WebUIFragment::component("product-card")],
7458 },
7459 );
7460 fragments.insert(
7461 "product-card".to_string(),
7462 FragmentList {
7463 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7464 },
7465 );
7466
7467 let mut protocol = WebUIProtocol::new(fragments);
7468 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7469 for name in ["app-shell", "cart-panel", "product-card"] {
7470 let comp = protocol.components.entry(name.to_string()).or_default();
7471 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7472 comp.css = format!(".{name}{{display:block}}");
7473 if name == "cart-panel" {
7474 comp.hydration_mode = StateProjectionMode::Keys as i32;
7475 comp.hydration_keys = vec!["hasItems".to_string()];
7476 }
7477 if name == "product-card" {
7478 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
7479 }
7480 }
7481
7482 let state = test_json!({ "hasItems": false });
7484 let mut writer = TestWriter::new();
7485
7486 let handler = WebUIHandler::with_plugin(|| {
7487 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7488 });
7489 handler
7490 .handle(
7491 &protocol,
7492 &state,
7493 &RenderOptions::new("index.html", "/"),
7494 &mut writer,
7495 )
7496 .unwrap();
7497
7498 let html = writer.get_content();
7499
7500 assert!(
7501 html.contains(r#"<script type="application/json" id="webui-data">"#),
7502 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
7503 );
7504 assert!(
7505 html.contains(r#""state":{"hasItems":false}"#),
7506 "SSR state should live in the JSON data block: {html}"
7507 );
7508 assert!(
7509 html.contains(r#""inventory":"#),
7510 "SSR inventory should live in the JSON data block: {html}"
7511 );
7512 assert!(
7513 !html.contains("window.__webui={\""),
7514 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
7515 );
7516 assert!(
7517 !html.contains(r#"document.getElementById("webui-data")"#),
7518 "SSR must not parse webui-data; client packages own that lazy load: {html}"
7519 );
7520 assert!(
7521 !html.contains("window.__webui=w;"),
7522 "executable bootstrap must not replace existing window.__webui registrations: {html}"
7523 );
7524 assert!(
7525 !html.contains("w.templateFns={\""),
7526 "template function emission must not replace existing templateFns registrations: {html}"
7527 );
7528 assert!(
7529 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
7530 "template functions should merge into the flat templateFns registry: {html}"
7531 );
7532
7533 assert!(
7536 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
7537 "product-card template should be emitted even when unrendered: {html}"
7538 );
7539
7540 assert!(
7543 html.contains(r#""product-card":"data:text/css,"#),
7544 "reachable product-card CSS module importmap should be emitted: {html}"
7545 );
7546
7547 assert!(
7549 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
7550 "rendered app-shell template should be emitted: {html}"
7551 );
7552 assert!(
7553 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
7554 "rendered cart-panel template should be emitted: {html}"
7555 );
7556 }
7557
7558 #[test]
7568 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
7569 let template = r#"<h1>Dashboard</h1>"#;
7571
7572 let mut fragments = HashMap::new();
7573 fragments.insert(
7574 "index.html".to_string(),
7575 FragmentList {
7576 fragments: vec![
7577 WebUIFragment::raw("<html><head>".to_string()),
7578 structural_fragment("head_end"),
7579 WebUIFragment::raw("</head><body>".to_string()),
7580 WebUIFragment::route("/", "dash-page"),
7581 structural_fragment("body_end"),
7582 WebUIFragment::raw("</body></html>".to_string()),
7583 ],
7584 },
7585 );
7586 fragments.insert(
7587 "dash-page".to_string(),
7588 FragmentList {
7589 fragments: vec![WebUIFragment::raw(template.to_string())],
7590 },
7591 );
7592
7593 let mut protocol = WebUIProtocol::new(fragments);
7594 let comp = protocol
7595 .components
7596 .entry("dash-page".to_string())
7597 .or_default();
7598 comp.css = "h1{font-size:2rem}".to_string();
7599 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
7600 let state = test_json!({});
7601 let mut writer = TestWriter::new();
7602
7603 handle(
7604 &protocol,
7605 &state,
7606 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7607 &mut writer,
7608 )
7609 .unwrap();
7610
7611 let html = writer.get_content();
7612
7613 assert!(
7614 html.contains(
7615 r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
7616 ),
7617 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
7618 );
7619 }
7620
7621 #[test]
7622 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
7623 let mut fragments = HashMap::new();
7628 fragments.insert(
7629 "index.html".to_string(),
7630 FragmentList {
7631 fragments: vec![
7632 WebUIFragment::raw("<html><head>".to_string()),
7633 structural_fragment("head_end"),
7634 WebUIFragment::raw("</head><body><app-shell>".to_string()),
7635 WebUIFragment::component("app-shell"),
7636 WebUIFragment::raw("</app-shell>".to_string()),
7637 structural_fragment("body_end"),
7638 WebUIFragment::raw("</body></html>".to_string()),
7639 ],
7640 },
7641 );
7642 fragments.insert(
7643 "app-shell".to_string(),
7644 FragmentList {
7645 fragments: vec![WebUIFragment::if_cond(
7646 ConditionExpr::identifier("hasItems"),
7647 "cart-items",
7648 )],
7649 },
7650 );
7651 fragments.insert(
7652 "cart-items".to_string(),
7653 FragmentList {
7654 fragments: vec![WebUIFragment::component("product-card")],
7655 },
7656 );
7657 fragments.insert(
7658 "product-card".to_string(),
7659 FragmentList {
7660 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
7661 },
7662 );
7663
7664 let mut protocol = WebUIProtocol::new(fragments);
7665 for name in ["app-shell", "product-card"] {
7666 let comp = protocol.components.entry(name.to_string()).or_default();
7667 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
7668 comp.css = format!(".{name}{{display:block}}");
7669 }
7670
7671 let state = test_json!({ "hasItems": false });
7674 let mut writer = TestWriter::new();
7675
7676 let handler = WebUIHandler::with_plugin(|| {
7677 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7678 });
7679 handler
7680 .handle(
7681 &protocol,
7682 &state,
7683 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
7684 &mut writer,
7685 )
7686 .unwrap();
7687
7688 let html = writer.get_content();
7689
7690 assert!(
7691 html.contains(
7692 r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
7693 ),
7694 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
7695 );
7696 }
7697
7698 #[test]
7699 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
7700 let mut fragments = HashMap::new();
7701 fragments.insert(
7702 "index.html".to_string(),
7703 FragmentList {
7704 fragments: vec![
7705 WebUIFragment::raw("<html><body><style>".to_string()),
7706 WebUIFragment::signal("tokens.light", true),
7707 WebUIFragment::raw("</style>".to_string()),
7708 structural_fragment("body_end"),
7709 WebUIFragment::raw("</body></html>".to_string()),
7710 ],
7711 },
7712 );
7713 fragments.insert(
7714 "app-shell".to_string(),
7715 FragmentList {
7716 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
7717 },
7718 );
7719 let index_fragments = fragments
7720 .get_mut("index.html")
7721 .expect("index fixture should exist");
7722 index_fragments
7723 .fragments
7724 .insert(1, WebUIFragment::component("app-shell"));
7725 let mut protocol = WebUIProtocol::new(fragments);
7726 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7727 protocol.components.insert(
7732 "app-shell".to_string(),
7733 webui_protocol::ComponentData {
7734 hydration_mode: StateProjectionMode::Keys as i32,
7735 hydration_keys: vec!["name".to_string()],
7736 ..Default::default()
7737 },
7738 );
7739 let state = test_json!({
7740 "name": "Alice",
7741 "tokens": {
7742 "light": "--color-brand: red;"
7743 }
7744 });
7745 let mut writer = TestWriter::new();
7746 let handler = WebUIHandler::with_plugin(|| {
7747 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7748 });
7749 handler.handle(
7750 &protocol,
7751 &state,
7752 &RenderOptions::new("index.html", "/"),
7753 &mut writer,
7754 )?;
7755 let output = writer.get_content();
7756
7757 assert!(output.contains("--color-brand: red;"));
7759 assert!(output.contains(r#""name":"Alice""#));
7761 assert!(!output.contains(r#""tokens""#));
7762 Ok(())
7763 }
7764
7765 #[test]
7766 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
7767 let mut fragments = HashMap::new();
7768 fragments.insert(
7769 "index.html".to_string(),
7770 FragmentList {
7771 fragments: vec![
7772 WebUIFragment::raw("<html><body>"),
7773 structural_fragment("body_end"),
7774 WebUIFragment::raw("</body></html>"),
7775 ],
7776 },
7777 );
7778 let protocol = WebUIProtocol::new(fragments);
7779 let state = test_json!({
7780 "client": "visible",
7781 "serverOnly": "also preserved",
7782 });
7783 let handler = WebUIHandler::with_plugin(|| {
7784 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7785 });
7786 let mut writer = TestWriter::new();
7787 handler.handle(
7788 &protocol,
7789 &state,
7790 &RenderOptions::new("index.html", "/"),
7791 &mut writer,
7792 )?;
7793 let output = writer.get_content();
7794 assert!(output.contains(r#""client":"visible""#));
7795 assert!(output.contains(r#""serverOnly":"also preserved""#));
7796 Ok(())
7797 }
7798
7799 #[test]
7800 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
7801 let mut fragments = HashMap::new();
7802 fragments.insert(
7803 "index.html".to_string(),
7804 FragmentList {
7805 fragments: vec![
7806 WebUIFragment::raw("<html><body>"),
7807 WebUIFragment::component("app-shell"),
7808 structural_fragment("body_end"),
7809 WebUIFragment::raw("</body></html>"),
7810 ],
7811 },
7812 );
7813 fragments.insert(
7814 "app-shell".to_string(),
7815 FragmentList {
7816 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7817 },
7818 );
7819 let mut protocol = WebUIProtocol::new(fragments);
7820 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7821 protocol.components.insert(
7822 "app-shell".to_string(),
7823 webui_protocol::ComponentData {
7824 hydration_mode: StateProjectionMode::All as i32,
7825 ..Default::default()
7826 },
7827 );
7828 let state = test_json!({
7829 "known": "value",
7830 "possiblyInherited": "must not be dropped",
7831 });
7832 let handler = WebUIHandler::with_plugin(|| {
7833 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7834 });
7835 let mut writer = TestWriter::new();
7836 handler.handle(
7837 &protocol,
7838 &state,
7839 &RenderOptions::new("index.html", "/"),
7840 &mut writer,
7841 )?;
7842 let output = writer.get_content();
7843 assert!(output.contains(r#""known":"value""#));
7844 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
7845 Ok(())
7846 }
7847
7848 #[test]
7849 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
7850 let mut fragments = HashMap::new();
7851 fragments.insert(
7852 "index.html".to_string(),
7853 FragmentList {
7854 fragments: vec![
7855 WebUIFragment::raw("<html><body>"),
7856 WebUIFragment::component("app-shell"),
7857 structural_fragment("body_end"),
7858 WebUIFragment::raw("</body></html>"),
7859 ],
7860 },
7861 );
7862 fragments.insert(
7863 "app-shell".to_string(),
7864 FragmentList {
7865 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7866 },
7867 );
7868 let mut protocol = WebUIProtocol::new(fragments);
7869 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7870 let state = test_json!({
7871 "known": "value",
7872 "serverOnly": "must not be dropped",
7873 });
7874 let handler = WebUIHandler::with_plugin(|| {
7875 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7876 });
7877 let mut writer = TestWriter::new();
7878 handler.handle(
7879 &protocol,
7880 &state,
7881 &RenderOptions::new("index.html", "/"),
7882 &mut writer,
7883 )?;
7884 let output = writer.get_content();
7885 assert!(output.contains(r#""known":"value""#));
7886 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7887 Ok(())
7888 }
7889
7890 #[test]
7891 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
7892 let mut fragments = HashMap::new();
7893 fragments.insert(
7894 "index.html".to_string(),
7895 FragmentList {
7896 fragments: vec![
7897 WebUIFragment::raw("<html><body>"),
7898 WebUIFragment::component("app-shell"),
7899 structural_fragment("body_end"),
7900 WebUIFragment::raw("</body></html>"),
7901 ],
7902 },
7903 );
7904 fragments.insert(
7905 "app-shell".to_string(),
7906 FragmentList {
7907 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
7908 },
7909 );
7910 let mut protocol = WebUIProtocol::new(fragments);
7911 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7912 protocol.components.insert(
7913 "app-shell".to_string(),
7914 webui_protocol::ComponentData {
7915 hydration_mode: i32::MAX,
7916 ..Default::default()
7917 },
7918 );
7919 let state = test_json!({
7920 "known": "value",
7921 "serverOnly": "must not be dropped",
7922 });
7923 let handler = WebUIHandler::with_plugin(|| {
7924 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7925 });
7926 let mut writer = TestWriter::new();
7927 handler.handle(
7928 &protocol,
7929 &state,
7930 &RenderOptions::new("index.html", "/"),
7931 &mut writer,
7932 )?;
7933 let output = writer.get_content();
7934 assert!(output.contains(r#""known":"value""#));
7935 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
7936 Ok(())
7937 }
7938
7939 #[test]
7940 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
7941 let mut protocol = WebUIProtocol::new(HashMap::new());
7942 protocol.components.insert(
7943 "app-shell".to_string(),
7944 webui_protocol::ComponentData {
7945 navigation_keys: vec!["selected".to_string()],
7946 ..Default::default()
7947 },
7948 );
7949
7950 match collect_navigation_state(&protocol, ["app-shell"]) {
7951 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
7952 StateSelection::Full | StateSelection::BorrowedKeys(_) => {
7953 panic!("legacy navigation keys should remain owned and projected")
7954 }
7955 }
7956 }
7957
7958 #[test]
7959 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
7960 let mut protocol = WebUIProtocol::new(HashMap::new());
7961 protocol.components.insert(
7962 "app-shell".to_string(),
7963 webui_protocol::ComponentData::default(),
7964 );
7965
7966 assert!(matches!(
7967 collect_navigation_state(&protocol, ["app-shell"]),
7968 StateSelection::Full
7969 ));
7970 }
7971
7972 #[test]
7973 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
7974 let mut fragments = HashMap::new();
7975 fragments.insert(
7976 "index.html".to_string(),
7977 FragmentList {
7978 fragments: vec![
7979 WebUIFragment::raw("<html><body>".to_string()),
7980 structural_fragment("body_end"),
7981 WebUIFragment::raw("</body></html>".to_string()),
7982 ],
7983 },
7984 );
7985 let mut protocol = WebUIProtocol::new(fragments);
7986 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
7987 let state = test_json!({
7988 "title": "Legacy state",
7989 "serverOnly": "preserved",
7990 });
7991 let handler = WebUIHandler::with_plugin(|| {
7992 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
7993 });
7994
7995 let mut writer = TestWriter::new();
7996 handler.handle(
7997 &protocol,
7998 &state,
7999 &RenderOptions::new("index.html", "/"),
8000 &mut writer,
8001 )?;
8002 assert!(writer.get_content().contains(r#""state":{}"#));
8003 assert!(!writer.get_content().contains("Legacy state"));
8004 assert!(!writer.get_content().contains("preserved"));
8005 Ok(())
8006 }
8007
8008 #[test]
8009 fn scriptless_component_state_is_navigation_only() -> Result<()> {
8010 let mut fragments = HashMap::new();
8011 fragments.insert(
8012 "index.html".to_string(),
8013 FragmentList {
8014 fragments: vec![
8015 WebUIFragment::raw("<html><body>"),
8016 WebUIFragment::component("items-page"),
8017 structural_fragment("body_end"),
8018 WebUIFragment::raw("</body></html>"),
8019 ],
8020 },
8021 );
8022 fragments.insert(
8023 "items-page".to_string(),
8024 FragmentList {
8025 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
8026 },
8027 );
8028 let mut protocol = WebUIProtocol::new(fragments);
8029 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8030 protocol.components.insert(
8031 "items-page".to_string(),
8032 webui_protocol::ComponentData {
8033 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
8034 navigation_mode: Some(StateProjectionMode::Keys as i32),
8035 navigation_keys: vec!["items".into()],
8036 ..Default::default()
8037 },
8038 );
8039 let state = test_json!({
8040 "items": ["STATE_SENTINEL"],
8041 "serverOnly": "SECRET_SENTINEL",
8042 });
8043 let handler = WebUIHandler::with_plugin(|| {
8044 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8045 });
8046 let mut writer = TestWriter::new();
8047
8048 handler.handle(
8049 &protocol,
8050 &state,
8051 &RenderOptions::new("index.html", "/"),
8052 &mut writer,
8053 )?;
8054
8055 let output = writer.get_content();
8056 assert!(output.contains(r#""state":{}"#));
8057 assert!(!output.contains("STATE_SENTINEL"));
8058 assert!(!output.contains("SECRET_SENTINEL"));
8059 Ok(())
8060 }
8061
8062 #[test]
8063 fn write_selected_state_projects_and_escapes() {
8064 let state = test_json!({
8067 "drop": "secret",
8068 "keep": "</script><b>"
8069 });
8070 let keys = ["keep"];
8071 let selection = StateSelection::Keys(keys.to_vec());
8072 let mut sink = TestWriter::new();
8073 let mut scratch = Vec::new();
8074 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8075 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
8076 }
8077
8078 #[test]
8079 fn write_selected_state_non_object_projection_emits_empty_object() {
8080 let state = test_json!("scalar state has nothing hydratable");
8081 let keys: [&str; 0] = [];
8082 let selection = StateSelection::Keys(keys.to_vec());
8083 let mut sink = TestWriter::new();
8084 let mut scratch = Vec::new();
8085 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8086 assert_eq!(sink.get_content(), "{}");
8087 }
8088
8089 #[test]
8090 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
8091 let state = test_json!({
8092 "keptA": 1,
8093 "keptB": 2,
8094 "serverOnlyA": 3,
8095 "serverOnlyB": 4,
8096 });
8097 let keys = ["keptA", "keptA", "keptB", "missing"];
8098 let selection = StateSelection::Keys(keys.to_vec());
8099 let mut sink = TestWriter::new();
8100 let mut scratch = Vec::new();
8101 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8102 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8103 }
8104
8105 #[test]
8106 fn write_selected_state_map_first_matches_schema_first_output() {
8107 let state = test_json!({
8108 "keptA": 1,
8109 "keptB": 2,
8110 });
8111 let keys = ["keptA", "keptB", "missingA", "missingB"];
8112 let selection = StateSelection::Keys(keys.to_vec());
8113 let mut sink = TestWriter::new();
8114 let mut scratch = Vec::new();
8115 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
8116 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
8117 }
8118
8119 #[test]
8120 fn write_selected_state_full_preserves_and_escapes_state() {
8121 let state = test_json!({
8122 "serverOnly": "</script><b>",
8123 "value": 42,
8124 });
8125 let mut sink = TestWriter::new();
8126 let mut scratch = Vec::new();
8127 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
8128 assert_eq!(
8129 sink.get_content(),
8130 r#"{"serverOnly":"<\/script><b>","value":42}"#
8131 );
8132 }
8133
8134 #[test]
8135 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
8136 let mut sink = TestWriter::new();
8142 let mut scratch: Vec<u8> = Vec::new();
8143 assert_eq!(scratch.capacity(), 0);
8145
8146 let mut large = serde_json::Map::new();
8148 for index in 0..64 {
8149 large.insert(
8150 format!("component_{index:04}"),
8151 Value::String("x".repeat(48)),
8152 );
8153 }
8154 let large = Value::Object(large);
8155 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
8156 let high_water = scratch.capacity();
8157 assert!(high_water > 0);
8158
8159 for seq in 0..8 {
8162 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
8163 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
8164 assert_eq!(
8165 scratch.capacity(),
8166 high_water,
8167 "scratch reallocated on checkpoint {seq}"
8168 );
8169 }
8170 }
8171
8172 #[test]
8173 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
8174 let mut fragments = HashMap::new();
8175 fragments.insert(
8176 "index.html".to_string(),
8177 FragmentList {
8178 fragments: vec![
8179 WebUIFragment::raw("<html><body>"),
8180 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8181 path: "/".to_string(),
8182 fragment_id: "home-page".to_string(),
8183 exact: true,
8184 ..Default::default()
8185 }),
8186 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
8187 path: "/admin".to_string(),
8188 fragment_id: "admin-page".to_string(),
8189 exact: true,
8190 ..Default::default()
8191 }),
8192 structural_fragment("body_end"),
8193 WebUIFragment::raw("</body></html>"),
8194 ],
8195 },
8196 );
8197 fragments.insert(
8198 "home-page".to_string(),
8199 FragmentList {
8200 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
8201 },
8202 );
8203 fragments.insert(
8204 "admin-page".to_string(),
8205 FragmentList {
8206 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
8207 },
8208 );
8209
8210 let mut protocol = WebUIProtocol::new(fragments);
8211 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
8212 protocol.components.insert(
8213 "home-page".to_string(),
8214 webui_protocol::ComponentData {
8215 template_json: "{}".to_string(),
8216 hydration_mode: StateProjectionMode::Keys as i32,
8217 hydration_keys: vec!["homeTitle".to_string()],
8218 ..Default::default()
8219 },
8220 );
8221 protocol.components.insert(
8222 "admin-page".to_string(),
8223 webui_protocol::ComponentData {
8224 template_json: "{}".to_string(),
8225 hydration_mode: StateProjectionMode::Keys as i32,
8226 hydration_keys: vec!["adminToken".to_string()],
8227 ..Default::default()
8228 },
8229 );
8230 let state = test_json!({
8231 "homeTitle": "Welcome",
8232 "adminToken": "TOP_SECRET_SENTINEL",
8233 });
8234 let handler = WebUIHandler::with_plugin(|| {
8235 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
8236 });
8237 let mut writer = TestWriter::new();
8238 handler.handle(
8239 &protocol,
8240 &state,
8241 &RenderOptions::new("index.html", "/"),
8242 &mut writer,
8243 )?;
8244 let output = writer.get_content();
8245 assert!(output.contains(r#""homeTitle":"Welcome""#));
8246 assert!(!output.contains("TOP_SECRET_SENTINEL"));
8247 assert!(!output.contains(r#""adminToken""#));
8248 Ok(())
8249 }
8250
8251 #[test]
8252 fn test_component_attr_name_aria() {
8253 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
8255 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
8256 assert_eq!(
8257 component_attr_name("aria-activedescendant"),
8258 "ariaActiveDescendant"
8259 );
8260 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
8261 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
8262 }
8263
8264 #[test]
8265 fn test_component_attr_name_html_global() {
8266 assert_eq!(component_attr_name("readonly"), "readOnly");
8267 assert_eq!(component_attr_name("tabindex"), "tabIndex");
8268 assert_eq!(component_attr_name("accesskey"), "accessKey");
8269 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
8270 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
8271 assert_eq!(component_attr_name("inputmode"), "inputMode");
8272 assert_eq!(component_attr_name("maxlength"), "maxLength");
8273 assert_eq!(component_attr_name("minlength"), "minLength");
8274 assert_eq!(component_attr_name("novalidate"), "noValidate");
8275 assert_eq!(component_attr_name("formaction"), "formAction");
8276 assert_eq!(component_attr_name("ismap"), "isMap");
8277 assert_eq!(component_attr_name("usemap"), "useMap");
8278 }
8279
8280 #[test]
8281 fn test_component_attr_name_strips_colon() {
8282 assert_eq!(component_attr_name(":readonly"), "readOnly");
8283 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
8284 assert_eq!(component_attr_name(":data-title"), "dataTitle");
8285 }
8286
8287 #[test]
8288 fn test_component_attr_name_regular() {
8289 assert_eq!(component_attr_name("data-title"), "dataTitle");
8290 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
8291 assert_eq!(component_attr_name("simple"), "simple");
8292 }
8293
8294 fn make_query_route_protocol() -> WebUIProtocol {
8297 use webui_protocol::WebUiFragmentRoute;
8298
8299 let mut fragments = HashMap::new();
8300
8301 fragments.insert(
8302 "index.html".to_string(),
8303 FragmentList {
8304 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8305 path: "/".into(),
8306 fragment_id: "app-shell".into(),
8307 exact: false,
8308 children: vec![
8309 WebUiFragmentRoute {
8310 path: "compose".into(),
8311 fragment_id: "compose-page".into(),
8312 exact: true,
8313 allowed_query: "action,to,subject".into(),
8314 keep_alive: false,
8315 ..Default::default()
8316 },
8317 WebUiFragmentRoute {
8318 path: "settings".into(),
8319 fragment_id: "settings-page".into(),
8320 exact: true,
8321 keep_alive: false,
8322 ..Default::default()
8323 },
8324 ],
8325 keep_alive: false,
8326 ..Default::default()
8327 })],
8328 },
8329 );
8330
8331 fragments.insert(
8332 "app-shell".to_string(),
8333 FragmentList {
8334 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
8335 },
8336 );
8337 fragments.insert(
8338 "compose-page".to_string(),
8339 FragmentList {
8340 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
8341 },
8342 );
8343 fragments.insert(
8344 "settings-page".to_string(),
8345 FragmentList {
8346 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
8347 },
8348 );
8349
8350 WebUIProtocol::new(fragments)
8351 }
8352
8353 #[test]
8354 fn test_matched_route_omits_query_attr_from_dom() {
8355 let protocol = make_query_route_protocol();
8356 let state = test_json!({});
8357 let handler = WebUIHandler::new();
8358 let mut writer = TestWriter::new();
8359
8360 handler
8361 .handle(
8362 &protocol,
8363 &state,
8364 &RenderOptions::new("index.html", "/compose"),
8365 &mut writer,
8366 )
8367 .expect("render failed");
8368
8369 let html = writer.get_content();
8370 assert!(
8372 !html.contains(r#"query="action,to,subject""#),
8373 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
8374 );
8375 }
8376
8377 #[test]
8378 fn test_nonmatched_route_omits_query_attr_from_dom() {
8379 let protocol = make_query_route_protocol();
8380 let state = test_json!({});
8381 let handler = WebUIHandler::new();
8382 let mut writer = TestWriter::new();
8383
8384 handler
8385 .handle(
8386 &protocol,
8387 &state,
8388 &RenderOptions::new("index.html", "/settings"),
8389 &mut writer,
8390 )
8391 .expect("render failed");
8392
8393 let html = writer.get_content();
8394 assert!(
8396 !html.contains(r#"query="#),
8397 "hidden route should not have query attr: {html}"
8398 );
8399 }
8400
8401 #[test]
8402 fn test_route_without_query_has_no_query_attr() {
8403 let protocol = make_query_route_protocol();
8404 let state = test_json!({});
8405 let handler = WebUIHandler::new();
8406 let mut writer = TestWriter::new();
8407
8408 handler
8409 .handle(
8410 &protocol,
8411 &state,
8412 &RenderOptions::new("index.html", "/settings"),
8413 &mut writer,
8414 )
8415 .expect("render failed");
8416
8417 let html = writer.get_content();
8418 let settings_idx = html
8420 .find(r#"component="settings-page""#)
8421 .expect("settings route should exist");
8422 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
8423 assert!(
8424 !settings_tag.contains("query="),
8425 "route without allowed_query should not emit query attr: {settings_tag}"
8426 );
8427 }
8428
8429 fn build_head_body_protocol() -> WebUIProtocol {
8434 let mut fragments = HashMap::new();
8435 fragments.insert(
8436 "index.html".to_string(),
8437 FragmentList {
8438 fragments: vec![
8439 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8440 structural_fragment("head_end"),
8441 WebUIFragment::raw("</head><body>hello".to_string()),
8442 structural_fragment("body_end"),
8443 WebUIFragment::raw("</body></html>".to_string()),
8444 ],
8445 },
8446 );
8447 WebUIProtocol::new(fragments)
8448 }
8449
8450 #[test]
8451 fn head_inject_emits_at_head_end_boundary() {
8452 let protocol = build_head_body_protocol();
8453 let state = test_json!({});
8454 let mut writer = TestWriter::new();
8455 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
8456 handle(&protocol, &state, &opts, &mut writer).unwrap();
8457 let html = writer.get_content();
8458 let inject_idx = html
8460 .find("<link rel=preload>")
8461 .expect("inject HTML missing");
8462 let head_close_idx = html.find("</head>").expect("</head> missing");
8463 assert!(
8464 inject_idx < head_close_idx,
8465 "head_inject must appear before </head>: {html}"
8466 );
8467 assert_eq!(html.matches("<link rel=preload>").count(), 1);
8469 }
8470
8471 #[test]
8472 fn body_inject_emits_at_body_end_boundary() {
8473 let protocol = build_head_body_protocol();
8474 let state = test_json!({});
8475 let mut writer = TestWriter::new();
8476 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
8477 handle(&protocol, &state, &opts, &mut writer).unwrap();
8478 let html = writer.get_content();
8479 let inject_idx = html
8480 .find("<script>lr</script>")
8481 .expect("inject HTML missing");
8482 let body_close_idx = html.find("</body>").expect("</body> missing");
8483 assert!(
8484 inject_idx < body_close_idx,
8485 "body_inject must appear before </body>: {html}"
8486 );
8487 assert_eq!(html.matches("<script>lr</script>").count(), 1);
8488 }
8489
8490 #[test]
8491 fn injects_are_no_op_when_unset() {
8492 let protocol = build_head_body_protocol();
8493 let state = test_json!({});
8494 let mut writer = TestWriter::new();
8495 handle(
8496 &protocol,
8497 &state,
8498 &RenderOptions::new("index.html", "/"),
8499 &mut writer,
8500 )
8501 .unwrap();
8502 let html = writer.get_content();
8503 assert!(!html.contains("<link rel=preload>"));
8504 assert!(!html.contains("<script>lr</script>"));
8505 }
8506
8507 #[test]
8508 fn empty_inject_string_treated_as_unset() {
8509 let protocol = build_head_body_protocol();
8510 let state = test_json!({});
8511 let mut writer = TestWriter::new();
8512 let opts = RenderOptions::new("index.html", "/")
8513 .with_head_inject("")
8514 .with_body_inject("");
8515 handle(&protocol, &state, &opts, &mut writer).unwrap();
8516 let html = writer.get_content();
8519 assert!(html.contains("</head>"));
8520 assert!(html.contains("</body>"));
8521 }
8522
8523 #[test]
8524 fn inject_html_is_passed_through_verbatim() {
8525 let protocol = build_head_body_protocol();
8529 let state = test_json!({});
8530 let mut writer = TestWriter::new();
8531 let opts =
8532 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
8533 handle(&protocol, &state, &opts, &mut writer).unwrap();
8534 assert!(writer.get_content().contains("<script>var x=1;</script>"));
8535 }
8536
8537 #[test]
8544 fn injects_robust_against_marker_literals_in_content() {
8545 let mut fragments = HashMap::new();
8546 fragments.insert(
8550 "index.html".to_string(),
8551 FragmentList {
8552 fragments: vec![
8553 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
8554 structural_fragment("head_end"),
8555 WebUIFragment::raw(
8556 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
8557 ),
8558 structural_fragment("body_end"),
8559 WebUIFragment::raw("</body></html>".to_string()),
8560 ],
8561 },
8562 );
8563 let protocol = WebUIProtocol::new(fragments);
8564 let state = test_json!({});
8565 let mut writer = TestWriter::new();
8566 let opts = RenderOptions::new("index.html", "/")
8567 .with_head_inject("<HEAD-INJ>")
8568 .with_body_inject("<BODY-INJ>");
8569 handle(&protocol, &state, &opts, &mut writer).unwrap();
8570 let html = writer.get_content();
8571 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
8574 let head_close_idx = html.find("</head>").expect("</head> missing");
8575 assert!(head_inj_idx < head_close_idx);
8576 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
8580 let body_close_idx = html.rfind("</body>").expect("</body> missing");
8582 assert!(
8583 body_inj_idx < body_close_idx,
8584 "body_inject must precede the structural </body>: {html}"
8585 );
8586 assert!(html.contains("<!-- </body> </head> -->"));
8588 }
8589
8590 #[test]
8594 fn both_injects_fire_at_correct_boundaries() {
8595 let protocol = build_head_body_protocol();
8596 let state = test_json!({});
8597 let mut writer = TestWriter::new();
8598 let opts = RenderOptions::new("index.html", "/")
8599 .with_head_inject("<META-HEAD>")
8600 .with_body_inject("<SCRIPT-BODY>");
8601 handle(&protocol, &state, &opts, &mut writer).unwrap();
8602 let html = writer.get_content();
8603 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
8604 let head_close = html.find("</head>").expect("</head> missing");
8605 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
8606 let body_close = html.find("</body>").expect("</body> missing");
8607 assert!(head_idx < head_close, "head_inject before </head>");
8608 assert!(head_close < body_idx, "body_inject after </head>");
8609 assert!(body_idx < body_close, "body_inject before </body>");
8610 assert_eq!(html.matches("<META-HEAD>").count(), 1);
8611 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
8612 }
8613
8614 #[test]
8620 fn injects_dedupe_against_duplicate_signals() {
8621 let mut fragments = HashMap::new();
8622 fragments.insert(
8623 "index.html".to_string(),
8624 FragmentList {
8625 fragments: vec![
8626 WebUIFragment::raw("<html><head>".to_string()),
8627 structural_fragment("head_end"),
8628 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
8631 structural_fragment("body_end"),
8632 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
8634 ],
8635 },
8636 );
8637 let protocol = WebUIProtocol::new(fragments);
8638 let state = test_json!({});
8639 let mut writer = TestWriter::new();
8640 let opts = RenderOptions::new("index.html", "/")
8641 .with_head_inject("<HINJ>")
8642 .with_body_inject("<BINJ>");
8643 handle(&protocol, &state, &opts, &mut writer).unwrap();
8644 let html = writer.get_content();
8645 assert_eq!(
8646 html.matches("<HINJ>").count(),
8647 1,
8648 "head_inject must emit exactly once even with duplicate head_end signals"
8649 );
8650 assert_eq!(
8651 html.matches("<BINJ>").count(),
8652 1,
8653 "body_inject must emit exactly once even with duplicate body_end signals"
8654 );
8655 }
8656
8657 #[test]
8661 fn injects_no_op_when_no_head_or_body_signals() {
8662 let mut fragments = HashMap::new();
8663 fragments.insert(
8664 "index.html".to_string(),
8665 FragmentList {
8666 fragments: vec![WebUIFragment::raw(
8667 "<my-component>hi</my-component>".to_string(),
8668 )],
8669 },
8670 );
8671 let protocol = WebUIProtocol::new(fragments);
8672 let state = test_json!({});
8673 let mut writer = TestWriter::new();
8674 let opts = RenderOptions::new("index.html", "/")
8675 .with_head_inject("<HINJ>")
8676 .with_body_inject("<BINJ>");
8677 handle(&protocol, &state, &opts, &mut writer).unwrap();
8678 let html = writer.get_content();
8679 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
8680 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
8681 assert!(html.contains("<my-component>"));
8682 }
8683
8684 #[test]
8690 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
8691 let protocol = std::sync::Arc::new(build_head_body_protocol());
8692 let state = std::sync::Arc::new(test_json!({}));
8693 let handler = std::sync::Arc::new(WebUIHandler::new());
8694
8695 const N_THREADS: usize = 16;
8696 let mut handles = Vec::with_capacity(N_THREADS);
8697 for tid in 0..N_THREADS {
8698 let h = std::sync::Arc::clone(&handler);
8699 let p = std::sync::Arc::clone(&protocol);
8700 let s = std::sync::Arc::clone(&state);
8701 handles.push(std::thread::spawn(move || {
8702 let head = format!("<HEAD-T{tid}>");
8703 let body = format!("<BODY-T{tid}>");
8704 let mut writer = TestWriter::new();
8705 let opts = RenderOptions::new("index.html", "/")
8706 .with_head_inject(&head)
8707 .with_body_inject(&body);
8708 h.handle(&p, &s, &opts, &mut writer).unwrap();
8709 let html = writer.get_content();
8710 assert_eq!(html.matches(&head).count(), 1);
8712 assert_eq!(html.matches(&body).count(), 1);
8713 for other in 0..N_THREADS {
8715 if other == tid {
8716 continue;
8717 }
8718 let other_head = format!("<HEAD-T{other}>");
8719 let other_body = format!("<BODY-T{other}>");
8720 assert!(
8721 !html.contains(&other_head),
8722 "tid {tid} saw {other}'s head_inject"
8723 );
8724 assert!(
8725 !html.contains(&other_body),
8726 "tid {tid} saw {other}'s body_inject"
8727 );
8728 }
8729 }));
8730 }
8731 for h in handles {
8732 h.join().expect("worker panicked");
8733 }
8734 }
8735
8736 #[test]
8741 fn large_inject_roundtrips_without_truncation() {
8742 let protocol = build_head_body_protocol();
8743 let state = test_json!({});
8744 let mut writer = TestWriter::new();
8745 let big = "x".repeat(1024 * 1024);
8746 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
8747 handle(&protocol, &state, &opts, &mut writer).unwrap();
8748 let html = writer.get_content();
8749 assert!(
8750 html.contains(&big),
8751 "large head_inject must be present verbatim ({} bytes)",
8752 big.len()
8753 );
8754 assert_eq!(html.matches(&big).count(), 1);
8756 }
8757
8758 #[test]
8763 fn empty_nonce_treated_as_unset() {
8764 let protocol = build_head_body_protocol();
8765 let state = test_json!({});
8766 let mut writer = TestWriter::new();
8767 let opts = RenderOptions::new("index.html", "/").with_nonce("");
8768 handle(&protocol, &state, &opts, &mut writer).unwrap();
8769 assert!(
8770 !writer.get_content().contains("webui-nonce"),
8771 "empty nonce must not emit <meta name=\"webui-nonce\">"
8772 );
8773 }
8774
8775 #[test]
8793 fn empty_field_bypass_is_normalised_at_handler_init() {
8794 let protocol = build_head_body_protocol();
8795 let state = test_json!({});
8796
8797 let opts_with_empty_nonce = RenderOptions {
8799 nonce: Some(""),
8800 ..RenderOptions::new("index.html", "/")
8801 };
8802 let mut writer = TestWriter::new();
8803 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
8804 let html = writer.get_content();
8805 assert!(
8806 !html.contains("webui-nonce"),
8807 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
8808 );
8809 assert!(
8810 !html.contains("nonce=\"\""),
8811 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
8812 );
8813
8814 let opts_with_empty_injects = RenderOptions {
8816 head_inject: Some(""),
8817 body_inject: Some(""),
8818 ..RenderOptions::new("index.html", "/")
8819 };
8820 let mut writer = TestWriter::new();
8821 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
8822 }
8826
8827 #[test]
8860 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
8861 let mut fragments = HashMap::new();
8862 fragments.insert(
8863 "index.html".to_string(),
8864 FragmentList {
8865 fragments: vec![
8866 WebUIFragment::raw("["),
8867 WebUIFragment::for_loop("item", "outer", "outer_body"),
8868 WebUIFragment::raw("]"),
8869 ],
8870 },
8871 );
8872 fragments.insert(
8873 "outer_body".to_string(),
8874 FragmentList {
8875 fragments: vec![
8876 WebUIFragment::raw("(O="),
8877 WebUIFragment::signal("item.tag", false),
8878 WebUIFragment::for_loop("item", "inner", "inner_body"),
8879 WebUIFragment::raw(",O="),
8880 WebUIFragment::signal("item.tag", false),
8881 WebUIFragment::raw(")"),
8882 ],
8883 },
8884 );
8885 fragments.insert(
8886 "inner_body".to_string(),
8887 FragmentList {
8888 fragments: vec![
8889 WebUIFragment::raw("[I="),
8890 WebUIFragment::signal("item.tag", false),
8891 WebUIFragment::raw("]"),
8892 ],
8893 },
8894 );
8895 let protocol = WebUIProtocol::new(fragments);
8896 let state = test_json!({
8897 "outer": [{"tag": "A"}, {"tag": "B"}],
8898 "inner": [{"tag": "X"}, {"tag": "Y"}],
8899 });
8900 let mut writer = TestWriter::new();
8901 handle(
8902 &protocol,
8903 &state,
8904 &RenderOptions::new("index.html", "/"),
8905 &mut writer,
8906 )
8907 .unwrap();
8908 assert_eq!(
8920 writer.get_content(),
8921 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
8922 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
8923 );
8924 }
8925
8926 #[derive(Default)]
8927 struct FlushTestWriter {
8928 output: String,
8929 flushes: Vec<usize>,
8930 fail_flush: bool,
8931 fail_flush_at: Option<usize>,
8932 flush_attempts: usize,
8933 ended: bool,
8934 }
8935
8936 impl ResponseWriter for FlushTestWriter {
8937 fn write(&mut self, content: &str) -> Result<()> {
8938 self.output.push_str(content);
8939 Ok(())
8940 }
8941
8942 fn end(&mut self) -> Result<()> {
8943 self.ended = true;
8944 Ok(())
8945 }
8946 }
8947
8948 impl FlushWriter for FlushTestWriter {
8949 fn flush(&mut self) -> Result<()> {
8950 let attempt = self.flush_attempts;
8951 self.flush_attempts += 1;
8952 if self.fail_flush || self.fail_flush_at == Some(attempt) {
8953 return Err(HandlerError::ClientDisconnected);
8954 }
8955 self.flushes.push(self.output.len());
8956 Ok(())
8957 }
8958 }
8959
8960 fn streaming_protocol(with_boundaries: bool) -> Protocol {
8961 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
8962 }
8963
8964 fn streaming_protocol_with_state_strategy(
8965 with_boundaries: bool,
8966 state_strategy: InitialStateStrategy,
8967 ) -> Protocol {
8968 let mut fragments = HashMap::new();
8969 let mut entry = vec![
8970 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
8971 structural_fragment("head_start"),
8972 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
8973 structural_fragment("head_end"),
8974 WebUIFragment::raw("</HEAD><body>"),
8975 structural_fragment("body_start"),
8976 ];
8977 if with_boundaries {
8978 entry.push(structural_fragment("boundary_start:0"));
8979 }
8980 entry.extend([
8981 WebUIFragment::raw("<my-counter"),
8982 structural_fragment("streaming_root:my-counter"),
8983 WebUIFragment::raw(">"),
8984 WebUIFragment::component("my-counter"),
8985 WebUIFragment::raw("</my-counter>"),
8986 ]);
8987 if with_boundaries {
8988 entry.push(structural_fragment("boundary_end:0"));
8989 }
8990 entry.extend([
8991 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
8992 structural_fragment("body_end"),
8993 WebUIFragment::raw("</body></html>"),
8994 ]);
8995 fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
8996 fragments.insert(
8997 "my-counter".to_string(),
8998 FragmentList {
8999 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
9000 },
9001 );
9002
9003 let mut document = WebUIProtocol::new(fragments);
9004 document.initial_state_strategy = state_strategy as i32;
9005 document.components.insert(
9006 "my-counter".to_string(),
9007 webui_protocol::ComponentData {
9008 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
9009 template_functions: "[function(){return true}]".to_string(),
9010 hydration_mode: StateProjectionMode::Keys as i32,
9011 hydration_keys: vec!["count".to_string()],
9012 ..Default::default()
9013 },
9014 );
9015 Protocol::new(document)
9016 }
9017
9018 #[test]
9019 fn handler_error_stays_small() {
9020 assert!(
9027 std::mem::size_of::<HandlerError>()
9028 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
9029 "HandlerError grew to {} bytes",
9030 std::mem::size_of::<HandlerError>()
9031 );
9032 }
9033
9034 #[test]
9035 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
9036 let protocol = streaming_protocol(true);
9037 let handler = WebUIHandler::with_plugin(|| {
9038 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9039 });
9040 let state = test_json!({ "count": 1, "serverOnly": "secret" });
9041 let mut writer = FlushTestWriter::default();
9042
9043 handler
9044 .render_streaming(
9045 &protocol,
9046 &state,
9047 &RenderOptions::new("index.html", "/"),
9048 &mut writer,
9049 )
9050 .unwrap();
9051
9052 assert!(writer.ended);
9053 assert_eq!(
9054 writer.flushes.len(),
9055 2,
9056 "boundary commit plus one coalesced terminal-tail flush"
9057 );
9058 let first_flush = &writer.output[..writer.flushes[0]];
9059 assert!(first_flush.contains("<!--wb:0-->"));
9060 assert!(first_flush.contains("<!--/wb:0-->"));
9061 assert!(first_flush.contains(r#"[1,0,0,0,{"inventory":"01","state":{"count":1}"#));
9062 assert!(first_flush.contains(r#""templates":{"my-counter":"#));
9063 assert!(!first_flush.contains("slow tail"));
9064 assert!(!writer.output.contains("id=\"webui-data\""));
9065 assert!(writer.output.contains("[1,1,3,0,{}]"));
9068 assert!(!writer.output.contains("[1,1,0,1,"));
9069 assert!(!writer.output.contains("[1,2,3,0,{}]"));
9070
9071 let marker = writer
9072 .output
9073 .find(STREAMING_MARKER)
9074 .expect("streaming marker");
9075 let attributed_head = writer
9076 .output
9077 .find("<HEAD data-shell=\"main\">")
9078 .expect("attributed mixed-case head");
9079 let authored_script = writer
9080 .output
9081 .find("src=\"/index.js\"")
9082 .expect("entry script");
9083 assert!(attributed_head < marker && marker < authored_script);
9084 }
9085
9086 #[test]
9087 fn streaming_terminal_tail_never_resends_full_state() {
9088 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
9089 let handler = WebUIHandler::with_plugin(|| {
9090 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9091 });
9092 let mut writer = FlushTestWriter::default();
9093
9094 handler
9095 .render_streaming(
9096 &protocol,
9097 &test_json!({ "count": 1, "serverOnly": "secret" }),
9098 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
9099 &mut writer,
9100 )
9101 .unwrap();
9102
9103 assert_eq!(
9104 writer.output.matches("serverOnly").count(),
9105 1,
9106 "full state belongs only to the interactive boundary"
9107 );
9108 assert!(writer.output.contains("[1,1,3,0,{}]"));
9109 }
9110
9111 #[test]
9112 fn static_streaming_document_uses_one_empty_terminal_record() {
9113 let fragments = HashMap::from([(
9114 "index.html".to_string(),
9115 FragmentList {
9116 fragments: vec![
9117 WebUIFragment::raw("<html><head>"),
9118 structural_fragment("head_start"),
9119 structural_fragment("head_end"),
9120 WebUIFragment::raw("</head><body>"),
9121 structural_fragment("body_start"),
9122 WebUIFragment::raw("<main>static</main>"),
9123 structural_fragment("body_end"),
9124 WebUIFragment::raw("</body></html>"),
9125 ],
9126 },
9127 )]);
9128 let protocol = Protocol::new(WebUIProtocol::new(fragments));
9129 let mut writer = FlushTestWriter::default();
9130
9131 WebUIHandler::new()
9132 .render_streaming(
9133 &protocol,
9134 &test_json!({ "serverOnly": "secret" }),
9135 &RenderOptions::new("index.html", "/"),
9136 &mut writer,
9137 )
9138 .unwrap();
9139
9140 assert_eq!(writer.flushes.len(), 1);
9141 assert!(writer.output.contains(STREAMING_MARKER));
9142 assert!(writer.output.contains("[1,0,3,0,{}]"));
9143 assert!(!writer.output.contains("serverOnly"));
9144 assert!(!writer.output.contains("id=\"webui-data\""));
9145 assert!(!writer.output.contains("<!--wb:"));
9146 }
9147
9148 #[test]
9149 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
9150 let protocol = streaming_protocol(true);
9151 let handler = WebUIHandler::with_plugin(|| {
9152 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9153 });
9154 let mut writer = FlushTestWriter::default();
9155 handler
9156 .render_streaming(
9157 &protocol,
9158 &test_json!({ "count": 1 }),
9159 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
9160 &mut writer,
9161 )
9162 .unwrap();
9163
9164 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
9165 let envelope = writer.output[boundary_end..]
9166 .find("data-webui-boundary nonce=\"test-nonce-123\"")
9167 .map(|index| index + boundary_end)
9168 .expect("nonce-bearing envelope");
9169 let functions = writer
9170 .output
9171 .find("templateFns")
9172 .expect("function side channel");
9173 let sentinel = writer
9174 .output
9175 .find("<webui-hydrate>")
9176 .expect("hydration sentinel");
9177 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
9178 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
9179 }
9180
9181 #[test]
9182 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
9183 fn protocol(signals: &[&str]) -> Protocol {
9184 let mut protocol_signals = Vec::with_capacity(signals.len() + 1);
9185 protocol_signals.push(structural_fragment("head_start"));
9186 protocol_signals.extend(signals.iter().map(|signal| structural_fragment(*signal)));
9187 let fragments = HashMap::from([(
9188 "index.html".to_string(),
9189 FragmentList {
9190 fragments: protocol_signals,
9191 },
9192 )]);
9193 Protocol::new(WebUIProtocol::new(fragments))
9194 }
9195
9196 let handler = WebUIHandler::new();
9197 let mut writer = FlushTestWriter::default();
9198 let result = handler.render_streaming(
9199 &protocol(&["boundary_start:1"]),
9200 &test_json!({}),
9201 &RenderOptions::new("index.html", "/"),
9202 &mut writer,
9203 );
9204 match result {
9205 Err(HandlerError::StreamingBoundary(err)) => {
9206 assert_eq!(err.signal, "boundary_start:1");
9207 assert!(err.reason.contains("expected boundary sequence 0"));
9208 }
9209 other => panic!("expected ordered-boundary error, got {other:?}"),
9210 }
9211
9212 let mut writer = FlushTestWriter::default();
9213 let result = handler.render_streaming(
9214 &protocol(&["boundary_start:0", "boundary_start:1"]),
9215 &test_json!({}),
9216 &RenderOptions::new("index.html", "/"),
9217 &mut writer,
9218 );
9219 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
9220 }
9221
9222 #[test]
9223 fn streaming_render_propagates_boundary_flush_disconnect() {
9224 let protocol = streaming_protocol(true);
9225 let handler = WebUIHandler::new();
9226 let mut writer = FlushTestWriter {
9227 fail_flush: true,
9228 ..FlushTestWriter::default()
9229 };
9230 let result = handler.render_streaming(
9231 &protocol,
9232 &test_json!({}),
9233 &RenderOptions::new("index.html", "/"),
9234 &mut writer,
9235 );
9236 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
9237 assert!(!writer.ended);
9238 }
9239
9240 #[test]
9241 fn streaming_render_requires_structural_head_start_before_writing() {
9242 let fragments = HashMap::from([(
9243 "index.html".to_string(),
9244 FragmentList {
9245 fragments: vec![
9246 WebUIFragment::raw(
9247 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
9248 ),
9249 structural_fragment("head_end"),
9250 WebUIFragment::raw("</HEAD><body>"),
9251 structural_fragment("body_start"),
9252 structural_fragment("body_end"),
9253 ],
9254 },
9255 )]);
9256 let protocol = Protocol::new(WebUIProtocol::new(fragments));
9257 let mut writer = FlushTestWriter::default();
9258
9259 let result = WebUIHandler::new().render_streaming(
9260 &protocol,
9261 &test_json!({}),
9262 &RenderOptions::new("index.html", "/"),
9263 &mut writer,
9264 );
9265
9266 assert!(matches!(
9267 result,
9268 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
9269 ));
9270 assert!(
9271 writer.output.is_empty(),
9272 "preflight must fail before output"
9273 );
9274 assert!(writer.flushes.is_empty());
9275 assert!(!writer.ended);
9276 }
9277
9278 #[test]
9279 fn streaming_render_rejects_duplicate_head_start() {
9280 let fragments = HashMap::from([(
9281 "index.html".to_string(),
9282 FragmentList {
9283 fragments: vec![
9284 WebUIFragment::raw("<html><head>"),
9285 structural_fragment("head_start"),
9286 structural_fragment("head_start"),
9287 structural_fragment("head_end"),
9288 WebUIFragment::raw("</head><body>"),
9289 structural_fragment("body_start"),
9290 structural_fragment("body_end"),
9291 ],
9292 },
9293 )]);
9294 let protocol = Protocol::new(WebUIProtocol::new(fragments));
9295 let mut writer = FlushTestWriter::default();
9296
9297 let result = WebUIHandler::new().render_streaming(
9298 &protocol,
9299 &test_json!({}),
9300 &RenderOptions::new("index.html", "/"),
9301 &mut writer,
9302 );
9303
9304 assert!(matches!(
9305 result,
9306 Err(HandlerError::DuplicateStreamingHeadStart)
9307 ));
9308 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
9309 assert!(writer.flushes.is_empty());
9310 }
9311
9312 #[test]
9313 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
9314 let handler = WebUIHandler::with_plugin(|| {
9315 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9316 });
9317 let state = test_json!({
9318 "count": 1,
9319 "head_start": "must not render",
9320 "boundary_start:0": "must not render",
9321 "boundary_end:0": "must not render",
9322 });
9323 let mut with_boundaries = TestWriter::new();
9324 let mut without_boundaries = TestWriter::new();
9325 handler
9326 .render(
9327 &streaming_protocol(true),
9328 &state,
9329 &RenderOptions::new("index.html", "/"),
9330 &mut with_boundaries,
9331 )
9332 .unwrap();
9333 handler
9334 .render(
9335 &streaming_protocol(false),
9336 &state,
9337 &RenderOptions::new("index.html", "/"),
9338 &mut without_boundaries,
9339 )
9340 .unwrap();
9341 assert_eq!(
9342 with_boundaries.get_content(),
9343 without_boundaries.get_content()
9344 );
9345 }
9346
9347 #[test]
9348 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
9349 for source in [
9350 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
9351 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
9352 ] {
9353 let mut parser = HtmlParser::new();
9354 parser
9355 .parse("index.html", source)
9356 .expect("parse head fixture");
9357 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9358 let mut writer = TestWriter::new();
9359 WebUIHandler::new()
9360 .render(
9361 &protocol,
9362 &test_json!({ "theme": "light" }),
9363 &RenderOptions::new("index.html", "/"),
9364 &mut writer,
9365 )
9366 .unwrap();
9367
9368 assert_eq!(
9369 writer.get_content(),
9370 "<html><head><title>T</title></head><body>x</body></html>"
9371 );
9372 }
9373 }
9374
9375 #[test]
9376 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
9377 let source =
9378 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
9379 let mut parser = HtmlParser::new();
9380 parser.parse("index.html", source).expect("parse fixture");
9381 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9382
9383 let mut ordinary = TestWriter::new();
9384 WebUIHandler::new()
9385 .render(
9386 &protocol,
9387 &test_json!({}),
9388 &RenderOptions::new("index.html", "/"),
9389 &mut ordinary,
9390 )
9391 .unwrap();
9392 assert_eq!(ordinary.get_content(), source);
9393
9394 let mut streaming = FlushTestWriter::default();
9395 WebUIHandler::new()
9396 .render_streaming(
9397 &protocol,
9398 &test_json!({}),
9399 &RenderOptions::new("index.html", "/"),
9400 &mut streaming,
9401 )
9402 .unwrap();
9403 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
9404 assert!(streaming.output.contains("</HEAD><BODY>x"));
9405 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
9406 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
9407 let title = streaming.output.find("<title>").expect("title");
9408 assert!(opening < marker && marker < title);
9409 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9410 }
9411
9412 #[test]
9413 fn ordinary_render_preserves_legacy_unnamespaced_structural_hooks() {
9414 let fragments = HashMap::from([(
9415 "index.html".to_string(),
9416 FragmentList {
9417 fragments: vec![
9418 WebUIFragment::raw("<html><head>"),
9419 WebUIFragment::signal("head_end".to_string(), true),
9420 WebUIFragment::raw("</head><body>"),
9421 WebUIFragment::signal("body_start".to_string(), true),
9422 WebUIFragment::raw("<p>legacy</p>"),
9423 WebUIFragment::signal("body_end".to_string(), true),
9424 WebUIFragment::raw("</body></html>"),
9425 ],
9426 },
9427 )]);
9428 let protocol = Protocol::new(WebUIProtocol::new(fragments));
9429 let handler = WebUIHandler::with_plugin(|| {
9430 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9431 });
9432 let options = RenderOptions::new("index.html", "/").with_nonce("test-legacy-nonce");
9433 let mut writer = TestWriter::new();
9434
9435 handler
9436 .render(&protocol, &test_json!({}), &options, &mut writer)
9437 .unwrap();
9438
9439 let html = writer.get_content();
9440 assert!(html.contains(r#"<meta name="webui-nonce" content="test-legacy-nonce">"#));
9441 assert!(html.contains(r#"<script type="application/json" id="webui-data""#));
9442 assert!(html.contains("<p>legacy</p>"));
9443
9444 let mut streaming = FlushTestWriter::default();
9445 assert!(matches!(
9446 handler.render_streaming(
9447 &protocol,
9448 &test_json!({}),
9449 &RenderOptions::new("index.html", "/"),
9450 &mut streaming,
9451 ),
9452 Err(HandlerError::MissingStreamingHeadStart { .. })
9453 ));
9454 }
9455
9456 #[test]
9457 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
9458 let source = concat!(
9459 "<html><head></head><body>",
9460 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
9461 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
9462 "</body></html>",
9463 );
9464 let state = test_json!({
9465 "head_start": "hs",
9466 "head_end": "he",
9467 "body_start": "bs",
9468 "body_end": "be",
9469 "boundary_start:0": "b0s",
9470 "boundary_end:0": "b0e",
9471 "streaming_root:forged": "root",
9472 });
9473 let mut parser = HtmlParser::new();
9474 parser.parse("index.html", source).expect("parse fixture");
9475 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
9476 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
9477
9478 let mut ordinary = TestWriter::new();
9479 WebUIHandler::new()
9480 .render(
9481 &protocol,
9482 &state,
9483 &RenderOptions::new("index.html", "/"),
9484 &mut ordinary,
9485 )
9486 .unwrap();
9487 assert_eq!(ordinary.get_content(), expected);
9488
9489 let mut streaming = FlushTestWriter::default();
9490 WebUIHandler::new()
9491 .render_streaming(
9492 &protocol,
9493 &state,
9494 &RenderOptions::new("index.html", "/"),
9495 &mut streaming,
9496 )
9497 .unwrap();
9498 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
9499 assert!(!streaming.output.contains("<!--wb:"));
9500 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
9501 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
9502 }
9503
9504 #[test]
9505 fn streaming_rejects_structural_signals_after_terminal() {
9506 let fragments = HashMap::from([(
9507 "index.html".to_string(),
9508 FragmentList {
9509 fragments: vec![
9510 WebUIFragment::raw("<html><head>"),
9511 structural_fragment("head_start"),
9512 structural_fragment("head_end"),
9513 WebUIFragment::raw("</head><body>"),
9514 structural_fragment("body_start"),
9515 structural_fragment("body_end"),
9516 WebUIFragment::raw("</body>"),
9517 structural_fragment("boundary_start:0"),
9518 WebUIFragment::raw("</html>"),
9519 ],
9520 },
9521 )]);
9522 let protocol = Protocol::new(WebUIProtocol::new(fragments));
9523 let mut streaming = FlushTestWriter::default();
9524 let result = WebUIHandler::new().render_streaming(
9525 &protocol,
9526 &test_json!({}),
9527 &RenderOptions::new("index.html", "/"),
9528 &mut streaming,
9529 );
9530
9531 match result {
9532 Err(HandlerError::StreamingBoundary(error)) => {
9533 assert_eq!(error.signal, "boundary_start:0");
9534 assert!(error.reason.contains("after the body_end terminal record"));
9535 }
9536 other => panic!("expected post-terminal rejection, got {other:?}"),
9537 }
9538 assert!(streaming.output.is_empty());
9539 assert!(!streaming.output.contains("<!--wb:0-->"));
9540
9541 let mut ordinary = TestWriter::new();
9542 WebUIHandler::new()
9543 .render(
9544 &protocol,
9545 &test_json!({}),
9546 &RenderOptions::new("index.html", "/"),
9547 &mut ordinary,
9548 )
9549 .unwrap();
9550 assert_eq!(
9551 ordinary.get_content(),
9552 "<html><head></head><body></body></html>"
9553 );
9554 }
9555
9556 fn disjoint_streaming_protocol_ext(hosts: &[&str], emit_root_signal: bool) -> Protocol {
9562 let mut fragments = HashMap::new();
9563 let mut entry = vec![
9564 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
9565 structural_fragment("head_start"),
9566 structural_fragment("head_end"),
9567 WebUIFragment::raw("</head><body>"),
9568 structural_fragment("body_start"),
9569 ];
9570 for (sequence, host) in hosts.iter().enumerate() {
9571 entry.push(structural_fragment(format!("boundary_start:{sequence}")));
9572 entry.push(WebUIFragment::raw(format!("<{host}")));
9573 if emit_root_signal {
9574 entry.push(structural_fragment(format!("streaming_root:{host}")));
9575 }
9576 entry.push(WebUIFragment::raw(">"));
9577 entry.push(WebUIFragment::component(*host));
9578 entry.push(WebUIFragment::raw(format!("</{host}>")));
9579 entry.push(structural_fragment(format!("boundary_end:{sequence}")));
9580 }
9581 entry.push(structural_fragment("body_end"));
9582 entry.push(WebUIFragment::raw("</body></html>"));
9583 fragments.insert("index.html".to_string(), FragmentList { fragments: entry });
9584 fragments.insert(
9585 "comp-a".to_string(),
9586 FragmentList {
9587 fragments: vec![WebUIFragment::raw("<b>A</b>")],
9588 },
9589 );
9590 fragments.insert(
9591 "comp-b".to_string(),
9592 FragmentList {
9593 fragments: vec![WebUIFragment::raw("<b>B</b>")],
9594 },
9595 );
9596
9597 let mut document = WebUIProtocol::new(fragments);
9598 document.initial_state_strategy = InitialStateStrategy::Components as i32;
9599 document.streaming_boundaries.insert(
9600 "index.html".to_string(),
9601 webui_protocol::StreamingBoundaryList {
9602 names: (0..hosts.len())
9603 .map(|index| format!("boundary-{index}"))
9604 .collect(),
9605 },
9606 );
9607 document.components.insert(
9608 "comp-a".to_string(),
9609 webui_protocol::ComponentData {
9610 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
9611 hydration_mode: StateProjectionMode::Keys as i32,
9612 hydration_keys: vec!["a_count".to_string()],
9613 ..Default::default()
9614 },
9615 );
9616 document.components.insert(
9617 "comp-b".to_string(),
9618 webui_protocol::ComponentData {
9619 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
9620 hydration_mode: StateProjectionMode::Keys as i32,
9621 hydration_keys: vec!["b_count".to_string()],
9622 ..Default::default()
9623 },
9624 );
9625 Protocol::new(document)
9626 }
9627
9628 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
9629 disjoint_streaming_protocol_ext(hosts, true)
9630 }
9631
9632 fn streaming_plan_validation_protocol(signals: &[&str], names: &[&str]) -> Protocol {
9633 let mut entry = vec![
9634 WebUIFragment::raw("<html><head>"),
9635 structural_fragment("head_start"),
9636 structural_fragment("head_end"),
9637 WebUIFragment::raw("</head><body>"),
9638 structural_fragment("body_start"),
9639 ];
9640 entry.extend(signals.iter().map(structural_fragment));
9641 entry.extend([
9642 structural_fragment("body_end"),
9643 WebUIFragment::raw("</body></html>"),
9644 ]);
9645
9646 let mut document = WebUIProtocol::new(HashMap::from([(
9647 "index.html".to_string(),
9648 FragmentList { fragments: entry },
9649 )]));
9650 document.streaming_boundaries.insert(
9651 "index.html".to_string(),
9652 webui_protocol::StreamingBoundaryList {
9653 names: names.iter().map(|name| (*name).to_string()).collect(),
9654 },
9655 );
9656 Protocol::new(document)
9657 }
9658
9659 #[test]
9660 fn streaming_response_rejects_cached_malformed_boundary_plan_before_writing() {
9661 let protocol =
9662 streaming_plan_validation_protocol(&["boundary_start:0", "boundary_end:1"], &["one"]);
9663 let handler = WebUIHandler::new();
9664 let mut writer = FlushTestWriter::default();
9665 let options = RenderOptions::new("index.html", "/");
9666
9667 let error = match handler.stream_response(&protocol, &options, &mut writer) {
9668 Ok(_) => panic!("malformed boundary plan unexpectedly opened a response"),
9669 Err(error) => error,
9670 };
9671
9672 assert!(
9673 matches!(error, HandlerError::StreamingBoundary(_)),
9674 "error: {error}"
9675 );
9676 assert!(writer.output.is_empty());
9677 assert!(writer.flushes.is_empty());
9678 }
9679
9680 #[test]
9681 fn streaming_response_rejects_boundary_name_count_mismatch_before_writing() {
9682 let protocol = streaming_plan_validation_protocol(
9683 &["boundary_start:0", "boundary_end:0"],
9684 &["one", "two"],
9685 );
9686 let handler = WebUIHandler::new();
9687 let mut writer = FlushTestWriter::default();
9688 let options = RenderOptions::new("index.html", "/");
9689
9690 let error = match handler.stream_response(&protocol, &options, &mut writer) {
9691 Ok(_) => panic!("mismatched boundary names unexpectedly opened a response"),
9692 Err(error) => error,
9693 };
9694
9695 assert!(
9696 matches!(error, HandlerError::Invariant(_)),
9697 "error: {error}"
9698 );
9699 assert!(writer.output.is_empty());
9700 assert!(writer.flushes.is_empty());
9701 }
9702
9703 #[test]
9704 fn streaming_response_interleaves_projected_updates_with_later_boundaries() {
9705 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b"]);
9706 let handler = WebUIHandler::with_plugin(|| {
9707 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9708 });
9709 let mut writer = FlushTestWriter::default();
9710 let options = RenderOptions::new("index.html", "/");
9711 let mut response = handler
9712 .stream_response(&protocol, &options, &mut writer)
9713 .unwrap();
9714 let first = response.boundary("boundary-0").unwrap();
9715 let second = response.boundary("boundary-1").unwrap();
9716
9717 response.write_shell(&test_json!({})).unwrap();
9718 response
9719 .write_boundary(
9720 first,
9721 &test_json!({ "a_count": 1, "serverOnly": "secret" }),
9722 BoundaryMode::Updatable,
9723 )
9724 .unwrap();
9725 response
9726 .update(first, &test_json!({ "a_count": 7, "serverOnly": "secret" }))
9727 .unwrap();
9728 response
9729 .write_boundary(
9730 second,
9731 &test_json!({ "b_count": 2, "serverOnly": "secret" }),
9732 BoundaryMode::Final,
9733 )
9734 .unwrap();
9735 response.finish(&test_json!({})).unwrap();
9736
9737 assert_eq!(writer.flushes.len(), 5);
9738 assert!(writer.output.contains(r#"[1,0,1,0,{"#));
9739 assert!(writer.output.contains(r#""state":{"a_count":1}"#));
9740 assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":7}]"#));
9741 assert!(writer.output.contains(r#"[1,2,0,1,{"#));
9742 assert!(writer.output.contains(r#""state":{"b_count":2}"#));
9743 assert!(writer.output.contains("[1,3,3,0,{}]"));
9744 assert_eq!(writer.output.matches("serverOnly").count(), 0);
9745
9746 let update_start = writer.output.find("[1,1,2,0,").unwrap();
9747 let update_end = writer.output[update_start..]
9748 .find("</script>")
9749 .map(|offset| update_start + offset)
9750 .unwrap();
9751 let update = &writer.output[update_start..update_end];
9752 assert!(!update.contains("inventory"));
9753 assert!(!update.contains("templates"));
9754 }
9755
9756 #[test]
9757 fn owned_streaming_session_matches_borrowed_response_bytes() {
9758 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
9759 let handler = Arc::new(WebUIHandler::with_plugin(|| {
9760 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
9761 }));
9762
9763 let mut writer = FlushTestWriter::default();
9764 let options = RenderOptions::new("index.html", "/");
9765 let mut response = handler
9766 .stream_response(&protocol, &options, &mut writer)
9767 .unwrap();
9768 let first = response.boundary("boundary-0").unwrap();
9769 let second = response.boundary("boundary-1").unwrap();
9770 response.write_shell(&test_json!({})).unwrap();
9771 response
9772 .write_boundary(
9773 first,
9774 &test_json!({ "a_count": 1 }),
9775 BoundaryMode::Updatable,
9776 )
9777 .unwrap();
9778 response
9779 .update(first, &test_json!({ "a_count": 7 }))
9780 .unwrap();
9781 response
9782 .write_boundary(second, &test_json!({ "b_count": 2 }), BoundaryMode::Final)
9783 .unwrap();
9784 response.finish(&test_json!({})).unwrap();
9785
9786 let mut session = StreamingSession::new(
9787 Arc::clone(&handler),
9788 Arc::clone(&protocol),
9789 SessionOptions::new("index.html", "/"),
9790 )
9791 .unwrap();
9792 let session_first = session.boundary("boundary-0").unwrap();
9793 let session_second = session.boundary("boundary-1").unwrap();
9794 assert_eq!(session_first, first);
9795 assert_eq!(session_second, second);
9796 assert_eq!(session.boundary_count(), 2);
9797
9798 let chunks: Vec<Vec<u8>> = vec![
9799 session.write_shell(&test_json!({})).unwrap(),
9800 session
9801 .write_boundary(
9802 session_first,
9803 &test_json!({ "a_count": 1 }),
9804 BoundaryMode::Updatable,
9805 )
9806 .unwrap(),
9807 session
9808 .update(session_first, &test_json!({ "a_count": 7 }))
9809 .unwrap(),
9810 session
9811 .write_boundary(
9812 session_second,
9813 &test_json!({ "b_count": 2 }),
9814 BoundaryMode::Final,
9815 )
9816 .unwrap(),
9817 session.finish(&test_json!({})).unwrap(),
9818 ];
9819
9820 assert_eq!(chunks.len(), writer.flushes.len());
9822 assert!(session.is_finished());
9823 let joined = String::from_utf8(chunks.concat()).unwrap();
9824 assert_eq!(joined, writer.output);
9825 }
9826
9827 #[test]
9828 fn owned_streaming_session_rejects_use_after_finish() {
9829 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9830 let handler = Arc::new(WebUIHandler::new());
9831 let mut session =
9832 StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9833 .unwrap();
9834 let boundary = session.boundary("boundary-0").unwrap();
9835 session.write_shell(&test_json!({})).unwrap();
9836 session
9837 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9838 .unwrap();
9839 session.finish(&test_json!({})).unwrap();
9840
9841 let error = session.write_shell(&test_json!({})).unwrap_err();
9842 assert!(error.to_string().contains("already finished"));
9843 assert!(session.finish(&test_json!({})).is_err());
9844 }
9845
9846 #[test]
9847 fn owned_streaming_session_surfaces_unknown_boundary_names() {
9848 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9849 let handler = Arc::new(WebUIHandler::new());
9850 let session =
9851 StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9852 .unwrap();
9853 let error = session.boundary("boundary-O").unwrap_err().to_string();
9854 assert!(error.contains("boundary-0"));
9855 }
9856
9857 #[test]
9858 fn owned_streaming_session_stays_usable_after_a_rejected_call() {
9859 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9860 let handler = Arc::new(WebUIHandler::new());
9861 let mut session =
9862 StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9863 .unwrap();
9864 let boundary = session.boundary("boundary-0").unwrap();
9865 session.write_shell(&test_json!({})).unwrap();
9866 assert!(session.update(boundary, &test_json!({ "a": 1 })).is_err());
9868 assert!(!session.is_finished());
9869 session
9870 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9871 .unwrap();
9872 session.finish(&test_json!({})).unwrap();
9873 }
9874
9875 #[test]
9876 fn owned_streaming_session_recovers_from_a_rejected_finish() {
9877 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a", "comp-b"]));
9878 let handler = Arc::new(WebUIHandler::new());
9879 let mut session =
9880 StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9881 .unwrap();
9882 let first = session.boundary("boundary-0").unwrap();
9883 let second = session.boundary("boundary-1").unwrap();
9884 session.write_shell(&test_json!({})).unwrap();
9885 session
9886 .write_boundary(first, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9887 .unwrap();
9888
9889 let error = session.finish(&test_json!({})).unwrap_err();
9891 assert!(
9892 error
9893 .to_string()
9894 .contains("every boundary must be committed"),
9895 "unexpected error: {error}"
9896 );
9897 assert!(!session.is_finished());
9898
9899 session
9900 .write_boundary(second, &test_json!({ "b_count": 1 }), BoundaryMode::Final)
9901 .unwrap();
9902 let tail = session.finish(&test_json!({})).unwrap();
9903 assert!(!tail.is_empty());
9904 assert!(session.is_finished());
9905 }
9906
9907 #[test]
9908 fn owned_streaming_session_rejects_finish_before_the_shell() {
9909 let protocol = Arc::new(disjoint_streaming_protocol(&["comp-a"]));
9910 let handler = Arc::new(WebUIHandler::new());
9911 let mut session =
9912 StreamingSession::new(handler, protocol, SessionOptions::new("index.html", "/"))
9913 .unwrap();
9914 let boundary = session.boundary("boundary-0").unwrap();
9915
9916 let error = session.finish(&test_json!({})).unwrap_err();
9917 assert!(
9918 error.to_string().contains("write_shell must be called"),
9919 "unexpected error: {error}"
9920 );
9921 assert!(!session.is_finished());
9922
9923 session.write_shell(&test_json!({})).unwrap();
9924 session
9925 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9926 .unwrap();
9927 session.finish(&test_json!({})).unwrap();
9928 assert!(session.is_finished());
9929 }
9930
9931 #[test]
9932 fn streaming_response_is_poisoned_after_partial_boundary_flush_failure() {
9933 let protocol = disjoint_streaming_protocol(&["comp-a"]);
9934 let handler = WebUIHandler::new();
9935 let mut writer = FlushTestWriter {
9936 fail_flush_at: Some(1),
9937 ..FlushTestWriter::default()
9938 };
9939 let options = RenderOptions::new("index.html", "/");
9940 let mut response = handler
9941 .stream_response(&protocol, &options, &mut writer)
9942 .unwrap();
9943 let boundary = response.boundary("boundary-0").unwrap();
9944 response.write_shell(&test_json!({})).unwrap();
9945
9946 let first_error = response
9947 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9948 .unwrap_err();
9949 assert!(matches!(first_error, HandlerError::ClientDisconnected));
9950
9951 let retry_error = response
9952 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9953 .unwrap_err();
9954 assert!(
9955 retry_error
9956 .to_string()
9957 .contains("unusable after a previous render or transport failure"),
9958 "error: {retry_error}"
9959 );
9960
9961 drop(response);
9962 assert_eq!(writer.output.matches("<!--wb:0-->").count(), 1);
9963 assert_eq!(writer.flushes.len(), 1);
9964 assert!(!writer.ended);
9965 }
9966
9967 #[test]
9968 fn streaming_response_rejects_updates_to_final_boundaries() {
9969 let protocol = disjoint_streaming_protocol(&["comp-a"]);
9970 let handler = WebUIHandler::new();
9971 let mut writer = FlushTestWriter::default();
9972 let options = RenderOptions::new("index.html", "/");
9973 let mut response = handler
9974 .stream_response(&protocol, &options, &mut writer)
9975 .unwrap();
9976 let boundary = response.boundary("boundary-0").unwrap();
9977 response.write_shell(&test_json!({})).unwrap();
9978 response
9979 .write_boundary(boundary, &test_json!({ "a_count": 1 }), BoundaryMode::Final)
9980 .unwrap();
9981
9982 let error = response
9983 .update(boundary, &test_json!({ "a_count": 2 }))
9984 .unwrap_err();
9985 assert!(
9986 error.to_string().contains("committed as final"),
9987 "error: {error}"
9988 );
9989 }
9990
9991 #[test]
9992 fn streaming_response_rejects_non_object_updates_before_writing() {
9993 let protocol = disjoint_streaming_protocol(&["comp-a"]);
9994 let handler = WebUIHandler::new();
9995 let mut writer = FlushTestWriter::default();
9996 let options = RenderOptions::new("index.html", "/");
9997 let mut response = handler
9998 .stream_response(&protocol, &options, &mut writer)
9999 .unwrap();
10000 let boundary = response.boundary("boundary-0").unwrap();
10001 response.write_shell(&test_json!({})).unwrap();
10002 response
10003 .write_boundary(
10004 boundary,
10005 &test_json!({ "a_count": 1 }),
10006 BoundaryMode::Updatable,
10007 )
10008 .unwrap();
10009 let error = response
10010 .update(boundary, &test_json!("invalid"))
10011 .unwrap_err();
10012 assert!(error.to_string().contains("JSON object"), "error: {error}");
10013
10014 response
10015 .update(boundary, &test_json!({ "a_count": 2 }))
10016 .unwrap();
10017 response.finish(&test_json!({})).unwrap();
10018 assert_eq!(writer.flushes.len(), 4);
10019 assert_eq!(writer.output.matches("data-webui-boundary").count(), 3);
10020 assert!(writer.output.contains(r#"[1,1,2,0,{"a_count":2}]"#));
10021 }
10022
10023 #[test]
10024 fn streaming_response_unknown_boundary_suggests_valid_name() {
10025 let protocol = disjoint_streaming_protocol(&["comp-a"]);
10026 let handler = WebUIHandler::new();
10027 let mut writer = FlushTestWriter::default();
10028 let options = RenderOptions::new("index.html", "/");
10029 let response = handler
10030 .stream_response(&protocol, &options, &mut writer)
10031 .unwrap();
10032
10033 let error = response.boundary("boundry-0").unwrap_err();
10034 assert!(
10035 error.to_string().contains("did you mean `boundary-0`?"),
10036 "error: {error}"
10037 );
10038 }
10039
10040 fn streaming_root_validation_protocol(
10041 mut host_fragments: Vec<WebUIFragment>,
10042 tail: Vec<WebUIFragment>,
10043 ) -> Protocol {
10044 let mut entry = vec![
10045 WebUIFragment::raw("<html><head>"),
10046 structural_fragment("head_start"),
10047 structural_fragment("head_end"),
10048 WebUIFragment::raw("</head><body>"),
10049 structural_fragment("body_start"),
10050 structural_fragment("boundary_start:0"),
10051 ];
10052 entry.append(&mut host_fragments);
10053 entry.extend(tail);
10054 let fragments = HashMap::from([
10055 ("index.html".to_string(), FragmentList { fragments: entry }),
10056 (
10057 "comp-a".to_string(),
10058 FragmentList {
10059 fragments: vec![WebUIFragment::raw("<b>A</b>")],
10060 },
10061 ),
10062 (
10063 "comp-b".to_string(),
10064 FragmentList {
10065 fragments: vec![WebUIFragment::raw("<b>B</b>")],
10066 },
10067 ),
10068 ]);
10069 Protocol::new(WebUIProtocol::new(fragments))
10070 }
10071
10072 fn completed_streaming_tail() -> Vec<WebUIFragment> {
10073 vec![
10074 WebUIFragment::raw("</comp-a>"),
10075 structural_fragment("boundary_end:0"),
10076 structural_fragment("body_end"),
10077 WebUIFragment::raw("</body></html>"),
10078 ]
10079 }
10080
10081 fn assert_streaming_root_error(
10082 protocol: &Protocol,
10083 expected_signal: &str,
10084 expected_reason: &str,
10085 ) {
10086 let mut writer = FlushTestWriter::default();
10087 let result = WebUIHandler::new().render_streaming(
10088 protocol,
10089 &test_json!({}),
10090 &RenderOptions::new("index.html", "/"),
10091 &mut writer,
10092 );
10093 match result {
10094 Err(HandlerError::StreamingBoundary(error)) => {
10095 assert_eq!(error.signal, expected_signal);
10096 assert!(
10097 error.reason.contains(expected_reason),
10098 "reason: {}",
10099 error.reason
10100 );
10101 }
10102 other => panic!("expected streaming-root rejection, got {other:?}"),
10103 }
10104 }
10105
10106 #[test]
10107 fn streaming_root_signal_injects_data_ws_inside_boundary() {
10108 let protocol = disjoint_streaming_protocol(&["comp-a"]);
10109 let handler = WebUIHandler::with_plugin(|| {
10110 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10111 });
10112 let mut writer = FlushTestWriter::default();
10113 handler
10114 .render_streaming(
10115 &protocol,
10116 &test_json!({ "a_count": 1 }),
10117 &RenderOptions::new("index.html", "/"),
10118 &mut writer,
10119 )
10120 .unwrap();
10121 assert!(
10124 writer.output.contains("<comp-a data-ws>"),
10125 "streamed host must carry data-ws: {}",
10126 writer.output
10127 );
10128 }
10129
10130 #[test]
10131 fn streaming_root_signal_preserves_ordinary_output_bytes() {
10132 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true);
10135 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false);
10136 let state = test_json!({ "a_count": 1, "b_count": 2 });
10137 let plugin = || {
10138 WebUIHandler::with_plugin(
10139 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
10140 )
10141 };
10142 let mut with_writer = TestWriter::new();
10143 let mut without_writer = TestWriter::new();
10144 plugin()
10145 .render(
10146 &with_signal,
10147 &state,
10148 &RenderOptions::new("index.html", "/"),
10149 &mut with_writer,
10150 )
10151 .unwrap();
10152 plugin()
10153 .render(
10154 &without_signal,
10155 &state,
10156 &RenderOptions::new("index.html", "/"),
10157 &mut without_writer,
10158 )
10159 .unwrap();
10160 let dom_prefix = |content: &str| -> String {
10165 content
10166 .split_once(r#"<script type="application/json" id="webui-data""#)
10167 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
10168 };
10169 assert_eq!(
10170 dom_prefix(&with_writer.get_content()),
10171 dom_prefix(&without_writer.get_content())
10172 );
10173 assert!(!with_writer.get_content().contains("data-ws"));
10174 assert!(with_writer.get_content().contains("<comp-a>"));
10175 }
10176
10177 #[test]
10178 fn streaming_component_without_root_signal_is_rejected() {
10179 let protocol = streaming_root_validation_protocol(
10180 vec![
10181 WebUIFragment::raw("<comp-a>"),
10182 WebUIFragment::component("comp-a"),
10183 ],
10184 completed_streaming_tail(),
10185 );
10186
10187 assert_streaming_root_error(
10188 &protocol,
10189 "streaming_root:comp-a",
10190 "has no matching compiler-owned root signal",
10191 );
10192 }
10193
10194 #[test]
10195 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
10196 let duplicate = streaming_root_validation_protocol(
10197 vec![
10198 WebUIFragment::raw("<comp-a"),
10199 structural_fragment("streaming_root:comp-a"),
10200 structural_fragment("streaming_root:comp-a"),
10201 WebUIFragment::raw(">"),
10202 WebUIFragment::component("comp-a"),
10203 ],
10204 completed_streaming_tail(),
10205 );
10206 assert_streaming_root_error(&duplicate, "streaming_root:comp-a", "is misplaced");
10207
10208 let after_tag_close = streaming_root_validation_protocol(
10209 vec![
10210 WebUIFragment::raw("<comp-a>"),
10211 structural_fragment("streaming_root:comp-a"),
10212 WebUIFragment::component("comp-a"),
10213 ],
10214 completed_streaming_tail(),
10215 );
10216 assert_streaming_root_error(&after_tag_close, "streaming_root:comp-a", "is misplaced");
10217
10218 let detached_from_opening = streaming_root_validation_protocol(
10219 vec![
10220 WebUIFragment::raw("not an opening tag"),
10221 structural_fragment("streaming_root:comp-a"),
10222 WebUIFragment::raw(">"),
10223 WebUIFragment::component("comp-a"),
10224 ],
10225 completed_streaming_tail(),
10226 );
10227 assert_streaming_root_error(
10228 &detached_from_opening,
10229 "streaming_root:comp-a",
10230 "unclosed component opening-tag close",
10231 );
10232 }
10233
10234 #[test]
10235 fn mismatched_streaming_root_signal_is_rejected() {
10236 let protocol = streaming_root_validation_protocol(
10237 vec![
10238 WebUIFragment::raw("<comp-a"),
10239 structural_fragment("streaming_root:comp-b"),
10240 WebUIFragment::raw(">"),
10241 WebUIFragment::component("comp-a"),
10242 ],
10243 completed_streaming_tail(),
10244 );
10245
10246 assert_streaming_root_error(
10247 &protocol,
10248 "streaming_root:comp-b",
10249 "opening host renders component <comp-a>",
10250 );
10251 }
10252
10253 #[test]
10254 fn pending_streaming_root_is_rejected_at_structural_ends() {
10255 let protocol = streaming_root_validation_protocol(
10256 vec![
10257 WebUIFragment::raw("<comp-a"),
10258 structural_fragment("streaming_root:comp-a"),
10259 WebUIFragment::raw(">"),
10260 ],
10261 vec![structural_fragment("boundary_end:0")],
10262 );
10263 assert_streaming_root_error(&protocol, "streaming_root:comp-a", "is misplaced");
10264 }
10265
10266 #[test]
10267 fn streaming_root_outside_boundary_is_rejected() {
10268 let fragments = HashMap::from([
10269 (
10270 "index.html".to_string(),
10271 FragmentList {
10272 fragments: vec![
10273 WebUIFragment::raw("<html><head>"),
10274 structural_fragment("head_start"),
10275 structural_fragment("head_end"),
10276 WebUIFragment::raw("</head><body>"),
10277 structural_fragment("body_start"),
10278 WebUIFragment::raw("<comp-a"),
10279 structural_fragment("streaming_root:comp-a"),
10280 WebUIFragment::raw(">"),
10281 WebUIFragment::component("comp-a"),
10282 WebUIFragment::raw("</comp-a>"),
10283 structural_fragment("body_end"),
10284 WebUIFragment::raw("</body></html>"),
10285 ],
10286 },
10287 ),
10288 (
10289 "comp-a".to_string(),
10290 FragmentList {
10291 fragments: vec![WebUIFragment::raw("<b>A</b>")],
10292 },
10293 ),
10294 ]);
10295 let protocol = Protocol::new(WebUIProtocol::new(fragments));
10296 let mut writer = FlushTestWriter::default();
10297 let result = WebUIHandler::new().render_streaming(
10298 &protocol,
10299 &test_json!({}),
10300 &RenderOptions::new("index.html", "/"),
10301 &mut writer,
10302 );
10303 match result {
10304 Err(HandlerError::StreamingBoundary(err)) => {
10305 assert_eq!(err.signal, "streaming_root:comp-a");
10306 assert!(
10307 err.reason.contains("outside any <boundary>"),
10308 "reason: {}",
10309 err.reason
10310 );
10311 }
10312 other => panic!("expected outside-boundary rejection, got {other:?}"),
10313 }
10314 }
10315
10316 fn parser_route_protocol(with_boundary: bool) -> Protocol {
10317 let mut parser = HtmlParser::with_options(DomStrategy::Light);
10318 parser
10319 .component_registry_mut()
10320 .register_component(ComponentRegistration::new(
10321 "route-page",
10322 "<p>route content</p>",
10323 None,
10324 true,
10325 ))
10326 .expect("register route component");
10327 let route = r#"<route path="/" component="route-page" exact />"#;
10328 let html = if with_boundary {
10329 format!(
10330 "<html><head></head><body>\
10331 <boundary name=\"route\">{route}</boundary>\
10332 </body></html>"
10333 )
10334 } else {
10335 format!("<html><head></head><body>{route}</body></html>")
10336 };
10337 parser
10338 .parse("index.html", &html)
10339 .expect("parse route streaming fixture");
10340 let mut document = WebUIProtocol::new(parser.into_fragment_records());
10341 document.components.insert(
10342 "route-page".to_string(),
10343 webui_protocol::ComponentData::default(),
10344 );
10345 Protocol::new(document)
10346 }
10347
10348 #[test]
10349 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
10350 let protocol = parser_route_protocol(true);
10351 let mut writer = FlushTestWriter::default();
10352 WebUIHandler::new()
10353 .render_streaming(
10354 &protocol,
10355 &test_json!({}),
10356 &RenderOptions::new("index.html", "/"),
10357 &mut writer,
10358 )
10359 .unwrap();
10360
10361 assert!(
10362 writer
10363 .output
10364 .contains("<route-page data-ws><p>route content</p></route-page>"),
10365 "matched route host must be deferred before upgrade: {}",
10366 writer.output
10367 );
10368 }
10369
10370 #[test]
10371 fn streaming_parser_route_host_outside_boundary_is_rejected() {
10372 let protocol = parser_route_protocol(false);
10373 let mut writer = FlushTestWriter::default();
10374 let result = WebUIHandler::new().render_streaming(
10375 &protocol,
10376 &test_json!({}),
10377 &RenderOptions::new("index.html", "/"),
10378 &mut writer,
10379 );
10380
10381 match result {
10382 Err(HandlerError::StreamingBoundary(error)) => {
10383 assert_eq!(error.signal, "streaming_root:route-page");
10384 assert!(error.reason.contains("outside any <boundary>"));
10385 }
10386 other => panic!("expected route boundary rejection, got {other:?}"),
10387 }
10388 assert!(
10389 !writer.output.contains("<route-page>"),
10390 "an unmarked custom-element opening must never be completed"
10391 );
10392 }
10393
10394 #[test]
10395 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
10396 let entry = vec![
10397 WebUIFragment::raw("<html><head>"),
10398 structural_fragment("head_start"),
10399 structural_fragment("head_end"),
10400 WebUIFragment::raw("</head><body>"),
10401 structural_fragment("body_start"),
10402 structural_fragment("boundary_start:0"),
10403 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
10404 path: "/account".into(),
10405 fragment_id: "account-shell".into(),
10406 exact: false,
10407 ..Default::default()
10408 }),
10409 structural_fragment("boundary_end:0"),
10410 structural_fragment("body_end"),
10411 WebUIFragment::raw("</body></html>"),
10412 ];
10413 let fragments = HashMap::from([
10414 ("index.html".to_string(), FragmentList { fragments: entry }),
10415 (
10416 "account-shell".to_string(),
10417 FragmentList {
10418 fragments: vec![WebUIFragment::route_from(
10419 webui_protocol::WebUiFragmentRoute {
10420 path: "./details".into(),
10421 fragment_id: "details-page".into(),
10422 exact: true,
10423 pending_component: "details-loading".into(),
10424 error_component: "details-error".into(),
10425 ..Default::default()
10426 },
10427 )],
10428 },
10429 ),
10430 (
10431 "details-page".to_string(),
10432 FragmentList {
10433 fragments: vec![WebUIFragment::raw("<p>details</p>")],
10434 },
10435 ),
10436 (
10437 "details-loading".to_string(),
10438 FragmentList {
10439 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
10440 },
10441 ),
10442 (
10443 "details-error".to_string(),
10444 FragmentList {
10445 fragments: vec![WebUIFragment::raw("<p>error</p>")],
10446 },
10447 ),
10448 ]);
10449 let mut document = WebUIProtocol::new(fragments);
10450 document.initial_state_strategy = InitialStateStrategy::Components as i32;
10451 for name in [
10452 "account-shell",
10453 "details-page",
10454 "details-loading",
10455 "details-error",
10456 ] {
10457 document.components.insert(
10458 name.to_string(),
10459 webui_protocol::ComponentData {
10460 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
10461 ..Default::default()
10462 },
10463 );
10464 }
10465 let protocol = Protocol::new(document);
10466 let handler = WebUIHandler::with_plugin(|| {
10467 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10468 });
10469 let mut writer = FlushTestWriter::default();
10470
10471 handler
10472 .render_streaming(
10473 &protocol,
10474 &test_json!({}),
10475 &RenderOptions::new("index.html", "/account/details"),
10476 &mut writer,
10477 )
10478 .unwrap();
10479
10480 let first = &writer.output[..writer.flushes[0]];
10481 assert!(
10482 first.contains(r#""details-loading":"#),
10483 "relative-route pending metadata missing: {first}"
10484 );
10485 assert!(
10486 first.contains(r#""details-error":"#),
10487 "relative-route error metadata missing: {first}"
10488 );
10489 }
10490
10491 #[test]
10492 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
10493 let entry = vec![
10494 WebUIFragment::raw("<html><head>"),
10495 structural_fragment("head_start"),
10496 structural_fragment("head_end"),
10497 WebUIFragment::raw("</head><body>"),
10498 structural_fragment("body_start"),
10499 structural_fragment("boundary_start:0"),
10500 WebUIFragment::raw("<static-shell"),
10501 structural_fragment("streaming_root:static-shell"),
10502 WebUIFragment::raw(">"),
10503 WebUIFragment::component("static-shell"),
10504 WebUIFragment::raw("</static-shell>"),
10505 WebUIFragment::raw("<route-shell"),
10506 structural_fragment("streaming_root:route-shell"),
10507 WebUIFragment::raw(">"),
10508 WebUIFragment::component("route-shell"),
10509 WebUIFragment::raw("</route-shell>"),
10510 structural_fragment("boundary_end:0"),
10511 structural_fragment("body_end"),
10512 WebUIFragment::raw("</body></html>"),
10513 ];
10514 let fragments = HashMap::from([
10515 ("index.html".to_string(), FragmentList { fragments: entry }),
10516 (
10517 "static-shell".to_string(),
10518 FragmentList {
10519 fragments: vec![WebUIFragment::if_cond(
10520 ConditionExpr::identifier("show_hidden"),
10521 "static-hidden-if",
10522 )],
10523 },
10524 ),
10525 (
10526 "static-hidden-if".to_string(),
10527 FragmentList {
10528 fragments: vec![WebUIFragment::component("static-hidden")],
10529 },
10530 ),
10531 (
10532 "static-hidden".to_string(),
10533 FragmentList {
10534 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
10535 },
10536 ),
10537 (
10538 "route-shell".to_string(),
10539 FragmentList {
10540 fragments: vec![WebUIFragment::route_from(
10541 webui_protocol::WebUiFragmentRoute {
10542 path: "/account".into(),
10543 fragment_id: "route-page".into(),
10544 exact: true,
10545 ..Default::default()
10546 },
10547 )],
10548 },
10549 ),
10550 (
10551 "route-page".to_string(),
10552 FragmentList {
10553 fragments: vec![WebUIFragment::raw("<p>route</p>")],
10554 },
10555 ),
10556 ]);
10557 let mut document = WebUIProtocol::new(fragments);
10558 document.set_css_strategy(webui_protocol::CssStrategy::Link);
10559 document.initial_state_strategy = InitialStateStrategy::Components as i32;
10560 for (name, keys) in [
10561 ("static-shell", vec!["show_hidden", "static_count"]),
10562 ("static-hidden", vec!["hidden_count"]),
10563 ("route-shell", vec!["route_shell_count"]),
10564 ("route-page", vec!["route_count"]),
10565 ] {
10566 document.components.insert(
10567 name.to_string(),
10568 webui_protocol::ComponentData {
10569 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
10570 hydration_mode: StateProjectionMode::Keys as i32,
10571 hydration_keys: keys.into_iter().map(str::to_string).collect(),
10572 css_href: format!("/{name}.css"),
10573 ..Default::default()
10574 },
10575 );
10576 }
10577 let protocol = Protocol::new(document);
10578 assert_eq!(
10579 protocol
10580 .component_reachability()
10581 .is_route_dependent(protocol.component_index()["route-shell"]),
10582 Some(true),
10583 "fixture must exercise request-aware checkpoint reachability"
10584 );
10585 let handler = WebUIHandler::with_plugin(|| {
10586 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10587 });
10588 let mut writer = FlushTestWriter::default();
10589
10590 handler
10591 .render_streaming(
10592 &protocol,
10593 &test_json!({
10594 "show_hidden": false,
10595 "static_count": 1,
10596 "hidden_count": 2,
10597 "route_shell_count": 3,
10598 "route_count": 4,
10599 "unrelated": "private",
10600 }),
10601 &RenderOptions::new("index.html", "/account"),
10602 &mut writer,
10603 )
10604 .unwrap();
10605
10606 let checkpoint = &writer.output[..writer.flushes[0]];
10607 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
10608 assert!(
10609 checkpoint.contains(&format!(r#""{template}":"#)),
10610 "{template} metadata missing: {checkpoint}"
10611 );
10612 }
10613 for state in [
10614 r#""show_hidden":false"#,
10615 r#""static_count":1"#,
10616 r#""hidden_count":2"#,
10617 r#""route_shell_count":3"#,
10618 r#""route_count":4"#,
10619 ] {
10620 assert!(
10621 checkpoint.contains(state),
10622 "{state} state missing: {checkpoint}"
10623 );
10624 }
10625 assert!(
10626 !checkpoint.contains("unrelated"),
10627 "checkpoint leaked unrelated state: {checkpoint}"
10628 );
10629 assert!(
10630 checkpoint.contains(
10631 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
10632 ),
10633 "checkpoint metadata lost source order: {checkpoint}"
10634 );
10635 }
10636
10637 #[test]
10638 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
10639 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
10644 let handler = WebUIHandler::with_plugin(|| {
10645 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10646 });
10647 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
10648 let mut writer = FlushTestWriter::default();
10649 handler
10650 .render_streaming(
10651 &protocol,
10652 &state,
10653 &RenderOptions::new("index.html", "/"),
10654 &mut writer,
10655 )
10656 .unwrap();
10657
10658 assert_eq!(
10659 writer.flushes.len(),
10660 4,
10661 "three boundary commits plus one terminal"
10662 );
10663 let segment = |index: usize| -> &str {
10664 let start = if index == 0 {
10665 0
10666 } else {
10667 writer.flushes[index - 1]
10668 };
10669 &writer.output[start..writer.flushes[index]]
10670 };
10671 let b0 = segment(0);
10672 let b1 = segment(1);
10673 let b2 = segment(2);
10674
10675 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
10677 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
10678 assert!(b0.contains(r#"[1,0,0,0,{"inventory":"01""#), "b0: {b0}");
10679 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
10680 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
10681
10682 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
10684 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
10685 assert!(b1.contains(r#"[1,1,0,1,{"inventory":"02""#), "b1: {b1}");
10686 assert!(
10687 !b1.contains(r#""templates":{"comp-a"#),
10688 "b1 re-sent comp-a: {b1}"
10689 );
10690 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
10691
10692 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
10694 assert!(b2.contains(r#"[1,2,0,2,{"inventory":"""#), "b2: {b2}");
10695 assert!(
10696 !b2.contains(r#""templates""#),
10697 "b2 re-sent a template: {b2}"
10698 );
10699
10700 assert!(!writer.output.contains("serverOnly"));
10702 }
10703
10704 #[test]
10705 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
10706 let entry = vec![
10707 WebUIFragment::raw("<html><head>"),
10708 structural_fragment("head_start"),
10709 structural_fragment("head_end"),
10710 WebUIFragment::raw("</head><body>"),
10711 structural_fragment("body_start"),
10712 structural_fragment("boundary_start:0"),
10713 WebUIFragment::raw("<comp-a"),
10714 structural_fragment("streaming_root:comp-a"),
10715 WebUIFragment::raw(">"),
10716 WebUIFragment::component("comp-a"),
10717 WebUIFragment::raw("</comp-a>"),
10718 structural_fragment("boundary_end:0"),
10719 structural_fragment("boundary_start:1"),
10720 WebUIFragment::raw("<comp-hidden"),
10721 structural_fragment("streaming_root:comp-hidden"),
10722 WebUIFragment::raw(">"),
10723 WebUIFragment::component("comp-hidden"),
10724 WebUIFragment::raw("</comp-hidden>"),
10725 structural_fragment("boundary_end:1"),
10726 structural_fragment("body_end"),
10727 WebUIFragment::raw("</body></html>"),
10728 ];
10729 let fragments = HashMap::from([
10730 ("index.html".to_string(), FragmentList { fragments: entry }),
10731 (
10732 "comp-a".to_string(),
10733 FragmentList {
10734 fragments: vec![WebUIFragment::if_cond(
10735 ConditionExpr::identifier("show_hidden"),
10736 "hidden-if",
10737 )],
10738 },
10739 ),
10740 (
10741 "hidden-if".to_string(),
10742 FragmentList {
10743 fragments: vec![WebUIFragment::component("comp-hidden")],
10744 },
10745 ),
10746 (
10747 "comp-hidden".to_string(),
10748 FragmentList {
10749 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
10750 },
10751 ),
10752 ]);
10753 let mut document = WebUIProtocol::new(fragments);
10754 document.initial_state_strategy = InitialStateStrategy::Components as i32;
10755 document.components.insert(
10756 "comp-a".to_string(),
10757 webui_protocol::ComponentData {
10758 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
10759 hydration_mode: StateProjectionMode::Keys as i32,
10760 hydration_keys: vec!["show_hidden".to_string()],
10761 ..Default::default()
10762 },
10763 );
10764 document.components.insert(
10765 "comp-hidden".to_string(),
10766 webui_protocol::ComponentData {
10767 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
10768 css: ".hidden{display:block}".to_string(),
10769 hydration_mode: StateProjectionMode::Keys as i32,
10770 hydration_keys: vec!["hidden_count".to_string()],
10771 ..Default::default()
10772 },
10773 );
10774 let protocol = Protocol::new(document);
10775 let handler = WebUIHandler::with_plugin(|| {
10776 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10777 });
10778 let mut writer = FlushTestWriter::default();
10779
10780 handler
10781 .render_streaming(
10782 &protocol,
10783 &test_json!({
10784 "show_hidden": false,
10785 "hidden_count": 7,
10786 "unrelated": "private",
10787 }),
10788 &RenderOptions::new("index.html", "/"),
10789 &mut writer,
10790 )
10791 .unwrap();
10792
10793 let boundary = |index: usize| -> &str {
10794 let start = if index == 0 {
10795 0
10796 } else {
10797 writer.flushes[index - 1]
10798 };
10799 &writer.output[start..writer.flushes[index]]
10800 };
10801 let first = boundary(0);
10802 let second = boundary(1);
10803
10804 assert!(first.contains(r#""comp-a":"#), "first: {first}");
10805 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
10806 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
10807 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
10808 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
10809 assert!(
10810 first.contains(r#"<script type="importmap">"#),
10811 "first: {first}"
10812 );
10813 assert!(
10814 !first.contains("unrelated"),
10815 "first leaked unrelated state: {first}"
10816 );
10817
10818 assert!(
10819 !second.contains(r#""templates""#),
10820 "second re-sent metadata: {second}"
10821 );
10822 assert!(
10823 !second.contains(r#"<script type="importmap">"#),
10824 "second re-sent CSS: {second}"
10825 );
10826 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
10827 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
10828 assert_eq!(
10829 writer
10830 .output
10831 .matches(r#"<script type="importmap">"#)
10832 .count(),
10833 1
10834 );
10835 }
10836
10837 #[test]
10838 fn streaming_flush_count_is_boundaries_plus_one() {
10839 for &count in &[1usize, 3, 10, 100] {
10840 let hosts: Vec<&str> = (0..count)
10841 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
10842 .collect();
10843 let protocol = disjoint_streaming_protocol(&hosts);
10844 let handler = WebUIHandler::with_plugin(|| {
10845 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
10846 });
10847 let mut writer = FlushTestWriter::default();
10848 handler
10849 .render_streaming(
10850 &protocol,
10851 &test_json!({ "a_count": 1, "b_count": 2 }),
10852 &RenderOptions::new("index.html", "/"),
10853 &mut writer,
10854 )
10855 .unwrap();
10856 assert_eq!(
10857 writer.flushes.len(),
10858 count + 1,
10859 "expected boundaries+1 flushes for {count} boundaries"
10860 );
10861 }
10862 }
10863
10864 #[test]
10865 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
10866 let mut protocol = WebUIProtocol::new(HashMap::new());
10867 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
10868 protocol.components.insert(
10869 "comp-a".to_string(),
10870 webui_protocol::ComponentData {
10871 hydration_mode: StateProjectionMode::Keys as i32,
10872 hydration_keys: vec!["a".to_string(), "shared".to_string()],
10873 ..Default::default()
10874 },
10875 );
10876 protocol.components.insert(
10877 "comp-b".to_string(),
10878 webui_protocol::ComponentData {
10879 hydration_mode: StateProjectionMode::Keys as i32,
10880 hydration_keys: vec!["b".to_string(), "shared".to_string()],
10881 ..Default::default()
10882 },
10883 );
10884
10885 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
10886 assert!(!collect_hydration_state_into(
10887 &protocol,
10888 ["comp-a"],
10889 &mut scratch,
10890 ));
10891 assert_eq!(scratch, ["a", "shared"]);
10892 let pointer = scratch.as_ptr();
10893 let capacity = scratch.capacity();
10894
10895 assert!(!collect_hydration_state_into(
10896 &protocol,
10897 ["comp-b"],
10898 &mut scratch,
10899 ));
10900 assert_eq!(scratch, ["b", "shared"]);
10901 assert_eq!(scratch.as_ptr(), pointer);
10902 assert_eq!(scratch.capacity(), capacity);
10903 }
10904}