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