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