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