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