1pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12mod render_capacity;
13mod response_writer;
14pub mod route_handler;
15pub mod route_matcher;
16pub(crate) mod route_renderer;
17pub(crate) mod streaming;
18
19#[doc(hidden)]
20pub use html_encode::{append_escaped_to_bytes, append_escaped_to_string};
21#[doc(hidden)]
22pub use render_capacity::RenderCapacityHints;
23#[doc(hidden)]
24pub use response_writer::{
25 append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
26 append_boolean_attribute_to_string,
27};
28pub use route_handler::Protocol;
29
30pub use html_encode::encode_safe;
40
41use plugin::BootstrapExtensionContext;
42use plugin::HandlerPlugin;
43use plugin::WebUiTemplatePayload;
44use route_handler::ComponentReachabilityIndex;
45use route_matcher::CompiledRouteIndex;
46use serde::ser::SerializeMap;
47use serde::Serialize;
48use serde_json::Value;
49use std::borrow::Cow;
50use std::cell::{Cell, OnceCell};
51use std::collections::{HashMap, HashSet};
52use std::fmt::{self, Write as _};
53use std::hash::{BuildHasherDefault, Hasher};
54use std::sync::{Arc, LazyLock};
55use streaming::{
56 consume_streaming_component_root, ensure_no_pending_streaming_root,
57 prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
58 validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
59 StreamingRenderState,
60};
61pub use streaming::{
62 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
63 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
64 MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
65};
66use thiserror::Error;
67use webui_expressions::{evaluate_with_resolver, ExpressionError};
68use webui_protocol::{
69 web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
70 InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
71};
72use webui_state::find_value_by_dotted_path_ref;
73
74#[derive(Debug, Error)]
76pub enum HandlerError {
77 #[error("Rendering error: {0}")]
78 Rendering(String),
79
80 #[error("invalid state JSON: {0}")]
86 InvalidState(String),
87
88 #[error("Rendering invariant error: {0}")]
89 Invariant(String),
90
91 #[error("Missing fragment: {0}")]
92 MissingFragment(String),
93
94 #[error("Missing data field: {0}")]
95 MissingData(String),
96
97 #[error("Type error: {0}")]
98 TypeError(String),
99
100 #[error("Protocol error: {0}")]
101 Protocol(#[from] webui_protocol::ProtocolError),
102
103 #[error("Evaluation error: {0}")]
104 Evaluation(String),
105
106 #[error("I/O error: {0}")]
107 Io(#[from] std::io::Error),
108
109 #[error("Writer error: {0}")]
110 Writer(String),
111
112 #[error("Plugin data error: {0}")]
113 PluginData(String),
114
115 #[error("client disconnected")]
122 ClientDisconnected,
123
124 #[error("streaming flush timed out")]
131 StreamTimeout,
132
133 #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
140 StreamingBoundary(Box<StreamingBoundaryError>),
141
142 #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
144 MissingStreamingBodyEnd,
145
146 #[error(
148 "streaming protocol is missing the required `head_start` signal before `{before}`; \
149 rebuild the protocol with streaming-boundary parser support"
150 )]
151 MissingStreamingHeadStart {
152 before: &'static str,
154 },
155
156 #[error("streaming protocol emitted duplicate `head_start` signals")]
158 DuplicateStreamingHeadStart,
159}
160
161#[derive(Debug)]
168pub struct StreamingBoundaryError {
169 pub signal: String,
171 pub reason: String,
173}
174
175pub type Result<T> = std::result::Result<T, HandlerError>;
176
177#[cold]
178#[inline(never)]
179fn invalid_fragment_range_error(
180 range: &std::ops::Range<usize>,
181 fragment_count: usize,
182) -> HandlerError {
183 HandlerError::Invariant(format!(
184 "fragment range {}..{} exceeds fragment count {fragment_count}",
185 range.start, range.end
186 ))
187}
188
189#[cold]
190#[inline(never)]
191fn route_style_plan_missing_error() -> HandlerError {
192 HandlerError::Invariant(
193 "matched route style targets were computed without a route chain".to_string(),
194 )
195}
196
197#[cold]
198#[inline(never)]
199fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
200 HandlerError::Invariant(format!(
201 "matched route style target count {targets} does not match route count {routes}"
202 ))
203}
204
205pub trait ResponseWriter {
207 fn write(&mut self, content: &str) -> Result<()>;
209
210 fn write_escaped(&mut self, content: &str) -> Result<()> {
215 self.write(&encode_safe(content))
216 }
217
218 fn write_escaped_attribute(&mut self, name: &str, value: &str) -> Result<()> {
223 self.write_attribute(name, &encode_safe(value))
224 }
225
226 #[doc(hidden)]
228 fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
229 self.write(" ")?;
230 self.write(name)?;
231 self.write("=\"")?;
232 self.write(value)?;
233 self.write("\"")
234 }
235
236 #[doc(hidden)]
238 fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
239 self.write(" ")?;
240 self.write(name)
241 }
242
243 fn end(&mut self) -> Result<()>;
245
246 #[doc(hidden)]
254 fn stream_flush(&mut self) -> Result<()> {
255 Ok(())
256 }
257
258 #[doc(hidden)]
259 fn stream_begin_component(&mut self) -> Result<()> {
260 Err(HandlerError::Invariant(
261 "component opening buffering requires a streaming sink".to_string(),
262 ))
263 }
264
265 #[doc(hidden)]
266 fn stream_mark_component_root(&mut self) -> Result<()> {
267 Err(HandlerError::Invariant(
268 "component root buffering requires a streaming sink".to_string(),
269 ))
270 }
271
272 #[doc(hidden)]
273 fn stream_commit_component(
274 &mut self,
275 span_id: Option<u32>,
276 enclosing_span_id: Option<u32>,
277 deferred: bool,
278 ) -> Result<()> {
279 let _ = (span_id, enclosing_span_id, deferred);
280 Err(HandlerError::Invariant(
281 "component opening commit requires a streaming sink".to_string(),
282 ))
283 }
284}
285
286struct StateWithoutSelectedKeys<'a> {
292 value: &'a Value,
293 keys: KeyView<'a>,
294}
295
296impl Serialize for StateWithoutSelectedKeys<'_> {
297 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
298 where
299 S: serde::Serializer,
300 {
301 let Value::Object(map) = self.value else {
302 return serializer.serialize_map(Some(0))?.end();
303 };
304 let mut out = serializer.serialize_map(None)?;
305 for (key, value) in map {
306 if key != STATE_INJECT_KEY && !self.keys.contains(key) {
307 out.serialize_entry(key, value)?;
308 }
309 }
310 out.end()
311 }
312}
313
314pub trait FlushWriter: ResponseWriter {
319 fn flush(&mut self) -> Result<()>;
321}
322
323pub struct RenderOptions<'a> {
328 pub entry_id: &'a str,
330 pub request_path: &'a str,
332 pub nonce: Option<&'a str>,
336 pub head_inject: Option<&'a str>,
344 pub body_inject: Option<&'a str>,
349}
350
351pub const STATE_INJECT_KEY: &str = "$webui";
367
368#[derive(Clone, Copy, Default)]
375pub(crate) struct StateInject<'state> {
376 pub(crate) head_end: Option<&'state str>,
377 pub(crate) body_start: Option<&'state str>,
378 pub(crate) body_end: Option<&'state str>,
379}
380
381impl<'state> StateInject<'state> {
382 pub(crate) fn resolve(state: &'state Value) -> Self {
389 let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
390 return Self::default();
391 };
392 let field = |name: &str| {
393 map.get(name)
394 .and_then(Value::as_str)
395 .filter(|html| !html.is_empty())
396 };
397 Self {
398 head_end: field("headEnd"),
399 body_start: field("bodyStart"),
400 body_end: field("bodyEnd"),
401 }
402 }
403}
404
405impl<'a> RenderOptions<'a> {
406 #[must_use]
408 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
409 Self {
410 entry_id,
411 request_path,
412 nonce: None,
413 head_inject: None,
414 body_inject: None,
415 }
416 }
417
418 #[must_use]
422 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
423 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
424 self
425 }
426
427 #[must_use]
442 pub fn with_head_inject(mut self, html: &'a str) -> Self {
443 self.head_inject = if html.is_empty() { None } else { Some(html) };
444 self
445 }
446
447 #[must_use]
457 pub fn with_body_inject(mut self, html: &'a str) -> Self {
458 self.body_inject = if html.is_empty() { None } else { Some(html) };
459 self
460 }
461}
462
463pub struct WebUIHandler {
468 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
469}
470
471#[derive(Clone, Copy, Eq, PartialEq)]
472enum StyleClosureInstall {
473 Static,
474 Routed,
475}
476
477pub(crate) struct ShadowStyleRoot {
478 component_index: u32,
479 static_closure_emitted: bool,
480 routed_resources: Vec<u32>,
481}
482
483struct LoopBinding<'protocol, 'state> {
484 name: &'protocol str,
485 value: &'state Value,
486}
487
488#[derive(Clone, Copy)]
489struct VisibleLoopScope {
490 start: usize,
491 end: usize,
492}
493
494impl VisibleLoopScope {
495 const EMPTY: Self = Self { start: 0, end: 0 };
496}
497
498pub(crate) type ScopeMap = HashMap<String, Value, BuildHasherDefault<ScopeKeyHasher>>;
500
501#[derive(Clone, Copy, Default)]
508pub(crate) struct ScopeKeyHasher(u64);
509
510impl ScopeKeyHasher {
511 const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
512
513 #[inline]
514 fn add_word(&mut self, word: u64) {
515 self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(Self::SEED);
516 }
517}
518
519impl Hasher for ScopeKeyHasher {
520 #[inline]
521 fn write(&mut self, bytes: &[u8]) {
522 let (words, tail) = bytes.as_chunks::<8>();
523 for word in words {
524 self.add_word(u64::from_le_bytes(*word));
525 }
526 if !tail.is_empty() {
527 let mut word = [0u8; 8];
528 word[..tail.len()].copy_from_slice(tail);
529 self.add_word(u64::from_le_bytes(word));
530 }
531 }
532
533 #[inline]
534 fn write_u8(&mut self, byte: u8) {
535 self.add_word(u64::from(byte));
536 }
537
538 #[inline]
539 fn finish(&self) -> u64 {
540 self.0
541 }
542}
543
544#[derive(Clone, Copy)]
545struct LocalValueSources<'ctx, 'protocol, 'state> {
546 owned: &'ctx ScopeMap,
547 borrowed: &'ctx BorrowedScope<'protocol, 'state>,
548}
549
550pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
552
553#[derive(Default)]
554struct BorrowedScope<'protocol, 'state> {
555 inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
556 inline_len: usize,
557 overflow: Vec<(&'protocol str, &'state Value)>,
558}
559
560impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
561 fn get(&self, name: &str) -> Option<&'state Value> {
562 if self.inline_len == 0 {
563 return None;
564 }
565 if let Some((entry_name, value)) = self.inline[0].as_ref() {
566 if *entry_name == name {
567 return Some(*value);
568 }
569 }
570 for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
571 if *entry_name == name {
572 return Some(*value);
573 }
574 }
575 self.overflow
576 .iter()
577 .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
578 }
579
580 fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
581 for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
582 if *entry_name == name {
583 return Some(std::mem::replace(current, value));
584 }
585 }
586 for (entry_name, current) in &mut self.overflow {
587 if *entry_name == name {
588 return Some(std::mem::replace(current, value));
589 }
590 }
591 if self.inline_len < INLINE_SCOPE_SLOTS {
592 self.inline[self.inline_len] = Some((name, value));
593 self.inline_len += 1;
594 } else {
595 self.overflow.push((name, value));
596 }
597 None
598 }
599
600 fn remove(&mut self, name: &str) -> Option<&'state Value> {
601 if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
602 entry
603 .as_ref()
604 .is_some_and(|(entry_name, _)| *entry_name == name)
605 }) {
606 let removed = self.inline[index].take().map(|(_, value)| value);
607 if let Some(entry) = self.overflow.pop() {
608 self.inline[index] = Some(entry);
609 } else {
610 self.inline_len -= 1;
611 self.inline[index] = self.inline[self.inline_len].take();
612 }
613 return removed;
614 }
615 let index = self
616 .overflow
617 .iter()
618 .position(|(entry_name, _)| *entry_name == name)?;
619 Some(self.overflow.swap_remove(index).1)
620 }
621
622 fn clear(&mut self) {
623 for entry in &mut self.inline[..self.inline_len] {
624 *entry = None;
625 }
626 self.inline_len = 0;
627 self.overflow.clear();
628 }
629
630 fn clone_into_owned(&self, target: &mut ScopeMap) {
631 for (name, value) in self.inline[..self.inline_len].iter().flatten() {
632 target.insert((*name).to_owned(), (*value).clone());
633 }
634 for (name, value) in &self.overflow {
635 target.insert((*name).to_owned(), (*value).clone());
636 }
637 }
638}
639
640#[derive(Clone, Copy)]
646pub(crate) struct RenderFragmentList<'protocol> {
647 fragments: &'protocol [WebUIFragment],
648 metadata: &'protocol [RenderFragmentMetadata],
649 attr_names: &'protocol str,
650 contains_boundary: bool,
651 has_routes: bool,
654}
655
656impl<'protocol> RenderFragmentList<'protocol> {
657 fn target(self, index: usize) -> Option<usize> {
659 let target = self.metadata.get(index)?.target;
660 (target != NO_RENDER_SLOT).then_some(target as usize)
661 }
662
663 fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
665 let prepared = self.metadata.get(index)?;
666 if prepared.attr_start == NO_ATTR_NAME {
667 return None;
668 }
669 let start = prepared.attr_start as usize;
670 self.attr_names
671 .get(start..start + prepared.attr_len as usize)
672 }
673}
674
675const NO_RENDER_SLOT: u32 = u32::MAX;
677const NO_ATTR_NAME: u32 = u32::MAX;
679
680#[derive(Clone, Copy)]
685struct RenderFragmentMetadata {
686 target: u32,
690 attr_start: u32,
692 attr_len: u32,
693}
694
695pub(crate) struct RenderFragmentIndex {
702 ids: Box<[Arc<str>]>,
703 metadata: Box<[RenderFragmentMetadata]>,
704 ranges: Box<[u32]>,
706 attr_names: Box<str>,
708 route_presence: Box<[u64]>,
710}
711
712pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
717 cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
718 spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
722 index: &'protocol RenderFragmentIndex,
723 protocol: &'protocol WebUIProtocol,
724}
725
726impl RenderFragmentIndex {
727 pub(crate) fn new(
734 protocol: &WebUIProtocol,
735 ids: &[Arc<str>],
736 slots: &HashMap<Arc<str>, u32>,
737 ) -> Self {
738 let total_fragments = protocol
739 .fragments
740 .values()
741 .map(|list| list.fragments.len())
742 .sum();
743 let mut metadata = Vec::with_capacity(total_fragments);
744 let mut ranges = Vec::with_capacity(ids.len() + 1);
745 let mut attr_names = String::with_capacity(total_fragments * 8);
748 let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
749
750 for (slot, id) in ids.iter().enumerate() {
751 #[allow(clippy::cast_possible_truncation)]
753 ranges.push(metadata.len() as u32);
754 let Some(list) = protocol.fragments.get(id.as_ref()) else {
755 continue;
756 };
757 let mut has_routes = false;
758 for fragment in &list.fragments {
759 let inner = fragment.fragment.as_ref();
760 if matches!(inner, Some(Fragment::Route(_))) {
761 has_routes = true;
762 }
763 let target = fragment_target_id(fragment)
764 .and_then(|target| slots.get(target).copied())
765 .unwrap_or(NO_RENDER_SLOT);
766 let (attr_start, attr_len) = match inner {
767 Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
771 #[allow(clippy::cast_possible_truncation)]
772 let start = attr_names.len() as u32;
773 let before = attr_names.len();
774 push_component_attr_name(&mut attr_names, component_attr_source(attribute));
775 #[allow(clippy::cast_possible_truncation)]
776 let len = (attr_names.len() - before) as u32;
777 (start, len)
778 }
779 _ => (NO_ATTR_NAME, 0),
780 };
781 metadata.push(RenderFragmentMetadata {
782 target,
783 attr_start,
784 attr_len,
785 });
786 }
787 if has_routes {
788 route_presence[slot / 64] |= 1u64 << (slot % 64);
789 }
790 }
791 #[allow(clippy::cast_possible_truncation)]
792 ranges.push(metadata.len() as u32);
793
794 Self {
795 ids: ids.to_vec().into_boxed_slice(),
796 metadata: metadata.into_boxed_slice(),
797 ranges: ranges.into_boxed_slice(),
798 attr_names: attr_names.into_boxed_str(),
799 route_presence: route_presence.into_boxed_slice(),
800 }
801 }
802
803 pub(crate) fn index(&self, id: &str) -> Option<usize> {
805 self.ids
806 .binary_search_by(|candidate| candidate.as_ref().cmp(id))
807 .ok()
808 }
809
810 pub(crate) fn resolve<'protocol>(
811 &'protocol self,
812 protocol: &'protocol WebUIProtocol,
813 ) -> ResolvedRenderFragmentIndex<'protocol> {
814 ResolvedRenderFragmentIndex {
815 cache: std::array::from_fn(|_| Cell::new(None)),
816 spilled: OnceCell::new(),
817 index: self,
818 protocol,
819 }
820 }
821}
822
823impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
824 pub(crate) fn index(&self, id: &str) -> Option<usize> {
825 self.index.index(id)
826 }
827
828 fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
830 if index < INLINE_RENDER_FRAGMENT_LISTS {
831 return self.cache.get(index);
832 }
833 let spilled = self.spilled.get_or_init(|| {
834 let overflow = self
835 .index
836 .ids
837 .len()
838 .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
839 (0..overflow).map(|_| Cell::new(None)).collect()
840 });
841 spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
842 }
843
844 pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
845 let id = self.index.ids.get(index)?;
846 let fragment_list = match self.slot(index) {
847 Some(slot) => match slot.get() {
848 Some(cached) => cached,
849 None => {
850 let resolved = self.protocol.fragments.get(id.as_ref())?;
851 slot.set(Some(resolved));
852 resolved
853 }
854 },
855 None => self.protocol.fragments.get(id.as_ref())?,
856 };
857 let start = *self.index.ranges.get(index)? as usize;
858 let end = *self.index.ranges.get(index + 1)? as usize;
859 let metadata = self.index.metadata.get(start..end)?;
860 let has_routes = self
861 .index
862 .route_presence
863 .get(index / 64)
864 .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
865 Some(RenderFragmentList {
866 fragments: &fragment_list.fragments,
867 metadata,
868 attr_names: &self.index.attr_names,
869 contains_boundary: fragment_list.contains_boundary,
870 has_routes,
871 })
872 }
873
874 #[cfg(test)]
875 pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
876 self.index(id).and_then(|index| self.list(index))
877 }
878}
879
880fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
887 let takes_complex_branch = attribute.condition_tree.is_none()
888 && attribute.template.is_empty()
889 && !attribute.value.is_empty()
890 && !attribute.raw_value
891 && attribute.complex;
892 if takes_complex_branch {
893 attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
894 } else {
895 &attribute.name
896 }
897}
898
899fn descend_into<'protocol>(
905 children: &mut RouteChildren<'protocol>,
906 index: usize,
907) -> RouteChildren<'protocol> {
908 match children {
909 Cow::Borrowed(routes) => match routes.get(index) {
910 Some(route) => Cow::Borrowed(&route.children),
911 None => Cow::Borrowed(&[]),
912 },
913 Cow::Owned(routes) => match routes.get_mut(index) {
914 Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
915 None => Cow::Borrowed(&[]),
916 },
917 }
918}
919
920fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
922 match fragment.fragment.as_ref()? {
923 Fragment::Component(component) => Some(&component.fragment_id),
924 Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
925 Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
926 Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
927 Some(&attribute.template)
928 }
929 _ => None,
930 }
931}
932
933pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
935 pub(crate) protocol: &'protocol WebUIProtocol,
936 pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
940 pub(crate) component_asset_style_manifest: &'protocol str,
943 pub(crate) component_asset_style_links: &'protocol str,
945 pub(crate) state: &'state Value,
946 pub(crate) writer: &'output mut dyn ResponseWriter,
947 pub(crate) local_vars: ScopeMap,
948 local_borrowed_vars: BorrowedScope<'protocol, 'state>,
950 loop_vars: Vec<LoopBinding<'protocol, 'state>>,
952 visible_loop_scope: VisibleLoopScope,
955 pub(crate) component_attrs: ScopeMap,
957 component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
959 collecting_component_attrs: bool,
963 pub(crate) request_path: &'protocol str,
966 pub(crate) route_base: Cow<'protocol, str>,
971 pub(crate) rendered_components: HashSet<String>,
975 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
977 pub(crate) route_children: RouteChildren<'protocol>,
985 pub(crate) entry_id: &'protocol str,
988 pub(crate) nonce: Option<&'protocol str>,
991 pub(crate) component_index: &'protocol HashMap<String, u32>,
994 pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
996 pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
1001 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
1007 pub(crate) css_strategy: webui_protocol::CssStrategy,
1009 pub(crate) head_inject: Option<&'protocol str>,
1015 pub(crate) body_inject: Option<&'protocol str>,
1019 pub(crate) state_inject: StateInject<'state>,
1024 pub(crate) head_end_emitted: bool,
1031 pub(crate) body_start_emitted: bool,
1036 pub(crate) component_asset_styles_emitted: bool,
1039 pub(crate) body_end_emitted: bool,
1044 pub(crate) route_index: &'protocol CompiledRouteIndex,
1046 pub(crate) route_chain_index: usize,
1050 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
1053 pub(crate) route_document_style_targets: Vec<bool>,
1057 pub(crate) reachable_components: Option<Vec<String>>,
1063 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
1065 pub(crate) json_scratch: Vec<u8>,
1072 pub(crate) scope_pool: Vec<ScopeMap>,
1078 pub(crate) document_style_resources: HashSet<String>,
1081 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1084 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1086}
1087
1088pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1091
1092pub(crate) fn structural_signal_value(
1093 signal: &webui_protocol::WebUIFragmentSignal,
1094) -> Option<&str> {
1095 if !signal.raw {
1096 return None;
1097 }
1098 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1099}
1100
1101pub(crate) fn write_interaction_marker(
1102 tag: &str,
1103 context: &mut WebUIProcessContext<'_, '_, '_>,
1104) -> Result<()> {
1105 if matches!(
1106 context
1107 .protocol
1108 .components
1109 .get(tag)
1110 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1111 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1112 ) {
1113 context.writer.write(" data-webui-interaction")?;
1114 }
1115 Ok(())
1116}
1117
1118fn doctype_prefix_end(raw: &str) -> Option<usize> {
1121 const PREFIX: &[u8] = b"<!doctype";
1122
1123 let bytes = raw.as_bytes();
1124 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1125 while bytes
1126 .get(cursor)
1127 .is_some_and(|byte| byte.is_ascii_whitespace())
1128 {
1129 cursor += 1;
1130 }
1131
1132 let prefix_end = cursor.checked_add(PREFIX.len())?;
1133 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1134 return None;
1135 }
1136 let separator = *bytes.get(prefix_end)?;
1137 if separator != b'>' && !separator.is_ascii_whitespace() {
1138 return None;
1139 }
1140
1141 let mut quote = None;
1142 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1143 match quote {
1144 Some(active) if byte == active => quote = None,
1145 Some(_) => {}
1146 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1147 None if byte == b'>' => return Some(prefix_end + offset + 1),
1148 None => {}
1149 }
1150 }
1151 None
1152}
1153
1154const SCOPE_POOL_CAP: usize = 8;
1157const INLINE_SCOPE_SLOTS: usize = 4;
1159const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1170const INLINE_STYLE_UNITS: usize = 8;
1172const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1173
1174struct EmittedStyleUnits<'a> {
1180 inline: [&'a str; INLINE_STYLE_UNITS],
1181 inline_len: usize,
1182 overflow: HashSet<&'a str>,
1183}
1184
1185impl<'a> EmittedStyleUnits<'a> {
1186 fn new() -> Self {
1187 Self {
1188 inline: [""; INLINE_STYLE_UNITS],
1189 inline_len: 0,
1190 overflow: HashSet::new(),
1191 }
1192 }
1193
1194 fn insert(&mut self, name: &'a str) -> bool {
1196 if self.inline[..self.inline_len].contains(&name) {
1197 return false;
1198 }
1199 if self.inline_len < INLINE_STYLE_UNITS {
1200 self.inline[self.inline_len] = name;
1201 self.inline_len += 1;
1202 return true;
1203 }
1204 self.overflow.insert(name)
1205 }
1206}
1207
1208struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1209
1210impl Serialize for ComponentAssetStyleManifest<'_> {
1211 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1212 where
1213 S: serde::Serializer,
1214 {
1215 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1216 for entry in self.0 {
1217 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1218 }
1219 map.end()
1220 }
1221}
1222
1223fn serialize_component_asset_style_manifest(
1224 preloads: &[ComponentAssetStylePreload],
1225) -> std::result::Result<String, serde_json::Error> {
1226 if preloads.is_empty() {
1227 return Ok(String::new());
1228 }
1229 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1230 if json.contains("</") {
1231 Ok(json.replace("</", "<\\/"))
1232 } else {
1233 Ok(json)
1234 }
1235}
1236
1237fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1238 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1239 let mut seen = HashSet::with_capacity(href_count);
1240 let mut links = String::with_capacity(href_count.saturating_mul(48));
1241 for entry in preloads {
1242 for href in &entry.style_hrefs {
1243 if !seen.insert(href.as_str()) {
1244 continue;
1245 }
1246 links.push_str("<link rel=\"stylesheet\" href=\"");
1247 push_escaped_html_attribute(&mut links, href);
1248 links.push_str("\">");
1249 }
1250 }
1251 links
1252}
1253
1254fn push_escaped_html_attribute(output: &mut String, value: &str) {
1255 let mut start = 0;
1256 for (index, ch) in value.char_indices() {
1257 let escaped = match ch {
1258 '&' => Some("&"),
1259 '"' => Some("""),
1260 '<' => Some("<"),
1261 '>' => Some(">"),
1262 _ => None,
1263 };
1264 let Some(entity) = escaped else {
1265 continue;
1266 };
1267 if start < index {
1268 output.push_str(&value[start..index]);
1269 }
1270 output.push_str(entity);
1271 start = index + ch.len_utf8();
1272 }
1273 if start < value.len() {
1274 output.push_str(&value[start..]);
1275 }
1276}
1277
1278fn remove_owned_component_attr(attrs: &mut ScopeMap, name: &str) {
1283 if !attrs.is_empty() {
1284 attrs.remove(name);
1285 }
1286}
1287
1288fn take_scope_map(pool: &mut Vec<ScopeMap>) -> ScopeMap {
1291 pool.pop().unwrap_or_default()
1292}
1293
1294fn recycle_scope_map(pool: &mut Vec<ScopeMap>, mut map: ScopeMap) {
1298 if pool.len() < SCOPE_POOL_CAP {
1299 map.clear();
1300 pool.push(map);
1301 }
1302}
1303
1304fn take_borrowed_scope<'protocol, 'state>(
1305 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1306) -> BorrowedScope<'protocol, 'state> {
1307 pool.pop().unwrap_or_default()
1308}
1309
1310fn recycle_borrowed_scope<'protocol, 'state>(
1311 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1312 mut scope: BorrowedScope<'protocol, 'state>,
1313) {
1314 if pool.len() < SCOPE_POOL_CAP {
1315 scope.clear();
1316 pool.push(scope);
1317 }
1318}
1319
1320pub(crate) enum WebUiBootstrapState<'a> {
1321 Complete {
1322 value: &'a Value,
1323 selection: StateSelection<'a>,
1324 },
1325 Reference {
1326 record_sequence: usize,
1327 value: &'a Value,
1328 delta: Option<StateSelection<'a>>,
1329 },
1330}
1331
1332pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1333 pub(crate) declaration_id: Option<u32>,
1334 pub(crate) enclosing_span_instance_id: Option<u32>,
1335 pub(crate) state: WebUiBootstrapState<'a>,
1336 pub(crate) chain: &'a [Value],
1337 pub(crate) inventory: &'a str,
1338 pub(crate) nonce: Option<&'a str>,
1339 pub(crate) css_hrefs: &'a [&'a str],
1340 pub(crate) style_specs: &'a [&'a str],
1341 pub(crate) component_styles: &'a ComponentStyles,
1342 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1343}
1344
1345#[cfg(test)]
1355fn component_attr_name(name: &str) -> String {
1356 let mut buffer = String::new();
1357 push_component_attr_name(&mut buffer, name);
1358 buffer
1359}
1360
1361fn push_component_attr_name(buffer: &mut String, name: &str) {
1370 let stripped = name.strip_prefix(':').unwrap_or(name);
1371 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1376 buffer.push_str(property);
1377 return;
1378 }
1379 buffer.reserve(stripped.len());
1380 let mut capitalize_next = false;
1381 for ch in stripped.chars() {
1382 if ch == '-' {
1383 capitalize_next = true;
1384 } else if capitalize_next {
1385 buffer.extend(ch.to_uppercase());
1386 capitalize_next = false;
1387 } else {
1388 buffer.push(ch);
1389 }
1390 }
1391}
1392
1393#[cold]
1394#[inline(never)]
1395fn missing_component_attr_name_error() -> HandlerError {
1396 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1397}
1398
1399struct ScalarBuffer {
1407 buf: [u8; 48],
1408 len: usize,
1409}
1410
1411impl ScalarBuffer {
1412 #[inline]
1413 const fn new() -> Self {
1414 Self {
1415 buf: [0; 48],
1416 len: 0,
1417 }
1418 }
1419
1420 #[inline]
1421 fn as_str(&self) -> Option<&str> {
1422 std::str::from_utf8(&self.buf[..self.len]).ok()
1423 }
1424}
1425
1426impl fmt::Write for ScalarBuffer {
1427 #[inline]
1428 fn write_str(&mut self, s: &str) -> fmt::Result {
1429 let bytes = s.as_bytes();
1430 let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1431 let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1432 slot.copy_from_slice(bytes);
1433 self.len = end;
1434 Ok(())
1435 }
1436}
1437
1438fn format_plain_json_scalar<'buf>(
1445 buffer: &'buf mut ScalarBuffer,
1446 value: &Value,
1447) -> Option<&'buf str> {
1448 match value {
1449 Value::Bool(true) => Some("true"),
1450 Value::Bool(false) => Some("false"),
1451 Value::Null => Some("null"),
1452 Value::Number(number) => {
1453 write!(buffer, "{number}").ok()?;
1456 buffer.as_str()
1457 }
1458 _ => None,
1459 }
1460}
1461
1462fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1466 let mut buffer = ScalarBuffer::new();
1467 match format_plain_json_scalar(&mut buffer, value) {
1468 Some(rendered) => {
1469 writer.write(rendered)?;
1470 Ok(true)
1471 }
1472 None => Ok(false),
1473 }
1474}
1475
1476pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1478 if n == 0 {
1479 return writer.write("0");
1480 }
1481 let mut buf = [0u8; 20];
1483 let mut pos = buf.len();
1484 while n > 0 {
1485 pos -= 1;
1486 #[allow(clippy::cast_possible_truncation)]
1488 let digit = (n % 10) as u8;
1489 buf[pos] = b'0' + digit;
1490 n /= 10;
1491 }
1492 match std::str::from_utf8(&buf[pos..]) {
1494 Ok(s) => writer.write(s),
1495 Err(_) => writer.write("0"),
1496 }
1497}
1498
1499pub(crate) fn write_script_safe_json<T>(
1500 writer: &mut dyn ResponseWriter,
1501 scratch: &mut Vec<u8>,
1502 value: &T,
1503) -> Result<()>
1504where
1505 T: Serialize + ?Sized,
1506{
1507 scratch.clear();
1515 serde_json::to_writer(&mut *scratch, value)
1516 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1517 let json = std::str::from_utf8(scratch)
1518 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1519 write_script_safe_json_str(writer, json)
1520}
1521
1522static END_TAG_OPENER: LazyLock<memchr::memmem::Finder<'static>> =
1525 LazyLock::new(|| memchr::memmem::Finder::new(b"</"));
1526
1527fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1528 let bytes = json.as_bytes();
1532 let mut start = 0;
1533 if let Some(first) = memchr::memchr(b'<', bytes) {
1534 for offset in END_TAG_OPENER.find_iter(&bytes[first..]) {
1535 let index = first + offset;
1536 if index > start {
1537 writer.write(&json[start..index])?;
1538 }
1539 writer.write("<\\/")?;
1540 start = index + 2;
1541 }
1542 }
1543 if start < json.len() {
1544 writer.write(&json[start..])?;
1545 }
1546 Ok(())
1547}
1548
1549fn write_json_field_name(
1550 writer: &mut dyn ResponseWriter,
1551 wrote_field: &mut bool,
1552 name: &str,
1553) -> Result<()> {
1554 if *wrote_field {
1555 writer.write(",")?;
1556 }
1557 *wrote_field = true;
1558 writer.write("\"")?;
1559 writer.write(name)?;
1560 writer.write("\":")
1561}
1562
1563fn write_json_field<T>(
1564 writer: &mut dyn ResponseWriter,
1565 scratch: &mut Vec<u8>,
1566 wrote_field: &mut bool,
1567 name: &str,
1568 value: &T,
1569) -> Result<()>
1570where
1571 T: Serialize + ?Sized,
1572{
1573 write_json_field_name(writer, wrote_field, name)?;
1574 write_script_safe_json(writer, scratch, value)
1575}
1576
1577struct ProjectedState<'a> {
1595 value: &'a Value,
1596 keys: KeyView<'a>,
1597}
1598
1599impl Serialize for ProjectedState<'_> {
1600 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1601 where
1602 S: serde::Serializer,
1603 {
1604 let Value::Object(map) = self.value else {
1605 return serializer.serialize_map(Some(0))?.end();
1606 };
1607
1608 let mut out = serializer.serialize_map(None)?;
1609 if self.keys.len() < map.len() {
1610 let mut previous = None;
1611 for key in self.keys.iter() {
1612 if key == STATE_INJECT_KEY {
1613 continue;
1614 }
1615 if previous == Some(key) {
1616 continue;
1617 }
1618 previous = Some(key);
1619 if let Some(value) = map.get(key) {
1620 out.serialize_entry(key, value)?;
1621 }
1622 }
1623 } else {
1624 for (key, value) in map {
1625 if key == STATE_INJECT_KEY {
1626 continue;
1627 }
1628 if self.keys.contains(key.as_str()) {
1629 out.serialize_entry(key, value)?;
1630 }
1631 }
1632 }
1633 out.end()
1634 }
1635}
1636
1637struct StateWithoutReservedKey<'a> {
1658 value: &'a Value,
1659}
1660
1661impl Serialize for StateWithoutReservedKey<'_> {
1662 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1663 where
1664 S: serde::Serializer,
1665 {
1666 let Value::Object(map) = self.value else {
1667 return self.value.serialize(serializer);
1668 };
1669 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1670 for (key, value) in map {
1671 if key == STATE_INJECT_KEY {
1672 continue;
1673 }
1674 out.serialize_entry(key, value)?;
1675 }
1676 out.end()
1677 }
1678}
1679
1680fn write_full_state(
1685 writer: &mut dyn ResponseWriter,
1686 scratch: &mut Vec<u8>,
1687 state: &Value,
1688) -> Result<()> {
1689 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1690 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1691 }
1692 write_script_safe_json(writer, scratch, state)
1693}
1694
1695pub(crate) fn write_selected_state(
1696 writer: &mut dyn ResponseWriter,
1697 scratch: &mut Vec<u8>,
1698 state: &Value,
1699 selection: &StateSelection<'_>,
1700) -> Result<()> {
1701 let keys = match selection {
1702 StateSelection::Full => return write_full_state(writer, scratch, state),
1703 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1704 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1705 StateSelection::FullExceptKeyIds(selection) => {
1706 return write_script_safe_json(
1707 writer,
1708 scratch,
1709 &StateWithoutSelectedKeys {
1710 value: state,
1711 keys: KeyView::Ids(*selection),
1712 },
1713 );
1714 }
1715 };
1716 if keys.is_empty() {
1717 return writer.write("{}");
1718 }
1719
1720 debug_assert!(
1725 keys.iter()
1726 .zip(keys.iter().skip(1))
1727 .all(|(left, right)| left <= right),
1728 "hydration keys must be sorted for binary-search projection"
1729 );
1730 if let Value::Object(map) = state {
1731 let selects_entire_map =
1732 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1733 if selects_entire_map {
1734 return write_full_state(writer, scratch, state);
1735 }
1736 }
1737 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1738}
1739
1740pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1743
1744pub(crate) enum StateSelection<'a> {
1746 Full,
1748 Keys(Vec<&'a str>),
1750 KeyIds(HydrationKeySelection<'a>),
1757 FullExceptKeyIds(HydrationKeySelection<'a>),
1759}
1760
1761#[derive(Clone, Copy)]
1763pub(crate) struct HydrationKeySelection<'a> {
1764 pub(crate) ids: &'a [u32],
1765 pub(crate) index: &'a ComponentReachabilityIndex,
1766}
1767
1768impl<'a> HydrationKeySelection<'a> {
1769 fn key(self, position: usize) -> Option<&'a str> {
1770 let id = self.ids.get(position).copied()?;
1771 self.index.hydration_key(id)
1772 }
1773}
1774
1775#[derive(Clone, Copy)]
1777enum KeyView<'a> {
1778 Borrowed(&'a [&'a str]),
1779 Ids(HydrationKeySelection<'a>),
1780}
1781
1782impl<'a> KeyView<'a> {
1783 fn len(self) -> usize {
1784 match self {
1785 Self::Borrowed(keys) => keys.len(),
1786 Self::Ids(selection) => selection.ids.len(),
1787 }
1788 }
1789
1790 fn is_empty(self) -> bool {
1791 self.len() == 0
1792 }
1793
1794 fn get(self, position: usize) -> Option<&'a str> {
1795 match self {
1796 Self::Borrowed(keys) => keys.get(position).copied(),
1797 Self::Ids(selection) => selection.key(position),
1798 }
1799 }
1800
1801 fn iter(self) -> impl Iterator<Item = &'a str> {
1802 (0..self.len()).map_while(move |position| self.get(position))
1803 }
1804
1805 fn contains(self, key: &str) -> bool {
1808 match self {
1809 Self::Borrowed(keys) => keys
1810 .binary_search_by(|candidate| str::cmp(candidate, key))
1811 .is_ok(),
1812 Self::Ids(selection) => selection
1813 .ids
1814 .binary_search_by(|id| {
1815 selection
1816 .index
1817 .hydration_key(*id)
1818 .map_or(std::cmp::Ordering::Less, |candidate| {
1819 str::cmp(candidate, key)
1820 })
1821 })
1822 .is_ok(),
1823 }
1824 }
1825}
1826
1827#[derive(Clone, Copy)]
1828enum ComponentStateSurface {
1829 Hydration,
1830 Navigation,
1831}
1832
1833pub(crate) fn collect_hydration_state<'a, 'b>(
1839 protocol: &'a WebUIProtocol,
1840 components: impl IntoIterator<Item = &'b str>,
1841) -> StateSelection<'a> {
1842 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1843 return StateSelection::Full;
1844 }
1845 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1846}
1847
1848pub(crate) fn collect_hydration_key_ids_into(
1856 protocol: &WebUIProtocol,
1857 index: &ComponentReachabilityIndex,
1858 components: impl IntoIterator<Item = u32>,
1859 ids: &mut Vec<u32>,
1860) -> bool {
1861 ids.clear();
1862 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1863 return true;
1864 }
1865 for component in components {
1866 if index.extend_hydration_keys(component, ids) {
1867 ids.clear();
1868 return true;
1869 }
1870 }
1871 ids.sort_unstable();
1872 ids.dedup();
1873 false
1874}
1875
1876pub(crate) fn collect_navigation_state<'a, 'b>(
1878 protocol: &'a WebUIProtocol,
1879 components: impl IntoIterator<Item = &'b str>,
1880) -> StateSelection<'a> {
1881 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1882}
1883
1884fn collect_component_state<'a, 'b>(
1885 protocol: &'a WebUIProtocol,
1886 components: impl IntoIterator<Item = &'b str>,
1887 surface: ComponentStateSurface,
1888) -> StateSelection<'a> {
1889 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1890 if collect_component_state_into(protocol, components, surface, &mut keys) {
1891 StateSelection::Full
1892 } else {
1893 StateSelection::Keys(keys)
1894 }
1895}
1896
1897fn collect_component_state_into<'a, 'b>(
1900 protocol: &'a WebUIProtocol,
1901 components: impl IntoIterator<Item = &'b str>,
1902 surface: ComponentStateSurface,
1903 keys: &mut Vec<&'a str>,
1904) -> bool {
1905 keys.clear();
1906 for name in components {
1907 let Some(component) = protocol.components.get(name) else {
1908 return true;
1909 };
1910 let (mode, component_keys) = match surface {
1911 ComponentStateSurface::Hydration => {
1912 (component.hydration_mode, &component.hydration_keys)
1913 }
1914 ComponentStateSurface::Navigation => (
1915 match component.navigation_mode {
1916 Some(mode) => mode,
1917 None if !component.navigation_keys.is_empty() => {
1918 StateProjectionMode::Keys as i32
1919 }
1920 None => return true,
1921 },
1922 &component.navigation_keys,
1923 ),
1924 };
1925 if mode == StateProjectionMode::All as i32 {
1926 return true;
1927 }
1928 if mode == StateProjectionMode::Keys as i32
1929 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1930 {
1931 keys.extend(component_keys.iter().map(String::as_str));
1932 } else if mode != StateProjectionMode::None as i32 {
1933 return true;
1934 }
1935 }
1936 keys.sort_unstable();
1937 keys.dedup();
1938 false
1939}
1940
1941pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1942 writer: &mut dyn ResponseWriter,
1943 scratch: &mut Vec<u8>,
1944 bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1945) -> Result<()> {
1946 let mut wrote_field = false;
1947
1948 writer.write("{")?;
1949 if let Some(declaration_id) = bootstrap.declaration_id {
1950 write_json_field(
1951 writer,
1952 scratch,
1953 &mut wrote_field,
1954 "declarationId",
1955 &declaration_id,
1956 )?;
1957 }
1958 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1959 write_json_field(
1960 writer,
1961 scratch,
1962 &mut wrote_field,
1963 "enclosingSpanInstanceId",
1964 &span_id,
1965 )?;
1966 }
1967 if !bootstrap.chain.is_empty() {
1968 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1969 }
1970 write_json_field(
1973 writer,
1974 scratch,
1975 &mut wrote_field,
1976 "componentStyles",
1977 bootstrap.component_styles,
1978 )?;
1979 if !bootstrap.css_hrefs.is_empty() {
1980 write_json_field(
1981 writer,
1982 scratch,
1983 &mut wrote_field,
1984 "css",
1985 bootstrap.css_hrefs,
1986 )?;
1987 }
1988 write_json_field(
1989 writer,
1990 scratch,
1991 &mut wrote_field,
1992 "inventory",
1993 bootstrap.inventory,
1994 )?;
1995 if let Some(nonce) = bootstrap.nonce {
1996 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1997 }
1998 match bootstrap.state {
1999 WebUiBootstrapState::Complete { value, selection } => {
2000 write_json_field_name(writer, &mut wrote_field, "state")?;
2001 write_selected_state(writer, scratch, value, &selection)?;
2002 }
2003 WebUiBootstrapState::Reference {
2004 record_sequence,
2005 value,
2006 delta,
2007 } => {
2008 write_json_field(
2009 writer,
2010 scratch,
2011 &mut wrote_field,
2012 "stateRef",
2013 &record_sequence,
2014 )?;
2015 if let Some(delta) = delta {
2016 write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
2017 write_selected_state(writer, scratch, value, &delta)?;
2018 }
2019 }
2020 }
2021 if !bootstrap.style_specs.is_empty() {
2022 write_json_field(
2023 writer,
2024 scratch,
2025 &mut wrote_field,
2026 "styles",
2027 bootstrap.style_specs,
2028 )?;
2029 }
2030 if bootstrap
2031 .templates
2032 .iter()
2033 .any(|template| !template.template_json.is_empty())
2034 {
2035 write_json_field_name(writer, &mut wrote_field, "templates")?;
2036 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
2037 }
2038 writer.write("}")
2039}
2040
2041fn write_webui_data_block(
2042 writer: &mut dyn ResponseWriter,
2043 scratch: &mut Vec<u8>,
2044 bootstrap: WebUiBootstrap<'_>,
2045) -> Result<()> {
2046 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
2047 if let Some(nonce) = bootstrap.nonce {
2048 writer.write(" nonce=\"")?;
2049 writer.write(nonce)?;
2050 writer.write("\"")?;
2051 }
2052
2053 writer.write(">")?;
2054 write_webui_bootstrap(writer, scratch, bootstrap)?;
2055 writer.write("</script>\n")
2056}
2057
2058fn write_component_asset_style_manifest(
2059 writer: &mut dyn ResponseWriter,
2060 manifest: &str,
2061 nonce: Option<&str>,
2062) -> Result<()> {
2063 if manifest.is_empty() {
2064 return Ok(());
2065 }
2066 writer.write("<script type=\"application/json\" id=\"")?;
2067 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
2068 writer.write("\"")?;
2069 if let Some(nonce) = nonce {
2070 writer.write(" nonce=\"")?;
2071 writer.write(&crate::html_encode::encode_safe(nonce))?;
2072 writer.write("\"")?;
2073 }
2074 writer.write(">")?;
2075 writer.write(manifest)?;
2076 writer.write("</script>")
2077}
2078
2079fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
2080 if context.component_asset_styles_emitted {
2081 return Ok(());
2082 }
2083 context.component_asset_styles_emitted = true;
2084 context.writer.write(context.component_asset_style_links)?;
2085 write_component_asset_style_manifest(
2086 context.writer,
2087 context.component_asset_style_manifest,
2088 context.nonce,
2089 )
2090}
2091
2092fn write_webui_template_json_map(
2093 writer: &mut dyn ResponseWriter,
2094 scratch: &mut Vec<u8>,
2095 templates: &[WebUiTemplatePayload<'_>],
2096) -> Result<()> {
2097 writer.write("{")?;
2098 let mut wrote = false;
2099 for template in templates {
2100 if template.template_json.is_empty() {
2101 continue;
2102 }
2103 if wrote {
2104 writer.write(",")?;
2105 }
2106 wrote = true;
2107 write_script_safe_json(writer, scratch, template.tag_name)?;
2108 writer.write(":")?;
2109 write_script_safe_json_str(writer, template.template_json)?;
2110 }
2111 writer.write("}")
2112}
2113
2114fn resolve_value_from_sources<'ctx, 'state>(
2115 path: &str,
2116 loop_vars: &'ctx [LoopBinding<'_, 'state>],
2117 visible_loop_scope: VisibleLoopScope,
2118 local_values: LocalValueSources<'ctx, '_, 'state>,
2119 state: &'state Value,
2120) -> Option<Cow<'ctx, Value>>
2121where
2122 'state: 'ctx,
2123{
2124 if let Some(binding) = loop_vars
2125 .get(visible_loop_scope.start..visible_loop_scope.end)
2126 .and_then(|bindings| bindings.last())
2127 {
2128 let name = binding.name;
2129 if path.len() == name.len() && path == name {
2130 return Some(Cow::Borrowed(binding.value));
2131 }
2132 if path.len() > name.len()
2133 && path.as_bytes().get(name.len()) == Some(&b'.')
2134 && path.starts_with(name)
2135 {
2136 if let Some(value) =
2137 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2138 {
2139 return Some(value);
2140 }
2141 return find_value_by_dotted_path_ref(path, state);
2142 }
2143 }
2144
2145 if let Some(first_part) = path.split('.').next() {
2146 if let Some(local_value) = local_values.borrowed.get(first_part) {
2147 if first_part.len() == path.len() {
2148 return Some(Cow::Borrowed(local_value));
2149 }
2150 let remaining = &path[first_part.len() + 1..];
2151 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2152 return Some(value);
2153 }
2154 return find_value_by_dotted_path_ref(path, state);
2155 }
2156 if let Some(local_value) = local_values.owned.get(first_part) {
2157 if first_part.len() == path.len() {
2158 return Some(Cow::Borrowed(local_value));
2159 }
2160 let remaining = &path[first_part.len() + 1..];
2161 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2162 return Some(value);
2163 }
2164 return find_value_by_dotted_path_ref(path, state);
2165 }
2166
2167 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2168 .iter()
2169 .rev()
2170 {
2171 if binding.name != first_part {
2172 continue;
2173 }
2174 if first_part.len() == path.len() {
2175 return Some(Cow::Borrowed(binding.value));
2176 }
2177 let remaining = &path[first_part.len() + 1..];
2178 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2179 return Some(value);
2180 }
2181 return find_value_by_dotted_path_ref(path, state);
2182 }
2183 }
2184
2185 find_value_by_dotted_path_ref(path, state)
2186}
2187
2188fn resolve_borrowed_collection<'state>(
2189 path: &str,
2190 loop_vars: &[LoopBinding<'_, 'state>],
2191 visible_loop_scope: VisibleLoopScope,
2192 local_values: LocalValueSources<'_, '_, 'state>,
2193 state: &'state Value,
2194) -> Option<&'state [Value]> {
2195 let first_part = path.split('.').next()?;
2196 if let Some(binding) = loop_vars
2197 .get(visible_loop_scope.start..visible_loop_scope.end)
2198 .and_then(|bindings| bindings.last())
2199 .filter(|binding| binding.name == first_part)
2200 {
2201 if first_part.len() == path.len() {
2202 return binding.value.as_array().map(Vec::as_slice);
2203 }
2204 let remaining = &path[first_part.len() + 1..];
2205 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2206 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2207 Some(Cow::Owned(_)) => None,
2208 None => borrowed_state_array(path, state),
2209 };
2210 }
2211 if let Some(local_value) = local_values.borrowed.get(first_part) {
2212 let local_match = if first_part.len() == path.len() {
2213 Some(Cow::Borrowed(local_value))
2214 } else {
2215 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2216 };
2217 return match local_match {
2218 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2219 Some(Cow::Owned(_)) => None,
2220 None => borrowed_state_array(path, state),
2221 };
2222 }
2223 if let Some(local_value) = local_values.owned.get(first_part) {
2224 let local_match = if first_part.len() == path.len() {
2225 Some(Cow::Borrowed(local_value))
2226 } else {
2227 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2228 };
2229 if local_match.is_some() {
2230 return None;
2231 }
2232 return borrowed_state_array(path, state);
2233 }
2234
2235 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2236 .iter()
2237 .rev()
2238 {
2239 if binding.name != first_part {
2240 continue;
2241 }
2242 if first_part.len() == path.len() {
2243 return binding.value.as_array().map(Vec::as_slice);
2244 }
2245 let remaining = &path[first_part.len() + 1..];
2246 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2247 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2248 Some(Cow::Owned(_)) => None,
2249 None => borrowed_state_array(path, state),
2250 };
2251 }
2252
2253 borrowed_state_array(path, state)
2254}
2255
2256fn resolve_state_backed_value<'state>(
2257 path: &str,
2258 loop_vars: &[LoopBinding<'_, 'state>],
2259 visible_loop_scope: VisibleLoopScope,
2260 local_values: LocalValueSources<'_, '_, 'state>,
2261 state: &'state Value,
2262) -> Option<&'state Value> {
2263 let first_part = path.split('.').next()?;
2264 if let Some(binding) = loop_vars
2265 .get(visible_loop_scope.start..visible_loop_scope.end)
2266 .and_then(|bindings| bindings.last())
2267 .filter(|binding| binding.name == first_part)
2268 {
2269 if first_part.len() == path.len() {
2270 return Some(binding.value);
2271 }
2272 let remaining = &path[first_part.len() + 1..];
2273 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2274 Some(Cow::Borrowed(value)) => Some(value),
2275 Some(Cow::Owned(_)) => None,
2276 None => borrowed_state_value(path, state),
2277 };
2278 }
2279 if let Some(local_value) = local_values.borrowed.get(first_part) {
2280 if first_part.len() == path.len() {
2281 return Some(local_value);
2282 }
2283 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2284 Some(Cow::Borrowed(value)) => Some(value),
2285 Some(Cow::Owned(_)) => None,
2286 None => borrowed_state_value(path, state),
2287 };
2288 }
2289 if let Some(local_value) = local_values.owned.get(first_part) {
2290 if first_part.len() == path.len() {
2291 return None;
2292 }
2293 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2294 Some(_) => None,
2295 None => borrowed_state_value(path, state),
2296 };
2297 }
2298
2299 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2300 .iter()
2301 .rev()
2302 {
2303 if binding.name != first_part {
2304 continue;
2305 }
2306 if first_part.len() == path.len() {
2307 return Some(binding.value);
2308 }
2309 let remaining = &path[first_part.len() + 1..];
2310 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2311 Some(Cow::Borrowed(value)) => Some(value),
2312 Some(Cow::Owned(_)) => None,
2313 None => borrowed_state_value(path, state),
2314 };
2315 }
2316
2317 borrowed_state_value(path, state)
2318}
2319
2320fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2321 match find_value_by_dotted_path_ref(path, state) {
2322 Some(Cow::Borrowed(value)) => Some(value),
2323 Some(Cow::Owned(_)) | None => None,
2324 }
2325}
2326
2327fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2328 match find_value_by_dotted_path_ref(path, state)? {
2329 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2330 Cow::Owned(_) => None,
2331 }
2332}
2333
2334impl WebUIHandler {
2335 pub fn new() -> Self {
2337 Self {
2338 plugin_factory: None,
2339 }
2340 }
2341
2342 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2347 Self {
2348 plugin_factory: Some(factory),
2349 }
2350 }
2351
2352 #[cfg(test)]
2353 fn handle(
2354 &self,
2355 document: &WebUIProtocol,
2356 state: &Value,
2357 options: &RenderOptions<'_>,
2358 writer: &mut dyn ResponseWriter,
2359 ) -> Result<()> {
2360 let protocol = Protocol::new(document.clone());
2361 self.render(&protocol, state, options, writer)
2362 }
2363
2364 fn process_fragment_target<'data>(
2370 &self,
2371 target: Option<usize>,
2372 fragment_id: &str,
2373 context: &mut WebUIProcessContext<'data, '_, '_>,
2374 ) -> Result<()> {
2375 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2376 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2377 };
2378 let Some(fragment_list) = context.render_fragments.list(index) else {
2379 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2380 };
2381 self.process_fragment(fragment_list, context)
2382 }
2383
2384 fn process_fragment<'data>(
2389 &self,
2390 fragment_list: RenderFragmentList<'data>,
2391 context: &mut WebUIProcessContext<'data, '_, '_>,
2392 ) -> Result<()> {
2393 self.process_fragment_from(fragment_list, 0, context)
2394 }
2395
2396 fn process_fragment_from<'data>(
2397 &self,
2398 fragment_list: RenderFragmentList<'data>,
2399 start: usize,
2400 context: &mut WebUIProcessContext<'data, '_, '_>,
2401 ) -> Result<()> {
2402 let fragments = fragment_list.fragments;
2403 let best_route = if fragment_list.has_routes {
2409 route_renderer::find_best_route_match(
2410 fragments,
2411 context.request_path,
2412 &context.route_base,
2413 context.route_index,
2414 )
2415 } else {
2416 None
2417 };
2418 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2419 }
2420
2421 fn process_fragment_range<'data>(
2422 &self,
2423 fragment_list: RenderFragmentList<'data>,
2424 range: std::ops::Range<usize>,
2425 best_route: &Option<(String, route_matcher::RouteMatch)>,
2426 context: &mut WebUIProcessContext<'data, '_, '_>,
2427 ) -> Result<()> {
2428 let fragments = fragment_list.fragments;
2429 let Some(selected) = fragments.get(range.clone()) else {
2430 return Err(invalid_fragment_range_error(&range, fragments.len()));
2431 };
2432 for (offset, item) in selected.iter().enumerate() {
2433 let index = range.start + offset;
2434 if context.streaming.is_some() {
2435 validate_pending_streaming_root(item, context)?;
2436 validate_streaming_root_opening(&fragments[..index], item)?;
2437 }
2438 match item.fragment.as_ref() {
2442 Some(Fragment::Raw(raw)) => {
2443 context.writer.write(&raw.value)?;
2444 }
2445 Some(Fragment::Component(component)) => {
2446 self.process_component(
2447 &component.fragment_id,
2448 fragment_list.target(index),
2449 ComponentHostOrigin::ParserProduced,
2450 context,
2451 )?;
2452 }
2453 Some(Fragment::ForLoop(for_loop)) => {
2454 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2455 }
2456 Some(Fragment::Signal(signal)) => {
2457 self.process_signal(signal, context)?;
2458 }
2459 Some(Fragment::IfCond(if_cond)) => {
2460 self.process_if(if_cond, fragment_list.target(index), context)?;
2461 }
2462 Some(Fragment::Attribute(attr)) => {
2463 self.process_attribute(
2464 attr,
2465 fragment_list.target(index),
2466 fragment_list.component_attr_name(index),
2467 context,
2468 )?;
2469 }
2470 Some(Fragment::Plugin(plugin_frag)) => {
2471 if let Some(p) = &mut context.plugin {
2472 p.on_element_data(&plugin_frag.data, context.writer)?;
2473 }
2474 }
2475 Some(Fragment::Route(route_frag)) => {
2476 self.process_route(route_frag, best_route, context)?;
2477 }
2478 Some(Fragment::Outlet(_)) => {
2479 self.process_outlet(context)?;
2480 }
2481 Some(Fragment::Boundary(_)) => {}
2482 None => {}
2483 }
2484 }
2485 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2486 }
2487
2488 fn process_outlet<'protocol>(
2494 &self,
2495 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2496 ) -> Result<()> {
2497 let mut children = std::mem::take(&mut context.route_children);
2503 if children.is_empty() {
2504 return Ok(());
2505 }
2506
2507 let request_segments = route_matcher::split_request_path(context.request_path);
2509 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2510 for (idx, child) in children.iter().enumerate() {
2511 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2512 context.route_index,
2513 &child.path,
2514 &context.route_base,
2515 &request_segments,
2516 child.exact,
2517 ) {
2518 let is_better = best
2519 .as_ref()
2520 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2521 if is_better {
2522 best = Some((idx, m));
2523 }
2524 }
2525 }
2526
2527 let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2528 if let Some((idx, ref rm)) = best {
2529 route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2530 let descended = descend_into(&mut children, idx);
2531 let Some(matched_child) = children.get(idx) else {
2532 return Ok(());
2533 };
2534 let comp = &matched_child.fragment_id;
2535
2536 if !comp.is_empty() {
2537 let saved_route_base = (rm.consumed_segments > 0).then(|| {
2538 let base = route_matcher::compute_route_base(
2539 context.request_path,
2540 rm.consumed_segments,
2541 );
2542 std::mem::replace(&mut context.route_base, Cow::Owned(base))
2543 });
2544 context.route_children = descended;
2545
2546 context.writer.write("<webui-route")?;
2548 context.writer.write(" path=\"")?;
2549 context.writer.write(&matched_child.path)?;
2550 context.writer.write("\"")?;
2551 context.writer.write(" component=\"")?;
2552 context.writer.write(comp)?;
2553 context.writer.write("\"")?;
2554 if matched_child.exact {
2555 context.writer.write(" exact")?;
2556 }
2557 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2558 let ri = context.route_chain_index;
2560 context.route_chain_index += 1;
2561 context.writer.write(" data-ri=\"")?;
2562 write_usize(context.writer, ri)?;
2563 context.writer.write("\" active>")?;
2564
2565 if !Self::component_owns_css_tree(comp, context.protocol) {
2566 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2567 }
2568 if !matched_child.content_fragment_id.is_empty() {
2569 self.process_fragment_target(
2570 None,
2571 &matched_child.content_fragment_id,
2572 context,
2573 )?;
2574 }
2575
2576 context.writer.write("<")?;
2577 context.writer.write(comp)?;
2578 if let Some(p) = &context.plugin {
2579 p.write_route_component_state(context.state, context.writer)?;
2580 }
2581 write_interaction_marker(comp, context)?;
2582 prepare_generated_streaming_root(comp, context)?;
2583 context.writer.write(">")?;
2584
2585 self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2586
2587 context.writer.write("</")?;
2588 context.writer.write(comp)?;
2589 context.writer.write(">")?;
2590 context.writer.write("</webui-route>")?;
2591
2592 if let Some(saved) = saved_route_base {
2593 context.route_base = saved;
2594 }
2595 context.route_children = Cow::Borrowed(&[]);
2599 }
2600 }
2601
2602 route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2603 }
2604
2605 fn emit_css_module_importmap(
2616 &self,
2617 specifier: &str,
2618 css: &str,
2619 context: &mut WebUIProcessContext,
2620 ) -> Result<()> {
2621 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2622 context.writer.write(&tag)?;
2623 Ok(())
2624 }
2625
2626 fn emit_component_style_closure(
2628 &self,
2629 root: &str,
2630 install: StyleClosureInstall,
2631 context: &mut WebUIProcessContext,
2632 ) -> Result<()> {
2633 if context.protocol.style_closures.is_empty() {
2634 return Ok(());
2635 }
2636 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2637 match context.shadow_style_roots.last() {
2638 Some(shadow_root) => {
2639 if !shadow_root.static_closure_emitted {
2640 return Err(HandlerError::Invariant(
2641 "routed styles reached a ShadowRoot before its static style hook"
2642 .to_string(),
2643 ));
2644 }
2645 let root_name = context
2646 .component_index
2647 .iter()
2648 .find_map(|(name, index)| {
2649 (*index == shadow_root.component_index).then_some(name.as_str())
2650 })
2651 .ok_or_else(|| {
2652 HandlerError::Invariant(
2653 "active Shadow style root lost its protocol index".to_string(),
2654 )
2655 })?;
2656 Some(
2657 context
2658 .protocol
2659 .style_closures
2660 .get(root_name)
2661 .ok_or_else(|| {
2662 HandlerError::Invariant(format!(
2663 "component style closure metadata is missing Shadow root `{root_name}`"
2664 ))
2665 })?,
2666 )
2667 }
2668 None => None,
2669 }
2670 } else {
2671 None
2672 };
2673 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2674 HandlerError::Invariant(format!(
2675 "component style closure metadata is missing root `{root}`"
2676 ))
2677 })?;
2678 let is_document_tree = context.shadow_style_roots.is_empty();
2679 let strategy = context.css_strategy;
2680
2681 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2686 let mut emitted_resources = EmittedStyleUnits::new();
2687
2688 for position in 0..unit_count {
2689 let unit = context
2690 .protocol
2691 .style_closure_unit(closure, &context.style_chunk_index, position)
2692 .ok_or_else(|| {
2693 HandlerError::Invariant(format!(
2694 "component style closure `{root}` references out-of-range unit {position}"
2695 ))
2696 })?;
2697 let (name, chunk) = (unit.name, unit.chunk);
2698 if !emitted_resources.insert(name) {
2699 continue;
2700 }
2701 let resource = unit.resource.ok_or_else(|| match chunk {
2702 Some(index) => HandlerError::Invariant(format!(
2703 "component style closure `{root}` references missing style chunk {index}"
2704 )),
2705 None => HandlerError::Invariant(format!(
2706 "component style closure `{root}` references missing resource `{name}`"
2707 )),
2708 })?;
2709 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2710 continue;
2711 }
2712 if let Some(static_closure) = shadow_static_closure {
2713 let resource_index = match chunk {
2714 Some(index) => {
2715 if static_closure.style_chunks.contains(&index) {
2716 continue;
2717 }
2718 index
2719 }
2720 None => {
2721 if static_closure.component_tags.iter().any(|tag| tag == name) {
2722 continue;
2723 }
2724 context.component_index.get(name).copied().ok_or_else(|| {
2725 HandlerError::Invariant(format!(
2726 "component style resource `{name}` is missing its protocol index"
2727 ))
2728 })?
2729 }
2730 };
2731 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2732 HandlerError::Invariant(
2733 "active Shadow style root disappeared during routed style delivery"
2734 .to_string(),
2735 )
2736 })?;
2737 if shadow_root.routed_resources.contains(&resource_index) {
2738 continue;
2739 }
2740 shadow_root.routed_resources.push(resource_index);
2741 }
2742
2743 match strategy {
2744 webui_protocol::CssStrategy::Link => {
2745 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2746 context
2747 .writer
2748 .write(&crate::html_encode::encode_safe(resource))?;
2749 context.writer.write("\" data-webui-resource=\"")?;
2750 context
2751 .writer
2752 .write(&crate::html_encode::encode_safe(name))?;
2753 context.writer.write("\" data-webui-strategy=\"link\">")?;
2754 }
2755 strategy => {
2756 context.writer.write("<style")?;
2757 if let Some(nonce) = context.nonce {
2758 context.writer.write(" nonce=\"")?;
2759 context
2760 .writer
2761 .write(&crate::html_encode::encode_safe(nonce))?;
2762 context.writer.write("\"")?;
2763 }
2764 context.writer.write(" data-webui-resource=\"")?;
2765 context
2766 .writer
2767 .write(&crate::html_encode::encode_safe(name))?;
2768 context.writer.write("\" data-webui-strategy=\"")?;
2769 context
2770 .writer
2771 .write(if strategy == webui_protocol::CssStrategy::Module {
2772 "module"
2773 } else {
2774 "style"
2775 })?;
2776 context.writer.write("\">")?;
2777 if context.style_resources_requiring_escape.contains(name) {
2778 crate::html_encode::write_style_text(context.writer, resource)?;
2779 } else {
2780 context.writer.write(resource)?;
2781 }
2782 context.writer.write("</style>")?;
2783 }
2784 }
2785 }
2786 Ok(())
2787 }
2788
2789 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2790 if context.route_chain.is_none() {
2791 let plan = crate::route_handler::collect_route_chain_plan(
2792 context.protocol,
2793 context.entry_id,
2794 context.request_path,
2795 context.route_index,
2796 );
2797 context.route_document_style_targets = plan.document_style_targets;
2798 context.route_chain = Some(plan.entries);
2799 }
2800 }
2801
2802 fn emit_active_route_styles(
2803 &self,
2804 preloaded: &mut Vec<u32>,
2805 context: &mut WebUIProcessContext,
2806 ) -> Result<()> {
2807 Self::ensure_request_route_chain(context);
2808 let Some(chain) = context.route_chain.take() else {
2809 return Err(route_style_plan_missing_error());
2810 };
2811 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2812 if chain.len() != document_targets.len() {
2813 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2814 context.route_document_style_targets = document_targets;
2815 context.route_chain = Some(chain);
2816 return Err(error);
2817 }
2818
2819 let result = (|| {
2820 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2821 if *targets_document {
2822 self.emit_component_style_closure(
2823 &entry.component,
2824 StyleClosureInstall::Static,
2825 context,
2826 )?;
2827 continue;
2828 }
2829
2830 if context.css_strategy != webui_protocol::CssStrategy::Link {
2831 continue;
2832 }
2833 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2834 }
2835 Ok(())
2836 })();
2837
2838 context.route_document_style_targets = document_targets;
2839 context.route_chain = Some(chain);
2840 result
2841 }
2842
2843 fn emit_component_style_preloads(
2844 &self,
2845 root: &str,
2846 preloaded: &mut Vec<u32>,
2847 context: &mut WebUIProcessContext,
2848 ) -> Result<()> {
2849 let Some(closure) = context.protocol.style_closures.get(root) else {
2850 return Ok(());
2851 };
2852 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2854 for position in 0..unit_count {
2855 let Some(unit) =
2856 context
2857 .protocol
2858 .style_closure_unit(closure, &context.style_chunk_index, position)
2859 else {
2860 continue;
2861 };
2862 let Some(href) = unit.resource else {
2863 continue;
2864 };
2865 let name = unit.name;
2866 if context.document_style_resources.contains(name) {
2867 continue;
2868 }
2869 let Some(&resource_index) = context.style_resource_index.get(name) else {
2870 continue;
2871 };
2872 if preloaded.contains(&resource_index) {
2873 continue;
2874 }
2875 context
2876 .writer
2877 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2878 context
2879 .writer
2880 .write(&crate::html_encode::encode_safe(href))?;
2881 context.writer.write("\">")?;
2882 preloaded.push(resource_index);
2883 }
2884 Ok(())
2885 }
2886
2887 fn emit_reachable_shadow_preloads(
2888 &self,
2889 preloaded: &mut Vec<u32>,
2890 context: &mut WebUIProcessContext,
2891 ) -> Result<()> {
2892 if context.css_strategy != webui_protocol::CssStrategy::Link {
2893 return Ok(());
2894 }
2895 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2896 crate::route_handler::collect_reachable_component_order_for_request(
2897 context.protocol,
2898 context.entry_id,
2899 context.request_path,
2900 context.route_index,
2901 )
2902 });
2903 let result = (|| {
2904 for component in &reachable {
2905 if context.protocol.component_uses_shadow_dom(component) {
2906 self.emit_component_style_preloads(component, preloaded, context)?;
2907 }
2908 }
2909 Ok(())
2910 })();
2911 context.reachable_components = Some(reachable);
2912 result
2913 }
2914
2915 fn process_shadow_style_signal(
2916 &self,
2917 root: &str,
2918 context: &mut WebUIProcessContext,
2919 ) -> Result<()> {
2920 let Some(root_index) = context.component_index.get(root).copied() else {
2921 return Err(HandlerError::Invariant(format!(
2922 "Shadow style hook `{root}` references an unknown component"
2923 )));
2924 };
2925 if context
2926 .shadow_style_roots
2927 .last()
2928 .map(|shadow_root| shadow_root.component_index)
2929 != Some(root_index)
2930 {
2931 return Err(HandlerError::Invariant(format!(
2932 "Shadow style hook `{root}` does not match the active component root"
2933 )));
2934 }
2935 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2936 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2937 HandlerError::Invariant(
2938 "active Shadow style root disappeared after its static style hook".to_string(),
2939 )
2940 })?;
2941 shadow_root.static_closure_emitted = true;
2942 Ok(())
2943 }
2944
2945 fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2954 if context.css_strategy != webui_protocol::CssStrategy::Module {
2955 return Ok(());
2956 }
2957 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2958 streaming_template_already_sent(streaming, context.component_index, fragment_id)
2959 });
2960 if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2961 if let Some(css) = context
2962 .protocol
2963 .components
2964 .get(fragment_id)
2965 .map(|c| c.css.as_str())
2966 .filter(|s| !s.is_empty())
2967 {
2968 self.emit_css_module_importmap(fragment_id, css, context)?;
2969 }
2970 }
2971 Ok(())
2972 }
2973
2974 fn process_route<'protocol>(
2976 &self,
2977 route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2978 best_route: &Option<(String, route_matcher::RouteMatch)>,
2979 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2980 ) -> Result<()> {
2981 let is_matched = best_route
2982 .as_ref()
2983 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2984
2985 context.writer.write("<webui-route")?;
2986 context.writer.write(" path=\"")?;
2987 context.writer.write(&route_frag.path)?;
2988 context.writer.write("\"")?;
2989 if !route_frag.fragment_id.is_empty() {
2990 context.writer.write(" component=\"")?;
2991 context.writer.write(&route_frag.fragment_id)?;
2992 context.writer.write("\"")?;
2993 }
2994 if route_frag.exact {
2995 context.writer.write(" exact")?;
2996 }
2997 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2998
2999 if is_matched {
3000 let ri = context.route_chain_index;
3002 context.route_chain_index += 1;
3003 context.writer.write(" data-ri=\"")?;
3004 write_usize(context.writer, ri)?;
3005 context.writer.write("\" active>")?;
3006
3007 let saved_route_base = best_route.as_ref().map(|(_, rm)| {
3008 let base =
3009 route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
3010 std::mem::replace(&mut context.route_base, Cow::Owned(base))
3011 });
3012 let saved_route_children = std::mem::replace(
3013 &mut context.route_children,
3014 Cow::Borrowed(&route_frag.children),
3015 );
3016
3017 if !route_frag.content_fragment_id.is_empty() {
3018 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
3019 }
3020
3021 if !route_frag.fragment_id.is_empty() {
3022 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
3023 self.emit_component_style_closure(
3024 &route_frag.fragment_id,
3025 StyleClosureInstall::Routed,
3026 context,
3027 )?;
3028 }
3029 context.writer.write("<")?;
3030 context.writer.write(&route_frag.fragment_id)?;
3031 if let Some(p) = &context.plugin {
3032 p.write_route_component_state(context.state, context.writer)?;
3033 }
3034 write_interaction_marker(&route_frag.fragment_id, context)?;
3035 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
3036 context.writer.write(">")?;
3037
3038 self.process_component(
3039 &route_frag.fragment_id,
3040 None,
3041 ComponentHostOrigin::HandlerGenerated,
3042 context,
3043 )?;
3044
3045 context.writer.write("</")?;
3046 context.writer.write(&route_frag.fragment_id)?;
3047 context.writer.write(">")?;
3048 }
3049 if let Some(saved) = saved_route_base {
3050 context.route_base = saved;
3051 }
3052 context.route_children = saved_route_children;
3053 } else {
3054 context.writer.write(" style=\"display:none\">")?;
3055 }
3056
3057 context.writer.write("</webui-route>")?;
3058 Ok(())
3059 }
3060
3061 fn process_component(
3063 &self,
3064 fragment_id: &str,
3065 target: Option<usize>,
3066 origin: ComponentHostOrigin,
3067 context: &mut WebUIProcessContext,
3068 ) -> Result<()> {
3069 if context.streaming.is_some() {
3070 consume_streaming_component_root(fragment_id, origin, context)?;
3071 record_checkpoint_tag(context, fragment_id);
3074 }
3075
3076 if !context.rendered_components.contains(fragment_id) {
3082 self.emit_css_module(fragment_id, context)?;
3083 context.rendered_components.insert(fragment_id.to_string());
3084 }
3085
3086 let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
3087 if owns_css_tree {
3088 Self::push_shadow_style_root(fragment_id, context)?;
3089 }
3090
3091 let saved_local_vars = std::mem::take(&mut context.local_vars);
3093 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
3094 let saved_component_attrs = std::mem::replace(
3098 &mut context.component_attrs,
3099 take_scope_map(&mut context.scope_pool),
3100 );
3101 let saved_component_borrowed_attrs = std::mem::replace(
3102 &mut context.component_borrowed_attrs,
3103 take_borrowed_scope(&mut context.borrowed_scope_pool),
3104 );
3105 context.local_vars = saved_component_attrs;
3106 context.local_borrowed_vars = saved_component_borrowed_attrs;
3107 context.collecting_component_attrs = false;
3108 let saved_loop_scope = context.visible_loop_scope;
3109 context.visible_loop_scope = VisibleLoopScope {
3110 start: context.loop_vars.len(),
3111 end: context.loop_vars.len(),
3112 };
3113
3114 if let Some(p) = &mut context.plugin {
3115 p.push_scope();
3116 }
3117
3118 let render_result = self.process_fragment_target(target, fragment_id, context);
3119 context.visible_loop_scope = saved_loop_scope;
3120
3121 if owns_css_tree {
3122 Self::pop_shadow_style_root(fragment_id, context)?;
3123 }
3124 render_result?;
3125
3126 if let Some(p) = &mut context.plugin {
3127 p.pop_scope();
3128 }
3129
3130 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3133 recycle_scope_map(&mut context.scope_pool, used_locals);
3134 let used_borrowed_locals =
3135 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3136 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3137 context.component_attrs.clear();
3140 context.component_borrowed_attrs.clear();
3141
3142 Ok(())
3143 }
3144
3145 #[inline]
3146 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3147 !protocol.style_closures.is_empty()
3148 && protocol
3149 .components
3150 .get(component)
3151 .is_some_and(|data| data.uses_shadow_dom)
3152 }
3153
3154 pub(crate) fn push_shadow_style_root(
3155 component: &str,
3156 context: &mut WebUIProcessContext,
3157 ) -> Result<()> {
3158 if !context.protocol.style_closures.contains_key(component) {
3159 return Err(HandlerError::Invariant(format!(
3160 "component style closure metadata is missing Shadow root `{component}`"
3161 )));
3162 }
3163 let root_index = context
3164 .component_index
3165 .get(component)
3166 .copied()
3167 .ok_or_else(|| {
3168 HandlerError::Invariant(format!(
3169 "Shadow component `{component}` is missing its protocol index"
3170 ))
3171 })?;
3172 context.shadow_style_roots.push(ShadowStyleRoot {
3173 component_index: root_index,
3174 static_closure_emitted: false,
3175 routed_resources: Vec::new(),
3176 });
3177 Ok(())
3178 }
3179
3180 pub(crate) fn pop_shadow_style_root(
3181 component: &str,
3182 context: &mut WebUIProcessContext,
3183 ) -> Result<()> {
3184 context.shadow_style_roots.pop().ok_or_else(|| {
3185 HandlerError::Invariant(format!(
3186 "Shadow component `{component}` lost its active style root"
3187 ))
3188 })?;
3189 Ok(())
3190 }
3191
3192 fn resolve_value_owned(
3194 &self,
3195 path: &str,
3196 context: &WebUIProcessContext<'_, '_, '_>,
3197 ) -> Option<Value> {
3198 resolve_value_from_sources(
3199 path,
3200 &context.loop_vars,
3201 context.visible_loop_scope,
3202 LocalValueSources {
3203 owned: &context.local_vars,
3204 borrowed: &context.local_borrowed_vars,
3205 },
3206 context.state,
3207 )
3208 .map(Cow::into_owned)
3209 }
3210
3211 fn evaluate_condition(
3218 &self,
3219 condition: &webui_protocol::ConditionExpr,
3220 context: &WebUIProcessContext,
3221 ) -> Result<bool> {
3222 let loop_vars = &context.loop_vars;
3223 let visible_loop_scope = context.visible_loop_scope;
3224 let local_values = LocalValueSources {
3225 owned: &context.local_vars,
3226 borrowed: &context.local_borrowed_vars,
3227 };
3228 let state = context.state;
3229 match evaluate_with_resolver(condition, |path| {
3230 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3231 }) {
3232 Ok(result) => Ok(result),
3233 Err(ExpressionError::MissingValue(_)) => Ok(false),
3234 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3235 }
3236 }
3237
3238 fn process_for_loop<'protocol, 'state>(
3244 &self,
3245 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3246 target: Option<usize>,
3247 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3248 ) -> Result<()> {
3249 if let Some(items) = resolve_borrowed_collection(
3250 &for_loop.collection,
3251 &context.loop_vars,
3252 context.visible_loop_scope,
3253 LocalValueSources {
3254 owned: &context.local_vars,
3255 borrowed: &context.local_borrowed_vars,
3256 },
3257 context.state,
3258 ) {
3259 return self.process_borrowed_for_loop(for_loop, target, items, context);
3260 }
3261 self.process_owned_for_loop(for_loop, target, context)
3262 }
3263
3264 fn process_borrowed_for_loop<'protocol, 'state>(
3265 &self,
3266 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3267 target: Option<usize>,
3268 items: &'state [Value],
3269 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3270 ) -> Result<()> {
3271 if let Some(plugin) = &mut context.plugin {
3272 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3273 }
3274
3275 let item_name = for_loop.item.as_str();
3276 let saved_value = context.local_vars.remove(item_name);
3277 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3278 let saved_scope = context.visible_loop_scope;
3279 for (index, item) in items.iter().enumerate() {
3280 if let Some(plugin) = &mut context.plugin {
3281 plugin.on_repeat_item_start(index, context.writer)?;
3282 plugin.push_scope();
3283 }
3284
3285 context.loop_vars.push(LoopBinding {
3286 name: item_name,
3287 value: item,
3288 });
3289 context.visible_loop_scope.end = context.loop_vars.len();
3290 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3291 context.loop_vars.pop();
3292 context.visible_loop_scope = saved_scope;
3293
3294 if let Some(plugin) = &mut context.plugin {
3295 plugin.pop_scope();
3296 plugin.on_repeat_item_end(index, context.writer)?;
3297 }
3298 }
3299 if let Some(value) = saved_value {
3300 context.local_vars.insert(item_name.to_string(), value);
3301 }
3302 if let Some(value) = saved_borrowed_value {
3303 context.local_borrowed_vars.insert(item_name, value);
3304 }
3305
3306 if let Some(plugin) = &mut context.plugin {
3307 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3308 }
3309 Ok(())
3310 }
3311
3312 fn process_owned_for_loop<'protocol, 'state>(
3313 &self,
3314 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3315 target: Option<usize>,
3316 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3317 ) -> Result<()> {
3318 let collection_name = &for_loop.collection;
3319
3320 let items = match self.resolve_value_owned(collection_name, context) {
3323 Some(Value::Array(arr)) => arr,
3324 Some(_) => {
3325 return Err(HandlerError::TypeError(format!(
3326 "Collection '{}' is not an array",
3327 collection_name
3328 )))
3329 }
3330 None => Vec::new(),
3331 };
3332
3333 if let Some(p) = &mut context.plugin {
3334 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3335 }
3336
3337 let item_name = for_loop.item.as_str();
3345 let saved_value = context.local_vars.remove(item_name);
3346 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3347 if !items.is_empty() {
3351 context
3352 .local_vars
3353 .insert(item_name.to_string(), Value::Null);
3354 }
3355 for (i, item) in items.into_iter().enumerate() {
3356 if let Some(p) = &mut context.plugin {
3357 p.on_repeat_item_start(i, context.writer)?;
3358 p.push_scope();
3359 }
3360
3361 if let Some(slot) = context.local_vars.get_mut(item_name) {
3363 *slot = item;
3364 }
3365 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3366
3367 if let Some(p) = &mut context.plugin {
3368 p.pop_scope();
3369 p.on_repeat_item_end(i, context.writer)?;
3370 }
3371 }
3372 match saved_value {
3374 Some(value) => {
3375 context.local_vars.insert(item_name.to_string(), value);
3376 }
3377 None => {
3378 context.local_vars.remove(item_name);
3379 }
3380 }
3381 if let Some(value) = saved_borrowed_value {
3382 context.local_borrowed_vars.insert(item_name, value);
3383 }
3384
3385 if let Some(p) = &mut context.plugin {
3386 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3387 }
3388
3389 Ok(())
3390 }
3391
3392 #[inline]
3398 fn process_signal<'data>(
3399 &self,
3400 signal: &'data webui_protocol::WebUIFragmentSignal,
3401 context: &mut WebUIProcessContext<'data, '_, '_>,
3402 ) -> Result<()> {
3403 if signal.raw {
3404 self.process_raw_signal(signal, context)
3405 } else {
3406 self.process_state_signal(signal, false, context)
3407 }
3408 }
3409
3410 #[inline(never)]
3411 fn process_raw_signal<'data>(
3412 &self,
3413 signal: &'data webui_protocol::WebUIFragmentSignal,
3414 context: &mut WebUIProcessContext<'data, '_, '_>,
3415 ) -> Result<()> {
3416 let Some(structural_value) = structural_signal_value(signal) else {
3417 return self.process_state_signal(signal, !signal.raw_text_context, context);
3418 };
3419
3420 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3421 return self.process_shadow_style_signal(root, context);
3422 }
3423
3424 if context.streaming.is_some()
3425 && self.process_streaming_signal(structural_value, context)?
3426 {
3427 return Ok(());
3428 }
3429
3430 if structural_value == "head_end" && !context.head_end_emitted {
3434 context.head_end_emitted = true;
3435 if let Some(nonce) = context.nonce {
3436 context
3437 .writer
3438 .write("<meta name=\"webui-nonce\" content=\"")?;
3439 context
3440 .writer
3441 .write(&crate::html_encode::encode_safe(nonce))?;
3442 context.writer.write("\">")?;
3443 }
3444
3445 if !context.protocol.component_render_css.is_empty() {
3448 context.writer.write("<style data-webui-render-policy")?;
3449 if let Some(nonce) = context.nonce {
3450 context.writer.write(" nonce=\"")?;
3451 context
3452 .writer
3453 .write(&crate::html_encode::encode_safe(nonce))?;
3454 context.writer.write("\"")?;
3455 }
3456 context.writer.write(">")?;
3457 context
3458 .writer
3459 .write(&context.protocol.component_render_css)?;
3460 context.writer.write("</style>")?;
3461 }
3462
3463 if !context.protocol.style_closures.is_empty() {
3464 let entry_id = context.entry_id;
3465 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3466 let mut preloaded = Vec::new();
3467 self.emit_active_route_styles(&mut preloaded, context)?;
3468 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3469 }
3470
3471 if !context.protocol.module_preloads.is_empty() {
3483 for href in &context.protocol.module_preloads {
3484 context
3485 .writer
3486 .write("<link rel=\"modulepreload\" href=\"")?;
3487 context.writer.write(href)?;
3488 context.writer.write("\">")?;
3489 }
3490 }
3491
3492 write_component_asset_styles(context)?;
3493
3494 if let Some(html) = context.head_inject {
3500 context.writer.write(html)?;
3501 }
3502
3503 if let Some(html) = context.state_inject.head_end {
3507 context.writer.write(html)?;
3508 }
3509 }
3510
3511 if structural_value == "body_start" && !context.body_start_emitted {
3514 context.body_start_emitted = true;
3515 write_component_asset_styles(context)?;
3516 if let Some(html) = context.state_inject.body_start {
3517 context.writer.write(html)?;
3518 }
3519 }
3520
3521 if structural_value == "body_end" && !context.body_end_emitted {
3526 context.body_end_emitted = true;
3527 if context.plugin.is_some() {
3528 let reachable = context.reachable_components.take().unwrap_or_else(|| {
3541 crate::route_handler::collect_reachable_component_order_for_request(
3542 context.protocol,
3543 context.entry_id,
3544 context.request_path,
3545 context.route_index,
3546 )
3547 });
3548 let state_selection =
3549 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3550
3551 if context.css_strategy == webui_protocol::CssStrategy::Module {
3555 for name in &reachable {
3556 if !context.rendered_components.contains(name) {
3557 if let Some(css) = context
3558 .protocol
3559 .components
3560 .get(name)
3561 .map(|c| c.css.as_str())
3562 .filter(|s| !s.is_empty())
3563 {
3564 self.emit_css_module_importmap(name, css, context)?;
3565 }
3566 }
3567 }
3568 }
3569
3570 let template_payloads = context
3574 .plugin
3575 .as_ref()
3576 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3577
3578 if template_payloads.is_none() {
3579 if let Some(ref p) = context.plugin {
3581 p.emit_templates(
3582 context.protocol,
3583 &reachable,
3584 context.nonce,
3585 context.writer,
3586 )?;
3587 }
3588 }
3589
3590 let inventory_hex = crate::route_handler::encode_component_inventory(
3592 &context.rendered_components,
3593 context.component_index,
3594 );
3595
3596 Self::ensure_request_route_chain(context);
3598 let chain = context.route_chain.as_deref().ok_or_else(|| {
3599 HandlerError::Invariant(
3600 "request route chain disappeared after collection".to_string(),
3601 )
3602 })?;
3603 let chain_json: Vec<Value> = chain
3604 .iter()
3605 .map(crate::route_handler::RouteChainEntry::to_json)
3606 .collect();
3607
3608 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3610 let mut css_hrefs: Vec<&str> = Vec::new();
3611 if is_link {
3612 for name in &reachable {
3613 if let Some(href) = context
3614 .protocol
3615 .components
3616 .get(name)
3617 .map(|c| c.css_href.as_str())
3618 .filter(|h| !h.is_empty())
3619 {
3620 css_hrefs.push(href);
3621 }
3622 }
3623 }
3624
3625 let mut style_specs: Vec<&str> = Vec::new();
3627 if context.css_strategy == webui_protocol::CssStrategy::Module {
3628 for name in &reachable {
3629 if context
3630 .protocol
3631 .components
3632 .get(name)
3633 .map(|c| !c.css.is_empty())
3634 .unwrap_or(false)
3635 {
3636 style_specs.push(name);
3637 }
3638 }
3639 }
3640
3641 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3642 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3643 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3644 style_roots.push(context.entry_id);
3645 for entry in chain {
3646 style_roots.push(entry.component.as_str());
3647 if !entry.pending_component.is_empty() {
3648 style_roots.push(entry.pending_component.as_str());
3649 }
3650 if !entry.error_component.is_empty() {
3651 style_roots.push(entry.error_component.as_str());
3652 }
3653 }
3654 style_roots.extend(reachable.iter().map(String::as_str));
3655 let component_styles =
3656 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3657 write_webui_data_block(
3658 context.writer,
3659 &mut context.json_scratch,
3660 WebUiBootstrap {
3661 declaration_id: None,
3662 enclosing_span_instance_id: None,
3663 state: WebUiBootstrapState::Complete {
3664 value: context.state,
3665 selection: state_selection,
3666 },
3667 chain: &chain_json,
3668 inventory: &inventory_hex,
3669 nonce: context.nonce,
3670 css_hrefs: &css_hrefs,
3671 style_specs: &style_specs,
3672 component_styles: &component_styles,
3673 templates: payloads,
3674 },
3675 )?;
3676
3677 if let Some(ref plugin) = context.plugin {
3681 plugin.emit_bootstrap_extension(
3682 BootstrapExtensionContext {
3683 protocol: context.protocol,
3684 components: &reachable,
3685 payloads,
3686 nonce: context.nonce,
3687 },
3688 context.writer,
3689 )?;
3690 }
3691 }
3692
3693 if let Some(html) = context.body_inject {
3700 context.writer.write(html)?;
3701 }
3702
3703 if let Some(html) = context.state_inject.body_end {
3706 context.writer.write(html)?;
3707 }
3708 }
3709
3710 Ok(())
3714 }
3715
3716 #[inline]
3728 fn process_state_signal(
3729 &self,
3730 signal: &webui_protocol::WebUIFragmentSignal,
3731 owns_html_range: bool,
3732 context: &mut WebUIProcessContext,
3733 ) -> Result<()> {
3734 if let Some(p) = &mut context.plugin {
3735 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3736 }
3737
3738 if let Some(value) = resolve_value_from_sources(
3739 &signal.value,
3740 &context.loop_vars,
3741 context.visible_loop_scope,
3742 LocalValueSources {
3743 owned: &context.local_vars,
3744 borrowed: &context.local_borrowed_vars,
3745 },
3746 context.state,
3747 ) {
3748 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3749 }
3750
3751 if let Some(p) = &mut context.plugin {
3752 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3753 }
3754 Ok(())
3755 }
3756
3757 fn write_signal_value(
3766 &self,
3767 value: &Value,
3768 raw: bool,
3769 writer: &mut dyn ResponseWriter,
3770 ) -> Result<()> {
3771 if write_plain_json_scalar(writer, value)? {
3774 return Ok(());
3775 }
3776 if raw {
3777 match value {
3778 Value::String(s) => writer.write(s),
3779 _ => writer.write(&value.to_string()),
3780 }
3781 } else {
3782 match value {
3783 Value::String(s) => writer.write_escaped(s),
3784 _ => {
3785 let s = value.to_string();
3786 writer.write_escaped(&s)
3787 }
3788 }
3789 }
3790 }
3791
3792 fn process_if(
3794 &self,
3795 if_cond: &webui_protocol::WebUIFragmentIf,
3796 target: Option<usize>,
3797 context: &mut WebUIProcessContext,
3798 ) -> Result<()> {
3799 let condition = if_cond
3800 .condition
3801 .as_ref()
3802 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3803 let condition_met = self.evaluate_condition(condition, context)?;
3804
3805 if let Some(p) = &mut context.plugin {
3806 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3807 }
3808
3809 if condition_met {
3810 if let Some(p) = &mut context.plugin {
3811 p.push_scope();
3812 }
3813
3814 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3815
3816 if let Some(p) = &mut context.plugin {
3817 p.pop_scope();
3818 }
3819 }
3820
3821 if let Some(p) = &mut context.plugin {
3822 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3823 }
3824
3825 Ok(())
3826 }
3827
3828 fn process_attribute<'protocol, 'state>(
3834 &self,
3835 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3836 template_target: Option<usize>,
3837 component_name: Option<&'protocol str>,
3838 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3839 ) -> Result<()> {
3840 if attr.attr_start {
3843 context.component_attrs.clear();
3844 context.component_borrowed_attrs.clear();
3845 context.collecting_component_attrs = true;
3846 }
3847
3848 if let Some(condition) = &attr.condition_tree {
3850 let condition_met = self.evaluate_condition(condition, context)?;
3851
3852 if context.collecting_component_attrs && !attr.attr_skip {
3853 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3854 context.component_borrowed_attrs.remove(name);
3855 context
3856 .component_attrs
3857 .insert(name.to_owned(), Value::Bool(condition_met));
3858 }
3859
3860 if condition_met {
3861 context.writer.write(" ")?;
3862 context.writer.write(&attr.name)?;
3863 }
3864 return Ok(());
3865 }
3866
3867 if !attr.template.is_empty() {
3869 if !context.collecting_component_attrs || attr.attr_skip {
3870 return self.write_template_attribute(attr, template_target, context);
3871 }
3872 let raw_value =
3873 self.render_template_attr_value(&attr.template, template_target, context, None)?;
3874 context
3875 .writer
3876 .write_escaped_attribute(&attr.name, &raw_value)?;
3877
3878 if context.collecting_component_attrs && !attr.attr_skip {
3879 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3880 context.component_borrowed_attrs.remove(name);
3881 context
3882 .component_attrs
3883 .insert(name.to_owned(), Value::String(raw_value));
3884 }
3885 return Ok(());
3886 }
3887
3888 if attr.raw_value || !attr.value.is_empty() {
3890 if attr.raw_value {
3891 context
3893 .writer
3894 .write_escaped_attribute(&attr.name, &attr.value)?;
3895 if context.collecting_component_attrs && !attr.attr_skip {
3896 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3897 context.component_borrowed_attrs.remove(name);
3898 context
3899 .component_attrs
3900 .insert(name.to_owned(), Value::String(attr.value.clone()));
3901 }
3902 } else if attr.complex {
3903 if context.collecting_component_attrs && !attr.attr_skip {
3905 let state_backed_value = resolve_state_backed_value(
3910 &attr.value,
3911 &context.loop_vars,
3912 context.visible_loop_scope,
3913 LocalValueSources {
3914 owned: &context.local_vars,
3915 borrowed: &context.local_borrowed_vars,
3916 },
3917 context.state,
3918 );
3919 if let Some(value) = state_backed_value {
3920 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3921 remove_owned_component_attr(&mut context.component_attrs, name);
3922 context.component_borrowed_attrs.insert(name, value);
3923 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3924 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3925 context.component_borrowed_attrs.remove(name);
3926 context.component_attrs.insert(name.to_owned(), value);
3927 }
3928 }
3929 } else {
3930 let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3934 resolve_state_backed_value(
3935 &attr.value,
3936 &context.loop_vars,
3937 context.visible_loop_scope,
3938 LocalValueSources {
3939 owned: &context.local_vars,
3940 borrowed: &context.local_borrowed_vars,
3941 },
3942 context.state,
3943 )
3944 } else {
3945 None
3946 };
3947 let value = match state_backed_value {
3950 Some(value) => Some(Cow::Borrowed(value)),
3951 None => resolve_value_from_sources(
3952 &attr.value,
3953 &context.loop_vars,
3954 context.visible_loop_scope,
3955 LocalValueSources {
3956 owned: &context.local_vars,
3957 borrowed: &context.local_borrowed_vars,
3958 },
3959 context.state,
3960 ),
3961 };
3962 match value.as_deref() {
3963 Some(Value::String(s)) => {
3964 context.writer.write_escaped_attribute(&attr.name, s)?;
3965 }
3966 Some(Value::Null) | None => {}
3967 Some(other) => {
3968 let mut scalar = ScalarBuffer::new();
3969 match format_plain_json_scalar(&mut scalar, other) {
3970 Some(rendered) => {
3971 write_attr(context.writer, &attr.name, rendered)?;
3972 }
3973 None => {
3974 let s = other.to_string();
3975 context.writer.write_escaped_attribute(&attr.name, &s)?;
3976 }
3977 }
3978 }
3979 }
3980
3981 if context.collecting_component_attrs && !attr.attr_skip {
3982 if let Some(borrowed) = state_backed_value {
3983 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3984 remove_owned_component_attr(&mut context.component_attrs, name);
3985 context.component_borrowed_attrs.insert(name, borrowed);
3986 } else {
3987 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3988 context.component_borrowed_attrs.remove(name);
3989 context.component_attrs.insert(
3990 name.to_owned(),
3991 value
3992 .map(Cow::into_owned)
3993 .unwrap_or(Value::String(String::new())),
3994 );
3995 }
3996 }
3997 }
3998 }
3999
4000 Ok(())
4001 }
4002
4003 fn write_template_attribute(
4004 &self,
4005 attr: &webui_protocol::WebUIFragmentAttribute,
4006 target: Option<usize>,
4007 context: &mut WebUIProcessContext,
4008 ) -> Result<()> {
4009 let mut scratch = std::mem::take(&mut context.json_scratch);
4010 let previous_capacity = scratch.capacity();
4011 scratch.clear();
4012 if scratch.capacity() < TEMPLATE_SIGNAL_CAPACITY_HINT * 2 {
4013 scratch.reserve(TEMPLATE_SIGNAL_CAPACITY_HINT * 2);
4014 }
4015 let buffer = String::from_utf8(scratch).map_err(invalid_template_buffer_error)?;
4016 let value =
4017 self.render_template_attr_value(&attr.template, target, context, Some(buffer))?;
4018 let result = context.writer.write_escaped_attribute(&attr.name, &value);
4019 context.json_scratch = recycle_template_buffer(value, previous_capacity);
4020 result
4021 }
4022
4023 fn render_template_attr_value(
4025 &self,
4026 template_id: &str,
4027 target: Option<usize>,
4028 context: &WebUIProcessContext,
4029 buffer: Option<String>,
4030 ) -> Result<String> {
4031 let fragments = template_attr_fragments(template_id, target, &context.render_fragments)?;
4032 let mut raw_value = buffer
4033 .unwrap_or_else(|| String::with_capacity(template_attr_capacity(fragments.fragments)));
4034 raw_value.clear();
4035 for frag in fragments.fragments {
4036 match frag.fragment.as_ref() {
4037 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4038 Some(Fragment::Signal(signal)) => {
4039 if let Some(value) = resolve_value_from_sources(
4040 &signal.value,
4041 &context.loop_vars,
4042 context.visible_loop_scope,
4043 LocalValueSources {
4044 owned: &context.local_vars,
4045 borrowed: &context.local_borrowed_vars,
4046 },
4047 context.state,
4048 ) {
4049 match value.as_ref() {
4050 Value::String(s) => raw_value.push_str(s),
4051 other => {
4052 let mut scalar = ScalarBuffer::new();
4053 match format_plain_json_scalar(&mut scalar, other) {
4054 Some(rendered) => raw_value.push_str(rendered),
4055 None => raw_value.push_str(&other.to_string()),
4056 }
4057 }
4058 }
4059 }
4060 }
4061 _ => {}
4062 }
4063 }
4064 Ok(raw_value)
4065 }
4066
4067 pub fn render<'a>(
4069 &self,
4070 protocol: &'a Protocol,
4071 state: &'a Value,
4072 options: &RenderOptions<'a>,
4073 writer: &'a mut dyn ResponseWriter,
4074 ) -> Result<()> {
4075 protocol.ensure_style_metadata()?;
4076 let document = protocol.protocol();
4077 let render_fragments = protocol.render_fragments().resolve(document);
4078 let Some(entry_index) = render_fragments.index(options.entry_id) else {
4079 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4080 };
4081 let entry = render_fragments
4082 .list(entry_index)
4083 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4084 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4085 let has_document_head_boundary = !entry_owns_css_tree
4086 && !document.style_closures.is_empty()
4087 && entry.fragments.iter().any(|fragment| {
4088 matches!(
4089 fragment.fragment.as_ref(),
4090 Some(Fragment::Signal(signal))
4091 if structural_signal_value(signal) == Some("head_end")
4092 )
4093 });
4094 let doctype_split = (!entry_owns_css_tree
4095 && !has_document_head_boundary
4096 && !document.style_closures.is_empty())
4097 .then(|| {
4098 let Some(Fragment::Raw(raw)) = entry
4099 .fragments
4100 .first()
4101 .and_then(|fragment| fragment.fragment.as_ref())
4102 else {
4103 return None;
4104 };
4105 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4106 })
4107 .flatten();
4108 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4109 let mut context = WebUIProcessContext {
4110 protocol: document,
4111 render_fragments,
4112 component_asset_style_manifest,
4113 component_asset_style_links: protocol.component_asset_style_links(),
4114 state,
4115 writer,
4116 local_vars: ScopeMap::default(),
4117 local_borrowed_vars: BorrowedScope::default(),
4118 loop_vars: Vec::new(),
4119 visible_loop_scope: VisibleLoopScope::EMPTY,
4120 component_attrs: ScopeMap::default(),
4121 component_borrowed_attrs: BorrowedScope::default(),
4122 collecting_component_attrs: false,
4123 request_path: options.request_path,
4124 route_base: Cow::Borrowed("/"),
4125 rendered_components: HashSet::new(),
4126 plugin: self.plugin_factory.map(|f| f()),
4127 route_children: Cow::Borrowed(&[]),
4128 entry_id: options.entry_id,
4129 nonce: options.nonce.filter(|s| !s.is_empty()),
4132 head_inject: options.head_inject.filter(|s| !s.is_empty()),
4133 body_inject: options.body_inject.filter(|s| !s.is_empty()),
4134 state_inject: StateInject::resolve(state),
4135 head_end_emitted: false,
4136 body_start_emitted: false,
4137 component_asset_styles_emitted: false,
4138 component_index: protocol.component_index(),
4139 style_resource_index: protocol.style_resource_index(),
4140 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4141 style_chunk_index: protocol.protocol().style_chunk_index(),
4142 css_strategy: protocol.css_strategy(),
4143 body_end_emitted: false,
4144 route_index: protocol.route_index(),
4145 route_chain_index: 0,
4146 route_chain: None,
4147 route_document_style_targets: Vec::new(),
4148 reachable_components: None,
4149 streaming: None,
4150 json_scratch: Vec::new(),
4151 scope_pool: Vec::new(),
4152 document_style_resources: HashSet::new(),
4153 shadow_style_roots: Vec::new(),
4154 borrowed_scope_pool: Vec::new(),
4155 };
4156
4157 if entry_owns_css_tree {
4158 Self::push_shadow_style_root(options.entry_id, &mut context)?;
4159 }
4160
4161 let render_result = if let Some((first_raw, split)) = doctype_split {
4162 context.writer.write(&first_raw[..split])?;
4163 self.emit_component_style_closure(
4164 options.entry_id,
4165 StyleClosureInstall::Static,
4166 &mut context,
4167 )?;
4168 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4169 context.writer.write(&first_raw[split..])?;
4170 self.process_fragment_from(entry, 1, &mut context)
4171 } else {
4172 if !entry_owns_css_tree
4173 && !has_document_head_boundary
4174 && !document.style_closures.is_empty()
4175 {
4176 self.emit_component_style_closure(
4177 options.entry_id,
4178 StyleClosureInstall::Static,
4179 &mut context,
4180 )?;
4181 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4182 }
4183 self.process_fragment(entry, &mut context)
4184 };
4185
4186 if entry_owns_css_tree {
4187 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4188 }
4189 render_result?;
4190 writer.end()?;
4191
4192 Ok(())
4193 }
4194}
4195
4196impl Default for WebUIHandler {
4197 fn default() -> Self {
4198 Self::new()
4199 }
4200}
4201
4202const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4205const MAX_REUSED_TEMPLATE_CAPACITY: usize = 4 * 1024;
4206
4207#[cold]
4208#[inline(never)]
4209fn invalid_template_buffer_error(error: std::string::FromUtf8Error) -> HandlerError {
4210 HandlerError::Invariant(format!(
4211 "cleared template output buffer is not valid UTF-8: {}",
4212 error.utf8_error()
4213 ))
4214}
4215
4216fn recycle_template_buffer(value: String, previous_capacity: usize) -> Vec<u8> {
4217 if value.capacity() <= MAX_REUSED_TEMPLATE_CAPACITY || value.capacity() <= previous_capacity {
4218 value.into_bytes()
4219 } else {
4220 Vec::new()
4221 }
4222}
4223
4224fn template_attr_fragments<'protocol>(
4225 template_id: &str,
4226 target: Option<usize>,
4227 index: &ResolvedRenderFragmentIndex<'protocol>,
4228) -> Result<RenderFragmentList<'protocol>> {
4229 target
4230 .or_else(|| index.index(template_id))
4231 .and_then(|slot| index.list(slot))
4232 .ok_or_else(|| missing_attribute_template_error(template_id))
4233}
4234
4235#[cold]
4236#[inline(never)]
4237fn missing_attribute_template_error(template_id: &str) -> HandlerError {
4238 HandlerError::MissingFragment(template_id.to_owned())
4239}
4240
4241fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4244 fragments
4245 .iter()
4246 .map(|frag| match frag.fragment.as_ref() {
4247 Some(Fragment::Raw(raw)) => raw.value.len(),
4248 Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4249 _ => 0,
4250 })
4251 .sum()
4252}
4253
4254fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4256 writer.write_attribute(name, value)
4257}
4258
4259#[cfg(test)]
4260fn handle(
4261 protocol: &WebUIProtocol,
4262 state: &Value,
4263 options: &RenderOptions<'_>,
4264 writer: &mut dyn ResponseWriter,
4265) -> Result<()> {
4266 let handler = WebUIHandler::new();
4267 handler.handle(protocol, state, options, writer)
4268}
4269
4270#[cfg(test)]
4271mod scalar_buffer_tests {
4272 use super::*;
4273
4274 #[test]
4277 fn plain_scalars_match_value_to_string() {
4278 let cases = [
4279 Value::Null,
4280 Value::Bool(true),
4281 Value::Bool(false),
4282 serde_json::json!(0),
4283 serde_json::json!(-1),
4284 serde_json::json!(42),
4285 serde_json::json!(i64::MIN),
4286 serde_json::json!(i64::MAX),
4287 serde_json::json!(u64::MAX),
4288 serde_json::json!(0.5),
4289 serde_json::json!(-0.0_f64),
4290 serde_json::json!(1.7976931348623157e308_f64),
4291 serde_json::json!(-1.7976931348623157e308_f64),
4292 serde_json::json!(f64::MIN_POSITIVE),
4293 ];
4294 for value in cases {
4295 let mut buffer = ScalarBuffer::new();
4296 let rendered = format_plain_json_scalar(&mut buffer, &value);
4297 assert_eq!(
4298 rendered,
4299 Some(value.to_string().as_str()),
4300 "scalar rendering diverged for {value:?}"
4301 );
4302 }
4303 }
4304
4305 #[test]
4308 fn non_plain_values_are_declined() {
4309 let cases = [
4310 serde_json::json!("<script>"),
4311 serde_json::json!([1, 2]),
4312 serde_json::json!({ "a": 1 }),
4313 ];
4314 for value in cases {
4315 let mut buffer = ScalarBuffer::new();
4316 assert_eq!(
4317 format_plain_json_scalar(&mut buffer, &value),
4318 None,
4319 "expected fallback for {value:?}"
4320 );
4321 }
4322 }
4323
4324 #[test]
4326 fn overflow_is_reported_instead_of_truncating() {
4327 let mut buffer = ScalarBuffer::new();
4328 assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4329 }
4330}
4331
4332#[cfg(test)]
4333mod tests {
4334 use super::*;
4335 use crate::streaming::STREAMING_MARKER;
4336 use std::cell::RefCell;
4337 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4338 use webui_protocol::{
4339 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4340 WebUIFragmentAttribute, WebUiFragmentRoute,
4341 };
4342 use webui_test_utils::test_json;
4343
4344 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4345 WebUIFragment::signal(
4346 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4347 true,
4348 )
4349 }
4350
4351 #[test]
4352 fn route_descent_borrows_protocol_children() {
4353 let routes = vec![WebUiFragmentRoute {
4354 children: vec![WebUiFragmentRoute {
4355 fragment_id: "child".to_string(),
4356 ..Default::default()
4357 }],
4358 ..Default::default()
4359 }];
4360 let mut level = RouteChildren::Borrowed(&routes);
4361
4362 let descended = descend_into(&mut level, 0);
4363
4364 let Cow::Borrowed(children) = descended else {
4365 panic!("protocol-owned route children should remain borrowed");
4366 };
4367 assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4368 }
4369
4370 #[test]
4371 fn route_descent_moves_parked_children() {
4372 let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4373 children: vec![WebUiFragmentRoute {
4374 fragment_id: "child".to_string(),
4375 ..Default::default()
4376 }],
4377 ..Default::default()
4378 }]);
4379
4380 let descended = descend_into(&mut level, 0);
4381
4382 let Cow::Owned(children) = descended else {
4383 panic!("parked route children must not borrow session-owned storage");
4384 };
4385 assert_eq!(children[0].fragment_id, "child");
4386 let Cow::Owned(source) = level else {
4387 panic!("the parked source level must stay owned");
4388 };
4389 assert!(
4390 source[0].children.is_empty(),
4391 "owned route children must move out instead of being cloned"
4392 );
4393 }
4394
4395 #[test]
4396 fn borrowed_collection_reuses_state_array() {
4397 let state = test_json!({
4398 "items": [
4399 {"name": "first"},
4400 {"name": "second"}
4401 ]
4402 });
4403 let local_vars = ScopeMap::default();
4404 let local_borrowed_vars = BorrowedScope::default();
4405 let loop_vars = Vec::new();
4406 let items = resolve_borrowed_collection(
4407 "items",
4408 &loop_vars,
4409 VisibleLoopScope::EMPTY,
4410 LocalValueSources {
4411 owned: &local_vars,
4412 borrowed: &local_borrowed_vars,
4413 },
4414 &state,
4415 )
4416 .unwrap_or_else(|| panic!("state array should be borrowed"));
4417 let state_items = state["items"]
4418 .as_array()
4419 .unwrap_or_else(|| panic!("fixture items should be an array"));
4420 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4421 }
4422
4423 #[test]
4424 fn owned_local_collection_keeps_precedence() {
4425 let state = test_json!({"items": [{"name": "global"}]});
4426 let local_vars =
4427 ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4428 let local_borrowed_vars = BorrowedScope::default();
4429 assert!(resolve_borrowed_collection(
4430 "items",
4431 &[],
4432 VisibleLoopScope::EMPTY,
4433 LocalValueSources {
4434 owned: &local_vars,
4435 borrowed: &local_borrowed_vars,
4436 },
4437 &state,
4438 )
4439 .is_none());
4440 }
4441
4442 #[test]
4443 fn borrowed_component_collection_reuses_state_array() {
4444 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4445 let contacts = &state["teams"][0]["contacts"];
4446 let mut local_borrowed_vars = BorrowedScope::default();
4447 local_borrowed_vars.insert("contacts", contacts);
4448 let local_vars = ScopeMap::default();
4449 let items = resolve_borrowed_collection(
4450 "contacts",
4451 &[],
4452 VisibleLoopScope::EMPTY,
4453 LocalValueSources {
4454 owned: &local_vars,
4455 borrowed: &local_borrowed_vars,
4456 },
4457 &state,
4458 )
4459 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4460 let state_items = contacts
4461 .as_array()
4462 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4463 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4464 }
4465
4466 #[test]
4467 fn nested_borrowed_collection_reuses_child_array() {
4468 let state = test_json!({
4469 "items": [{
4470 "children": [
4471 {"name": "first"},
4472 {"name": "second"}
4473 ]
4474 }]
4475 });
4476 let item = &state["items"][0];
4477 let loop_vars = vec![LoopBinding {
4478 name: "item",
4479 value: item,
4480 }];
4481 let local_vars = ScopeMap::default();
4482 let local_borrowed_vars = BorrowedScope::default();
4483 let children = resolve_borrowed_collection(
4484 "item.children",
4485 &loop_vars,
4486 VisibleLoopScope { start: 0, end: 1 },
4487 LocalValueSources {
4488 owned: &local_vars,
4489 borrowed: &local_borrowed_vars,
4490 },
4491 &state,
4492 )
4493 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4494 let state_children = item["children"]
4495 .as_array()
4496 .unwrap_or_else(|| panic!("fixture children should be an array"));
4497 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4498 }
4499
4500 #[test]
4501 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4502 let state = test_json!({
4503 "item": {"name": "global", "fallback": "global fallback"},
4504 "outer": {"name": "outer"},
4505 "inner": {"name": "inner"}
4506 });
4507 let loop_vars = vec![
4508 LoopBinding {
4509 name: "item",
4510 value: &state["outer"],
4511 },
4512 LoopBinding {
4513 name: "item",
4514 value: &state["inner"],
4515 },
4516 ];
4517 let local_vars = ScopeMap::default();
4518 let local_borrowed_vars = BorrowedScope::default();
4519 let sources = LocalValueSources {
4520 owned: &local_vars,
4521 borrowed: &local_borrowed_vars,
4522 };
4523
4524 assert_eq!(
4525 resolve_value_from_sources(
4526 "item.name",
4527 &loop_vars,
4528 VisibleLoopScope { start: 0, end: 2 },
4529 sources,
4530 &state,
4531 )
4532 .as_deref()
4533 .and_then(Value::as_str),
4534 Some("inner")
4535 );
4536 assert_eq!(
4537 resolve_value_from_sources(
4538 "item.fallback",
4539 &loop_vars,
4540 VisibleLoopScope { start: 0, end: 2 },
4541 sources,
4542 &state,
4543 )
4544 .as_deref()
4545 .and_then(Value::as_str),
4546 Some("global fallback")
4547 );
4548 assert_eq!(
4549 resolve_value_from_sources(
4550 "item.name",
4551 &loop_vars,
4552 VisibleLoopScope { start: 2, end: 2 },
4553 sources,
4554 &state,
4555 )
4556 .as_deref()
4557 .and_then(Value::as_str),
4558 Some("global")
4559 );
4560 }
4561
4562 #[test]
4563 fn state_backed_resolver_matches_general_borrowed_resolution() {
4564 let state = test_json!({
4565 "global": {"name": "global"},
4566 "inner": {"fallback": "global fallback"},
4567 "borrowed": {"name": "borrowed"},
4568 "items": [1, 2, 3]
4569 });
4570 let loop_values = test_json!({
4571 "outer": {"name": "outer"},
4572 "inner": {"name": "inner"}
4573 });
4574 let loop_vars = vec![
4575 LoopBinding {
4576 name: "outer",
4577 value: &loop_values["outer"],
4578 },
4579 LoopBinding {
4580 name: "inner",
4581 value: &loop_values["inner"],
4582 },
4583 ];
4584 let mut local_borrowed_vars = BorrowedScope::default();
4585 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4586 let local_vars =
4587 ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4588 let sources = LocalValueSources {
4589 owned: &local_vars,
4590 borrowed: &local_borrowed_vars,
4591 };
4592 let scope = VisibleLoopScope { start: 0, end: 2 };
4593
4594 for path in [
4595 "global.name",
4596 "inner.name",
4597 "inner.fallback",
4598 "outer.name",
4599 "borrowed.name",
4600 ] {
4601 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4602 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4603 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4604 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4605 let Cow::Borrowed(resolved) = resolved else {
4606 panic!("{path} should remain borrowed");
4607 };
4608 assert!(
4609 std::ptr::eq(state_backed, resolved),
4610 "{path} resolved from different sources"
4611 );
4612 }
4613
4614 assert!(
4615 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4616 "owned local values must use the general resolver"
4617 );
4618 assert!(
4619 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4620 .is_none(),
4621 "synthetic values must use the general resolver"
4622 );
4623 }
4624
4625 #[test]
4626 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4627 let values = [
4628 Value::String("a".to_string()),
4629 Value::String("b".to_string()),
4630 Value::String("c".to_string()),
4631 Value::String("d".to_string()),
4632 Value::String("e".to_string()),
4633 Value::String("f".to_string()),
4634 Value::String("replacement".to_string()),
4635 ];
4636 let mut scope = BorrowedScope::default();
4637 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4638 assert!(scope.insert(name, &values[index]).is_none());
4639 }
4640 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4641 assert_eq!(scope.overflow.len(), 2);
4642 assert_eq!(scope.get("a"), Some(&values[0]));
4643 assert_eq!(scope.get("f"), Some(&values[5]));
4644 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4645 assert_eq!(scope.get("a"), Some(&values[6]));
4646 assert_eq!(scope.remove("b"), Some(&values[1]));
4647 assert!(scope.get("b").is_none());
4648 assert_eq!(scope.get("f"), Some(&values[5]));
4649 scope.clear();
4650 assert!(scope.get("a").is_none());
4651 assert!(scope.overflow.is_empty());
4652 }
4653
4654 struct TestWriter {
4656 content: RefCell<String>,
4657 ended: RefCell<bool>,
4658 }
4659
4660 impl TestWriter {
4661 fn new() -> Self {
4662 Self {
4663 content: RefCell::new(String::new()),
4664 ended: RefCell::new(false),
4665 }
4666 }
4667
4668 fn get_content(&self) -> String {
4669 self.content.borrow().clone()
4670 }
4671
4672 fn is_ended(&self) -> bool {
4673 *self.ended.borrow()
4674 }
4675 }
4676
4677 impl ResponseWriter for TestWriter {
4678 fn write(&mut self, content: &str) -> Result<()> {
4679 self.content.borrow_mut().push_str(content);
4680 Ok(())
4681 }
4682
4683 fn end(&mut self) -> Result<()> {
4684 *self.ended.borrow_mut() = true;
4685 Ok(())
4686 }
4687 }
4688
4689 #[test]
4690 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4691 struct AttributeWriter {
4692 output: String,
4693 }
4694
4695 impl ResponseWriter for AttributeWriter {
4696 fn write(&mut self, content: &str) -> Result<()> {
4697 self.output.push_str(content);
4698 Ok(())
4699 }
4700
4701 crate::string_response_writer_methods!(output);
4702
4703 fn end(&mut self) -> Result<()> {
4704 Ok(())
4705 }
4706 }
4707
4708 let mut writer = AttributeWriter {
4709 output: String::new(),
4710 };
4711 write_attr(&mut writer, "data-id", "42")
4712 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4713 writer
4714 .write_boolean_attribute("disabled")
4715 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4716 assert_eq!(writer.output, " data-id=\"42\" disabled");
4717 }
4718
4719 #[test]
4720 fn template_attributes_preserve_escaping_and_raw_component_values() -> Result<()> {
4721 crate::define_string_response_writer!(StringWriter, output);
4722 crate::define_bytes_response_writer!(BytesWriter, output);
4723
4724 let values = [
4725 None,
4726 Some(Value::Null),
4727 Some(test_json!(false)),
4728 Some(test_json!(u64::MAX)),
4729 Some(test_json!(-0.0)),
4730 Some(test_json!("é水🙂<&\"'/&")),
4731 Some(Value::String("é<&\"'/".repeat(1024))),
4732 Some(test_json!(["<item>", 7])),
4733 Some(test_json!({ "key": "quoted \"value\"" })),
4734 ];
4735 for component_prop in [false, true] {
4736 let mut attribute = WebUIFragment::attribute_template("data-value", "value-template");
4737 if let Some(Fragment::Attribute(attribute)) = &mut attribute.fragment {
4738 attribute.attr_start = component_prop;
4739 }
4740 let body = if component_prop {
4741 WebUIFragment::component("value-host")
4742 } else {
4743 WebUIFragment::signal("label", false)
4744 };
4745 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4746 (
4747 "index.html".to_owned(),
4748 FragmentList {
4749 fragments: vec![
4750 WebUIFragment::raw("<value-host"),
4751 attribute,
4752 WebUIFragment::raw(">"),
4753 body,
4754 WebUIFragment::raw("</value-host>"),
4755 ],
4756 contains_boundary: false,
4757 },
4758 ),
4759 (
4760 "value-template".to_owned(),
4761 FragmentList {
4762 fragments: vec![
4763 WebUIFragment::raw("prefix/"),
4764 WebUIFragment::signal("value", true),
4765 WebUIFragment::raw("/suffix&"),
4766 ],
4767 contains_boundary: false,
4768 },
4769 ),
4770 (
4771 "value-host".to_owned(),
4772 FragmentList {
4773 fragments: vec![WebUIFragment::signal("dataValue", false)],
4774 contains_boundary: false,
4775 },
4776 ),
4777 ])));
4778 assert_eq!(std::mem::size_of::<RenderFragmentMetadata>(), 12);
4779 for value in &values {
4780 let mut state = test_json!({ "label": "outside<&" });
4781 if let Some(value) = value {
4782 state["value"] = value.clone();
4783 }
4784 let rendered = match value {
4785 None => Cow::Borrowed(""),
4786 Some(Value::String(value)) => Cow::Borrowed(value.as_str()),
4787 Some(value) => Cow::Owned(value.to_string()),
4788 };
4789 let raw_value = format!("prefix/{rendered}/suffix&");
4790 let escaped = encode_safe(&raw_value);
4791 let content = if component_prop {
4792 escaped.as_ref()
4793 } else {
4794 "outside<&"
4795 };
4796 let expected =
4797 format!("<value-host data-value=\"{escaped}\">{content}</value-host>");
4798 let mut default = TestWriter::new();
4799 let mut text = StringWriter::with_capacity(expected.len());
4800 let mut bytes = BytesWriter::with_capacity(expected.len());
4801 for writer in [
4802 &mut default as &mut dyn ResponseWriter,
4803 &mut text,
4804 &mut bytes,
4805 ] {
4806 WebUIHandler::new().render(
4807 &protocol,
4808 &state,
4809 &RenderOptions::new("index.html", "/"),
4810 writer,
4811 )?;
4812 }
4813 assert_eq!(default.get_content(), expected);
4814 assert_eq!(text.output, expected);
4815 assert_eq!(bytes.output, expected.as_bytes());
4816 }
4817 }
4818 Ok(())
4819 }
4820
4821 #[test]
4822 fn template_buffer_reuse_does_not_add_a_large_high_water_mark() {
4823 let small = String::with_capacity(64);
4824 assert_eq!(recycle_template_buffer(small, 0).capacity(), 64);
4825
4826 let large = String::with_capacity(MAX_REUSED_TEMPLATE_CAPACITY * 2);
4827 assert_eq!(recycle_template_buffer(large, 0).capacity(), 0);
4828
4829 let existing = String::with_capacity(MAX_REUSED_TEMPLATE_CAPACITY * 2);
4830 let capacity = existing.capacity();
4831 assert_eq!(
4832 recycle_template_buffer(existing, capacity).capacity(),
4833 capacity
4834 );
4835 }
4836
4837 #[test]
4838 fn shared_attribute_template_preserves_raw_and_component_rendering() -> Result<()> {
4839 crate::define_string_response_writer!(BufferedWriter, output);
4840 let mut component_attribute =
4841 WebUIFragment::attribute_template("data-value", "shared-template");
4842 if let Some(Fragment::Attribute(attribute)) = &mut component_attribute.fragment {
4843 attribute.attr_start = true;
4844 }
4845 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4846 (
4847 "index.html".to_owned(),
4848 FragmentList {
4849 fragments: vec![
4850 WebUIFragment::raw("<a"),
4851 WebUIFragment::attribute_template("title", "shared-template"),
4852 WebUIFragment::raw("></a><value-host"),
4853 component_attribute,
4854 WebUIFragment::raw(">"),
4855 WebUIFragment::component("value-host"),
4856 WebUIFragment::raw("</value-host><section>"),
4857 WebUIFragment::component("shared-template"),
4858 WebUIFragment::raw("</section>"),
4859 ],
4860 contains_boundary: false,
4861 },
4862 ),
4863 (
4864 "shared-template".to_owned(),
4865 FragmentList {
4866 fragments: vec![
4867 WebUIFragment::raw("prefix/&"),
4868 WebUIFragment::signal("value", false),
4869 WebUIFragment::raw("<em>suffix</em>"),
4870 ],
4871 contains_boundary: false,
4872 },
4873 ),
4874 (
4875 "value-host".to_owned(),
4876 FragmentList {
4877 fragments: vec![WebUIFragment::signal("dataValue", false)],
4878 contains_boundary: false,
4879 },
4880 ),
4881 ])));
4882 let value = "é<&\"'/";
4883 let state = test_json!({ "value": value });
4884 let raw_value = format!("prefix/&{value}<em>suffix</em>");
4885 let escaped = encode_safe(&raw_value);
4886 let expected = format!(
4887 "<a title=\"{escaped}\"></a><value-host data-value=\"{escaped}\">\
4888 {escaped}</value-host><section>prefix/&{}<em>suffix</em></section>",
4889 encode_safe(value)
4890 );
4891 let mut writer = BufferedWriter::with_capacity(expected.len());
4892 WebUIHandler::new().render(
4893 &protocol,
4894 &state,
4895 &RenderOptions::new("index.html", "/"),
4896 &mut writer,
4897 )?;
4898 assert_eq!(writer.output, expected);
4899 Ok(())
4900 }
4901
4902 #[test]
4903 fn split_template_attribute_stops_at_the_first_writer_error() {
4904 struct FailAfter {
4905 writes: usize,
4906 fail_at: usize,
4907 ended: bool,
4908 }
4909
4910 impl ResponseWriter for FailAfter {
4911 fn write(&mut self, _content: &str) -> Result<()> {
4912 self.writes += 1;
4913 if self.writes == self.fail_at {
4914 Err(HandlerError::ClientDisconnected)
4915 } else {
4916 Ok(())
4917 }
4918 }
4919
4920 fn end(&mut self) -> Result<()> {
4921 self.ended = true;
4922 Ok(())
4923 }
4924 }
4925
4926 let protocol = Protocol::new(WebUIProtocol::new(HashMap::from([
4927 (
4928 "index.html".to_owned(),
4929 FragmentList {
4930 fragments: vec![
4931 WebUIFragment::raw("<a"),
4932 WebUIFragment::attribute_template("href", "value-template"),
4933 WebUIFragment::raw(">must not be written</a>"),
4934 ],
4935 contains_boundary: false,
4936 },
4937 ),
4938 (
4939 "value-template".to_owned(),
4940 FragmentList {
4941 fragments: vec![
4942 WebUIFragment::raw("prefix/"),
4943 WebUIFragment::signal("value", false),
4944 WebUIFragment::raw("/suffix"),
4945 ],
4946 contains_boundary: false,
4947 },
4948 ),
4949 ])));
4950 let state = test_json!({ "value": "é<&\"'/" });
4951 let options = RenderOptions::new("index.html", "/");
4952 let handler = WebUIHandler::new();
4953 let mut complete = FailAfter {
4954 writes: 0,
4955 fail_at: usize::MAX,
4956 ended: false,
4957 };
4958 handler
4959 .render(&protocol, &state, &options, &mut complete)
4960 .unwrap();
4961 assert!(complete.writes > 1);
4962 assert!(complete.ended);
4963 for fail_at in 1..complete.writes {
4964 let mut writer = FailAfter {
4965 writes: 0,
4966 fail_at,
4967 ended: false,
4968 };
4969 let result = handler.render(&protocol, &state, &options, &mut writer);
4970 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
4971 assert_eq!(writer.writes, fail_at);
4972 assert!(!writer.ended);
4973 }
4974 }
4975
4976 #[test]
4977 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4978 let mut fragments = HashMap::new();
4979 fragments.insert(
4980 "index.html".to_string(),
4981 FragmentList {
4982 fragments: vec![
4983 WebUIFragment {
4984 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4985 name: ":data-title".to_string(),
4986 value: "source".to_string(),
4987 complex: true,
4988 attr_start: true,
4989 ..Default::default()
4990 })),
4991 },
4992 WebUIFragment::component("prepared-child"),
4993 ],
4994 contains_boundary: false,
4995 },
4996 );
4997 fragments.insert(
4998 "prepared-child".to_string(),
4999 FragmentList {
5000 fragments: vec![WebUIFragment::signal("dataTitle", false)],
5001 contains_boundary: false,
5002 },
5003 );
5004 let protocol = Protocol::new(WebUIProtocol::new(fragments));
5005 let resolved = protocol.render_fragments().resolve(protocol.protocol());
5006 let entry = resolved
5007 .list_by_id("index.html")
5008 .unwrap_or_else(|| panic!("entry render plan should exist"));
5009 let child_index = protocol
5010 .render_fragments()
5011 .index("prepared-child")
5012 .unwrap_or_else(|| panic!("component target should be indexed"));
5013 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
5014 assert_eq!(entry.target(1), Some(child_index));
5015 assert!(!entry.has_routes);
5016 let source_entry = protocol
5017 .protocol()
5018 .fragments
5019 .get("index.html")
5020 .unwrap_or_else(|| panic!("source entry should exist"));
5021 assert!(std::ptr::eq(
5022 entry.fragments.as_ptr(),
5023 source_entry.fragments.as_ptr()
5024 ));
5025
5026 let state = test_json!({"source": "Prepared"});
5027 let options = RenderOptions::new("index.html", "/");
5028 for handler in [
5029 WebUIHandler::new(),
5030 WebUIHandler::with_plugin(|| {
5031 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
5032 }),
5033 ] {
5034 let mut writer = TestWriter::new();
5035 assert!(handler
5036 .render(&protocol, &state, &options, &mut writer)
5037 .is_ok());
5038 assert!(writer.get_content().contains("Prepared"));
5039 }
5040 }
5041
5042 #[test]
5043 fn prepared_graph_records_route_presence_per_fragment_list() {
5044 let mut fragments = HashMap::new();
5045 fragments.insert(
5046 "index.html".to_string(),
5047 FragmentList {
5048 fragments: vec![WebUIFragment::route("/", "dash-page")],
5049 contains_boundary: false,
5050 },
5051 );
5052 fragments.insert(
5053 "dash-page".to_string(),
5054 FragmentList {
5055 fragments: vec![WebUIFragment::raw("dashboard")],
5056 contains_boundary: false,
5057 },
5058 );
5059 let protocol = Protocol::new(WebUIProtocol::new(fragments));
5060 let resolved = protocol.render_fragments().resolve(protocol.protocol());
5061 let entry = resolved
5062 .list_by_id("index.html")
5063 .unwrap_or_else(|| panic!("entry render plan should exist"));
5064 let page = resolved
5065 .list_by_id("dash-page")
5066 .unwrap_or_else(|| panic!("page render plan should exist"));
5067 assert!(entry.has_routes);
5069 assert!(!page.has_routes);
5070 }
5071
5072 #[test]
5073 fn prepared_prop_names_match_attribute_to_camel_exactly() {
5074 let names = [
5078 "data-title",
5079 ":data-title",
5080 "aria-labelledby",
5081 "aria-valuemax",
5082 "readonly",
5083 "tabindex",
5084 "class",
5085 "for",
5086 "maxlength",
5087 "novalidate",
5088 "already-camel",
5089 "trailing-",
5090 "-leading",
5091 "multi-part-attribute-name",
5092 "single",
5093 "",
5094 ];
5095 for name in names {
5096 let stripped = name.strip_prefix(':').unwrap_or(name);
5097 assert_eq!(
5098 component_attr_name(name),
5099 webui_protocol::attrs::attribute_to_camel(stripped),
5100 "prepared name diverged for `{name}`"
5101 );
5102 }
5103 }
5104
5105 #[test]
5106 fn prepared_component_prop_names_match_per_branch_stripping() {
5107 let complex = WebUIFragmentAttribute {
5111 name: "::data-title".to_string(),
5112 value: "source".to_string(),
5113 complex: true,
5114 ..Default::default()
5115 };
5116 assert_eq!(component_attr_source(&complex), ":data-title");
5117 assert_eq!(
5118 component_attr_name(component_attr_source(&complex)),
5119 "dataTitle"
5120 );
5121
5122 let raw = WebUIFragmentAttribute {
5124 raw_value: true,
5125 ..complex.clone()
5126 };
5127 assert_eq!(component_attr_source(&raw), "::data-title");
5128
5129 let templated = WebUIFragmentAttribute {
5131 template: "tpl".to_string(),
5132 ..complex.clone()
5133 };
5134 assert_eq!(component_attr_source(&templated), "::data-title");
5135
5136 let empty_value = WebUIFragmentAttribute {
5138 value: String::new(),
5139 ..complex.clone()
5140 };
5141 assert_eq!(component_attr_source(&empty_value), "::data-title");
5142
5143 let ordinary = WebUIFragmentAttribute {
5145 name: ":data-title".to_string(),
5146 ..complex
5147 };
5148 assert_eq!(component_attr_source(&ordinary), "data-title");
5149 assert_eq!(
5150 component_attr_name(component_attr_source(&ordinary)),
5151 "dataTitle"
5152 );
5153 }
5154
5155 #[test]
5156 fn render_slots_match_continuation_slots() {
5157 let mut fragments = HashMap::new();
5162 for id in [
5163 "index.html",
5164 "zeta-last",
5165 "alpha-first",
5166 "middle-one",
5167 "middle-two",
5168 ] {
5169 fragments.insert(
5170 id.to_string(),
5171 FragmentList {
5172 fragments: vec![WebUIFragment::raw(id)],
5173 contains_boundary: false,
5174 },
5175 );
5176 }
5177 let protocol = Protocol::new(WebUIProtocol::new(fragments));
5178 let index = protocol.render_fragments();
5179 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
5180 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
5181 {
5182 let id = protocol
5183 .fragment_id(slot)
5184 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
5185 assert_eq!(
5186 index.index(id),
5187 Some(slot as usize),
5188 "render slot must equal continuation slot for `{id}`"
5189 );
5190 assert_eq!(protocol.fragment_slot(id), Some(slot));
5191 }
5192 }
5193
5194 #[test]
5195 fn prepared_graph_reports_missing_fragments_without_panicking() {
5196 let mut fragments = HashMap::new();
5197 fragments.insert(
5198 "index.html".to_string(),
5199 FragmentList {
5200 fragments: vec![WebUIFragment::component("absent-child")],
5201 contains_boundary: false,
5202 },
5203 );
5204 let protocol = Protocol::new(WebUIProtocol::new(fragments));
5205 let resolved = protocol.render_fragments().resolve(protocol.protocol());
5206 let entry = resolved
5207 .list_by_id("index.html")
5208 .unwrap_or_else(|| panic!("entry render plan should exist"));
5209 assert_eq!(entry.target(0), None);
5212 assert!(resolved.list_by_id("absent-child").is_none());
5213 assert!(resolved.list(usize::MAX).is_none());
5214
5215 let state = test_json!({});
5216 let options = RenderOptions::new("index.html", "/");
5217 let mut writer = TestWriter::new();
5218 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
5219 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
5220 other => panic!("expected a missing fragment error, got {other:?}"),
5221 }
5222
5223 let mut writer = TestWriter::new();
5224 match WebUIHandler::new().render(
5225 &protocol,
5226 &state,
5227 &RenderOptions::new("absent-entry", "/"),
5228 &mut writer,
5229 ) {
5230 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
5231 other => panic!("expected a missing entry error, got {other:?}"),
5232 }
5233 assert!(protocol.has_fragment("index.html"));
5236 assert!(!protocol.has_fragment("absent-entry"));
5237 }
5238
5239 #[test]
5240 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
5241 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
5242 let mut fragments = HashMap::with_capacity(list_count);
5243 for index in 0..list_count {
5244 fragments.insert(
5245 format!("fragment-{index}"),
5246 FragmentList {
5247 fragments: vec![WebUIFragment::raw("content")],
5248 contains_boundary: false,
5249 },
5250 );
5251 }
5252 let protocol = Protocol::new(WebUIProtocol::new(fragments));
5253 let resolved = protocol.render_fragments().resolve(protocol.protocol());
5254 assert!(resolved.cache[0].get().is_none());
5256 assert!(resolved.spilled.get().is_none());
5257 assert!(resolved.list(0).is_some());
5258 assert!(resolved.cache[0].get().is_some());
5259 assert!(resolved.spilled.get().is_none());
5261 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
5264 let spilled = resolved
5265 .spilled
5266 .get()
5267 .expect("a slot past the inline cache should allocate the spill region");
5268 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
5269 assert!(spilled[0].get().is_some());
5270 assert!(resolved.list(list_count - 1).is_some());
5271
5272 for (id, source) in &protocol.protocol().fragments {
5273 let prepared = resolved
5274 .list_by_id(id)
5275 .unwrap_or_else(|| panic!("render plan should contain {id}"));
5276 assert!(std::ptr::eq(
5278 prepared.fragments.as_ptr(),
5279 source.fragments.as_ptr()
5280 ));
5281 }
5282 }
5283
5284 #[test]
5285 fn test_handle_raw() {
5286 let mut fragments = HashMap::new();
5288 fragments.insert(
5289 "index.html".to_string(),
5290 FragmentList {
5291 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
5292 contains_boundary: false,
5293 },
5294 );
5295
5296 let protocol = WebUIProtocol::new(fragments);
5297 let state = test_json!({});
5298
5299 let mut writer = TestWriter::new();
5301
5302 assert!(
5304 handle(
5305 &protocol,
5306 &state,
5307 &RenderOptions::new("index.html", "/"),
5308 &mut writer
5309 )
5310 .is_ok(),
5311 "Failed to handle raw protocol"
5312 );
5313
5314 assert_eq!(writer.get_content(), "Hello, WebUI!");
5316 assert!(writer.is_ended());
5317 }
5318
5319 #[test]
5320 fn test_handle_signal() {
5321 let mut fragments = HashMap::new();
5323 fragments.insert(
5324 "index.html".to_string(),
5325 FragmentList {
5326 fragments: vec![
5327 WebUIFragment::raw("Hello, "),
5328 WebUIFragment::signal("name", false),
5329 WebUIFragment::raw("!"),
5330 ],
5331 contains_boundary: false,
5332 },
5333 );
5334
5335 let protocol = WebUIProtocol::new(fragments);
5336 let state = test_json!({"name": "WebUI"});
5337
5338 let mut writer = TestWriter::new();
5340
5341 assert!(
5343 handle(
5344 &protocol,
5345 &state,
5346 &RenderOptions::new("index.html", "/"),
5347 &mut writer
5348 )
5349 .is_ok(),
5350 "Failed to handle signal protocol"
5351 );
5352
5353 assert_eq!(writer.get_content(), "Hello, WebUI!");
5355 assert!(writer.is_ended());
5356 }
5357
5358 #[test]
5359 fn test_handle_for_loop() {
5360 let mut fragments = HashMap::new();
5362 fragments.insert(
5363 "index.html".to_string(),
5364 FragmentList {
5365 fragments: vec![
5366 WebUIFragment::raw("People: "),
5367 WebUIFragment::for_loop("person", "people", "person-item"),
5368 ],
5369 contains_boundary: false,
5370 },
5371 );
5372
5373 fragments.insert(
5374 "person-item".to_string(),
5375 FragmentList {
5376 fragments: vec![
5377 WebUIFragment::signal("person.name", false),
5378 WebUIFragment::raw(", "),
5379 ],
5380 contains_boundary: false,
5381 },
5382 );
5383
5384 let protocol = WebUIProtocol::new(fragments);
5385 let state = test_json!({
5386 "people": [
5387 {"name": "Alice"},
5388 {"name": "Bob"},
5389 {"name": "Charlie"}
5390 ]
5391 });
5392
5393 let mut writer = TestWriter::new();
5395
5396 assert!(
5398 handle(
5399 &protocol,
5400 &state,
5401 &RenderOptions::new("index.html", "/"),
5402 &mut writer
5403 )
5404 .is_ok(),
5405 "Failed to handle for loop protocol"
5406 );
5407
5408 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5410 assert!(writer.is_ended());
5411 }
5412
5413 #[test]
5414 fn test_handle_if_condition() {
5415 let mut fragments = HashMap::new();
5417 fragments.insert(
5418 "index.html".to_string(),
5419 FragmentList {
5420 fragments: vec![
5421 WebUIFragment::raw("Status: "),
5422 WebUIFragment::if_cond(
5423 webui_protocol::ConditionExpr::identifier("isActive"),
5424 "active-content",
5425 ),
5426 WebUIFragment::raw("End"),
5427 ],
5428 contains_boundary: false,
5429 },
5430 );
5431
5432 fragments.insert(
5433 "active-content".to_string(),
5434 FragmentList {
5435 fragments: vec![WebUIFragment::raw("Active")],
5436 contains_boundary: false,
5437 },
5438 );
5439
5440 let protocol = WebUIProtocol::new(fragments);
5441
5442 let state_true = test_json!({"isActive": true});
5444 let mut writer_true = TestWriter::new();
5445 assert!(
5446 handle(
5447 &protocol,
5448 &state_true,
5449 &RenderOptions::new("index.html", "/"),
5450 &mut writer_true
5451 )
5452 .is_ok(),
5453 "Failed to handle if condition (true case)"
5454 );
5455 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5456 assert!(writer_true.is_ended());
5457
5458 let state_false = test_json!({"isActive": false});
5460 let mut writer_false = TestWriter::new();
5461 assert!(
5462 handle(
5463 &protocol,
5464 &state_false,
5465 &RenderOptions::new("index.html", "/"),
5466 &mut writer_false
5467 )
5468 .is_ok(),
5469 "Failed to handle if condition (false case)"
5470 );
5471 assert_eq!(writer_false.get_content(), "Status: End");
5472 assert!(writer_false.is_ended());
5473 }
5474
5475 #[test]
5476 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5477 let source = r#"<if condition="missingTopLevel">top-positive</if><if condition="!missingTopLevel">top-negated</if><for each="item in items"><if condition="item.searchPresentation"><mark>{{item.label}}</mark></if><if condition="!item.searchPresentation"><span>{{item.label}}</span></if></for>"#;
5478 let mut parser = HtmlParser::new();
5479 parser
5480 .parse("index.html", source)
5481 .expect("parse missing-path condition fixture");
5482 let protocol = WebUIProtocol::new(parser.into_fragment_records());
5483 let state = test_json!({
5484 "items": [
5485 {"label": "Normal"},
5486 {"label": "Search", "searchPresentation": true}
5487 ]
5488 });
5489 let mut writer = TestWriter::new();
5490
5491 handle(
5492 &protocol,
5493 &state,
5494 &RenderOptions::new("index.html", "/"),
5495 &mut writer,
5496 )
5497 .expect("render missing-path condition fixture");
5498
5499 assert_eq!(
5500 writer.get_content(),
5501 "top-negated<span>Normal</span><mark>Search</mark>"
5502 );
5503 }
5504
5505 #[test]
5506 fn native_dynamic_attribute_does_not_leak_into_next_component() {
5507 let mut fragments = HashMap::new();
5508 fragments.insert(
5509 "index.html".to_string(),
5510 FragmentList {
5511 fragments: vec![
5512 WebUIFragment::raw("<div"),
5513 WebUIFragment {
5514 fragment: Some(web_ui_fragment::Fragment::Attribute(
5515 WebUIFragmentAttribute {
5516 name: "title".into(),
5517 value: "nativeTitle".into(),
5518 ..Default::default()
5519 },
5520 )),
5521 },
5522 WebUIFragment::raw("></div><my-comp>"),
5523 WebUIFragment::component("my-comp"),
5524 WebUIFragment::raw("</my-comp>"),
5525 ],
5526 contains_boundary: false,
5527 },
5528 );
5529 fragments.insert(
5530 "my-comp".to_string(),
5531 FragmentList {
5532 fragments: vec![WebUIFragment::signal("title", false)],
5533 contains_boundary: false,
5534 },
5535 );
5536 let protocol = WebUIProtocol::new(fragments);
5537 let state = test_json!({
5538 "nativeTitle": "native",
5539 "title": "global"
5540 });
5541 let mut writer = TestWriter::new();
5542 handle(
5543 &protocol,
5544 &state,
5545 &RenderOptions::new("index.html", "/"),
5546 &mut writer,
5547 )
5548 .unwrap_or_else(|error| panic!("render failed: {error}"));
5549 assert_eq!(
5550 writer.get_content(),
5551 "<div title=\"native\"></div><my-comp>global</my-comp>"
5552 );
5553 }
5554
5555 #[test]
5556 fn test_handle_component() {
5557 let mut fragments = HashMap::new();
5559 fragments.insert(
5560 "index.html".to_string(),
5561 FragmentList {
5562 fragments: vec![
5563 WebUIFragment::raw("Component: "),
5564 WebUIFragment::component("my-component"),
5565 ],
5566 contains_boundary: false,
5567 },
5568 );
5569
5570 fragments.insert(
5571 "my-component".to_string(),
5572 FragmentList {
5573 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5574 contains_boundary: false,
5575 },
5576 );
5577
5578 let protocol = WebUIProtocol::new(fragments);
5579 let state = test_json!({});
5580
5581 let mut writer = TestWriter::new();
5583
5584 assert!(
5586 handle(
5587 &protocol,
5588 &state,
5589 &RenderOptions::new("index.html", "/"),
5590 &mut writer
5591 )
5592 .is_ok(),
5593 "Failed to handle component protocol"
5594 );
5595
5596 assert_eq!(
5598 writer.get_content(),
5599 "Component: <div>Component Content</div>"
5600 );
5601 assert!(writer.is_ended());
5602 }
5603
5604 #[test]
5605 fn test_missing_fragment() {
5606 let mut fragments = HashMap::new();
5608 fragments.insert(
5609 "index.html".to_string(),
5610 FragmentList {
5611 fragments: vec![WebUIFragment::component("missing-component")],
5612 contains_boundary: false,
5613 },
5614 );
5615
5616 let protocol = WebUIProtocol::new(fragments);
5617 let state = test_json!({});
5618
5619 let mut writer = TestWriter::new();
5621
5622 let result = handle(
5624 &protocol,
5625 &state,
5626 &RenderOptions::new("index.html", "/"),
5627 &mut writer,
5628 );
5629
5630 assert!(result.is_err());
5632 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5633 assert_eq!(fragment_id, "missing-component");
5634 } else {
5635 panic!("Expected MissingFragment error");
5636 }
5637 }
5638
5639 #[test]
5640 fn test_missing_signal_renders_empty() {
5641 let mut fragments = HashMap::new();
5643 fragments.insert(
5644 "index.html".to_string(),
5645 FragmentList {
5646 fragments: vec![
5647 WebUIFragment::raw("Hello, "),
5648 WebUIFragment::signal("missing_field", false),
5649 WebUIFragment::raw("!"),
5650 ],
5651 contains_boundary: false,
5652 },
5653 );
5654
5655 let protocol = WebUIProtocol::new(fragments);
5656 let state = test_json!({});
5657
5658 let mut writer = TestWriter::new();
5659
5660 assert!(
5661 handle(
5662 &protocol,
5663 &state,
5664 &RenderOptions::new("index.html", "/"),
5665 &mut writer
5666 )
5667 .is_ok(),
5668 "Missing signal should not produce an error"
5669 );
5670
5671 assert_eq!(writer.get_content(), "Hello, !");
5672 assert!(writer.is_ended());
5673 }
5674
5675 #[test]
5678 fn test_boolean_attr_true() {
5679 let mut fragments = HashMap::new();
5680 fragments.insert(
5681 "index.html".to_string(),
5682 FragmentList {
5683 fragments: vec![
5684 WebUIFragment::raw("<button"),
5685 WebUIFragment::attribute_boolean(
5686 "disabled",
5687 ConditionExpr::identifier("isDisabled"),
5688 ),
5689 WebUIFragment::raw(">Click</button>"),
5690 ],
5691 contains_boundary: false,
5692 },
5693 );
5694 let protocol = WebUIProtocol::new(fragments);
5695 let state = test_json!({"isDisabled": true});
5696 let mut writer = TestWriter::new();
5697 handle(
5698 &protocol,
5699 &state,
5700 &RenderOptions::new("index.html", "/"),
5701 &mut writer,
5702 )
5703 .unwrap();
5704 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5705 }
5706
5707 #[test]
5708 fn test_boolean_attr_false() {
5709 let mut fragments = HashMap::new();
5710 fragments.insert(
5711 "index.html".to_string(),
5712 FragmentList {
5713 fragments: vec![
5714 WebUIFragment::raw("<button"),
5715 WebUIFragment::attribute_boolean(
5716 "disabled",
5717 ConditionExpr::identifier("isDisabled"),
5718 ),
5719 WebUIFragment::raw(">Click</button>"),
5720 ],
5721 contains_boundary: false,
5722 },
5723 );
5724 let protocol = WebUIProtocol::new(fragments);
5725 let state = test_json!({"isDisabled": false});
5726 let mut writer = TestWriter::new();
5727 handle(
5728 &protocol,
5729 &state,
5730 &RenderOptions::new("index.html", "/"),
5731 &mut writer,
5732 )
5733 .unwrap();
5734 assert_eq!(writer.get_content(), "<button>Click</button>");
5735 }
5736
5737 #[test]
5738 fn test_boolean_attr_missing() {
5739 let mut fragments = HashMap::new();
5740 fragments.insert(
5741 "index.html".to_string(),
5742 FragmentList {
5743 fragments: vec![
5744 WebUIFragment::raw("<input type=\"checkbox\""),
5745 WebUIFragment::attribute_boolean(
5746 "checked",
5747 ConditionExpr::identifier("checked"),
5748 ),
5749 WebUIFragment::raw(">"),
5750 ],
5751 contains_boundary: false,
5752 },
5753 );
5754 let protocol = WebUIProtocol::new(fragments);
5755 let state = test_json!({});
5756 let mut writer = TestWriter::new();
5757 handle(
5758 &protocol,
5759 &state,
5760 &RenderOptions::new("index.html", "/"),
5761 &mut writer,
5762 )
5763 .unwrap();
5764 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5765 }
5766
5767 #[test]
5768 fn test_boolean_attr_multiple() {
5769 let mut fragments = HashMap::new();
5770 fragments.insert(
5771 "index.html".to_string(),
5772 FragmentList {
5773 fragments: vec![
5774 WebUIFragment::raw("<input type=\"checkbox\""),
5775 WebUIFragment::attribute_boolean(
5776 "checked",
5777 ConditionExpr::identifier("checked"),
5778 ),
5779 WebUIFragment::attribute_boolean(
5780 "disabled",
5781 ConditionExpr::identifier("disabled"),
5782 ),
5783 WebUIFragment::raw(">"),
5784 ],
5785 contains_boundary: false,
5786 },
5787 );
5788 let protocol = WebUIProtocol::new(fragments);
5789 let state = test_json!({"checked": true, "disabled": false});
5790 let mut writer = TestWriter::new();
5791 handle(
5792 &protocol,
5793 &state,
5794 &RenderOptions::new("index.html", "/"),
5795 &mut writer,
5796 )
5797 .unwrap();
5798 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5799 }
5800
5801 #[test]
5804 fn test_attribute_with_value() {
5805 let mut fragments = HashMap::new();
5806 fragments.insert(
5807 "index.html".to_string(),
5808 FragmentList {
5809 fragments: vec![
5810 WebUIFragment::raw("<input"),
5811 WebUIFragment::attribute("value", "inputValue"),
5812 WebUIFragment::raw(">"),
5813 ],
5814 contains_boundary: false,
5815 },
5816 );
5817 let protocol = WebUIProtocol::new(fragments);
5818 let state = test_json!({"inputValue": "Hello"});
5819 let mut writer = TestWriter::new();
5820 handle(
5821 &protocol,
5822 &state,
5823 &RenderOptions::new("index.html", "/"),
5824 &mut writer,
5825 )
5826 .unwrap();
5827 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5828 }
5829
5830 #[test]
5831 fn test_dynamic_attribute_preserves_nullish_and_falsy_values() -> Result<()> {
5832 let mut fragments = HashMap::new();
5833 fragments.insert(
5834 "index.html".to_string(),
5835 FragmentList {
5836 fragments: vec![
5837 WebUIFragment::raw("<input"),
5838 WebUIFragment::attribute("pattern", "pattern"),
5839 WebUIFragment::raw(">"),
5840 ],
5841 contains_boundary: false,
5842 },
5843 );
5844 let protocol = WebUIProtocol::new(fragments);
5845
5846 for (state, expected) in [
5847 (test_json!({}), "<input>"),
5848 (test_json!({"pattern": null}), "<input>"),
5849 (test_json!({"pattern": ""}), "<input pattern=\"\">"),
5850 (test_json!({"pattern": 0}), "<input pattern=\"0\">"),
5851 (test_json!({"pattern": false}), "<input pattern=\"false\">"),
5852 (
5853 test_json!({"pattern": "[a-z]+"}),
5854 "<input pattern=\"[a-z]+\">",
5855 ),
5856 ] {
5857 let mut writer = TestWriter::new();
5858 handle(
5859 &protocol,
5860 &state,
5861 &RenderOptions::new("index.html", "/"),
5862 &mut writer,
5863 )?;
5864 assert_eq!(writer.get_content(), expected, "state: {state}");
5865 }
5866 Ok(())
5867 }
5868
5869 #[test]
5870 fn test_dynamic_attribute_omission_preserves_component_props() -> Result<()> {
5871 let mut fragments = HashMap::new();
5872 fragments.insert(
5873 "index.html".to_string(),
5874 FragmentList {
5875 fragments: vec![
5876 WebUIFragment::raw("<my-input"),
5877 WebUIFragment {
5878 fragment: Some(web_ui_fragment::Fragment::Attribute(
5879 WebUIFragmentAttribute {
5880 name: "pattern".into(),
5881 value: "constraint".into(),
5882 attr_start: true,
5883 ..Default::default()
5884 },
5885 )),
5886 },
5887 WebUIFragment::raw(">"),
5888 WebUIFragment::component("my-input"),
5889 WebUIFragment::raw("</my-input>"),
5890 ],
5891 contains_boundary: false,
5892 },
5893 );
5894 fragments.insert(
5895 "my-input".to_string(),
5896 FragmentList {
5897 fragments: vec![
5898 WebUIFragment::raw("<input"),
5899 WebUIFragment::attribute("pattern", "pattern"),
5900 WebUIFragment::raw(">"),
5901 WebUIFragment::signal("pattern", false),
5902 ],
5903 contains_boundary: false,
5904 },
5905 );
5906 let protocol = WebUIProtocol::new(fragments);
5907
5908 for (state, expected) in [
5909 (test_json!({}), "<my-input><input pattern=\"\"></my-input>"),
5910 (
5911 test_json!({"constraint": null}),
5912 "<my-input><input>null</my-input>",
5913 ),
5914 (
5915 test_json!({"pattern": "global"}),
5916 "<my-input><input pattern=\"\"></my-input>",
5917 ),
5918 (
5919 test_json!({"constraint": ""}),
5920 "<my-input pattern=\"\"><input pattern=\"\"></my-input>",
5921 ),
5922 (
5923 test_json!({"constraint": "[a-z]+"}),
5924 "<my-input pattern=\"[a-z]+\"><input pattern=\"[a-z]+\">[a-z]+</my-input>",
5925 ),
5926 ] {
5927 let mut writer = TestWriter::new();
5928 handle(
5929 &protocol,
5930 &state,
5931 &RenderOptions::new("index.html", "/"),
5932 &mut writer,
5933 )?;
5934 assert_eq!(writer.get_content(), expected, "state: {state}");
5935 }
5936 Ok(())
5937 }
5938
5939 #[test]
5940 fn test_attribute_with_falsy_numeric() {
5941 let mut fragments = HashMap::new();
5942 fragments.insert(
5943 "index.html".to_string(),
5944 FragmentList {
5945 fragments: vec![
5946 WebUIFragment::raw("<div name=\"test\""),
5947 WebUIFragment::attribute("handle", "number"),
5948 WebUIFragment::raw("></div>"),
5949 ],
5950 contains_boundary: false,
5951 },
5952 );
5953 let protocol = WebUIProtocol::new(fragments);
5954 let state = test_json!({"number": 0});
5955 let mut writer = TestWriter::new();
5956 handle(
5957 &protocol,
5958 &state,
5959 &RenderOptions::new("index.html", "/"),
5960 &mut writer,
5961 )
5962 .unwrap();
5963 assert_eq!(
5964 writer.get_content(),
5965 "<div name=\"test\" handle=\"0\"></div>"
5966 );
5967 }
5968
5969 #[test]
5972 fn test_attribute_array_value_is_escaped() {
5973 let mut fragments = HashMap::new();
5974 fragments.insert(
5975 "index.html".to_string(),
5976 FragmentList {
5977 fragments: vec![
5978 WebUIFragment::raw("<a"),
5979 WebUIFragment::attribute("href", "value"),
5980 WebUIFragment::raw(">demo</a>"),
5981 ],
5982 contains_boundary: false,
5983 },
5984 );
5985 let protocol = WebUIProtocol::new(fragments);
5986 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5987 let mut writer = TestWriter::new();
5988 handle(
5989 &protocol,
5990 &state,
5991 &RenderOptions::new("index.html", "/"),
5992 &mut writer,
5993 )
5994 .unwrap();
5995 let content = writer.get_content();
5996 assert!(
5999 content.contains("""),
6000 "Double quotes inside attribute value must be escaped: {content}"
6001 );
6002 assert_eq!(
6005 content.matches("=\"").count(),
6006 1,
6007 "Only one attribute assignment expected: {content}"
6008 );
6009 }
6010
6011 #[test]
6012 fn test_attribute_object_value_is_escaped() {
6013 let mut fragments = HashMap::new();
6014 fragments.insert(
6015 "index.html".to_string(),
6016 FragmentList {
6017 fragments: vec![
6018 WebUIFragment::raw("<div"),
6019 WebUIFragment::attribute("data-cfg", "cfg"),
6020 WebUIFragment::raw("></div>"),
6021 ],
6022 contains_boundary: false,
6023 },
6024 );
6025 let protocol = WebUIProtocol::new(fragments);
6026 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
6027 let mut writer = TestWriter::new();
6028 handle(
6029 &protocol,
6030 &state,
6031 &RenderOptions::new("index.html", "/"),
6032 &mut writer,
6033 )
6034 .unwrap();
6035 let content = writer.get_content();
6036 assert!(
6037 content.contains("""),
6038 "Double quotes inside attribute value must be escaped: {content}"
6039 );
6040 assert_eq!(
6041 content.matches("=\"").count(),
6042 1,
6043 "Only one attribute assignment expected: {content}"
6044 );
6045 }
6046
6047 #[test]
6050 fn test_mixed_attribute_template() {
6051 let mut fragments = HashMap::new();
6052 fragments.insert(
6053 "index.html".to_string(),
6054 FragmentList {
6055 fragments: vec![
6056 WebUIFragment::raw("<input"),
6057 WebUIFragment::attribute_template("value", "attr-1"),
6058 WebUIFragment::raw(">"),
6059 ],
6060 contains_boundary: false,
6061 },
6062 );
6063 fragments.insert(
6064 "attr-1".to_string(),
6065 FragmentList {
6066 fragments: vec![
6067 WebUIFragment::raw("hello "),
6068 WebUIFragment::signal("item", false),
6069 ],
6070 contains_boundary: false,
6071 },
6072 );
6073 let protocol = WebUIProtocol::new(fragments);
6074 let state = test_json!({"item": "world"});
6075 let mut writer = TestWriter::new();
6076 handle(
6077 &protocol,
6078 &state,
6079 &RenderOptions::new("index.html", "/"),
6080 &mut writer,
6081 )
6082 .unwrap();
6083 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
6084 }
6085
6086 #[test]
6089 fn test_raw_signal_not_escaped() {
6090 let mut fragments = HashMap::new();
6091 fragments.insert(
6092 "index.html".to_string(),
6093 FragmentList {
6094 fragments: vec![
6095 WebUIFragment::signal("html", false),
6096 WebUIFragment::signal("html", true),
6097 ],
6098 contains_boundary: false,
6099 },
6100 );
6101 let protocol = WebUIProtocol::new(fragments);
6102 let state = test_json!({"html": "<strong>hi</strong>"});
6103 let mut writer = TestWriter::new();
6104 handle(
6105 &protocol,
6106 &state,
6107 &RenderOptions::new("index.html", "/"),
6108 &mut writer,
6109 )
6110 .unwrap();
6111 assert_eq!(
6112 writer.get_content(),
6113 "<strong>hi</strong><strong>hi</strong>"
6114 );
6115 }
6116
6117 fn render_named_for(template: &str, state: &Value) -> String {
6120 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6121 parser.parse("index.html", template).unwrap();
6122 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
6123 let mut writer = TestWriter::new();
6124 WebUIHandler::new()
6125 .render(
6126 &protocol,
6127 state,
6128 &RenderOptions::new("index.html", "/"),
6129 &mut writer,
6130 )
6131 .unwrap();
6132 writer.get_content()
6133 }
6134
6135 #[test]
6136 fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
6137 let source = r#"<ul><for each="child in items" id="tree-item"><li>{{child.name}}</li><if condition="{{child.children}}"><ul><for id="tree-item" each="child in child.children" /></ul></if><i>{{child.name}}</i></for></ul><b>{{child.name}}</b>"#;
6138 let state = test_json!({
6139 "child": { "name": "global" },
6140 "items": [
6141 { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
6142 { "name": "Name", "children": [] },
6143 { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
6144 ]
6145 });
6146 assert_eq!(render_named_for(source, &state), "<ul><li>Colors</li><ul><li>Ali</li><i>Ali</i><li>Alice</li><i>Alice</i><li>Bob</li><i>Bob</i></ul><i>Colors</i><li>Name</li><i>Name</i><li>Hobbies</li><ul><li>Sports</li><ul><li>Futbol</li><i>Futbol</i><li>Cricket</li><i>Cricket</i></ul><i>Sports</i></ul><i>Hobbies</i></ul><b>global</b>");
6147 }
6148
6149 #[test]
6150 fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
6151 let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
6152 let state = test_json!({
6153 "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
6154 });
6155 assert_eq!(render_named_for(source, &state), "abbcca");
6156 assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
6157 }
6158
6159 #[test]
6160 fn named_for_forward_and_mutual_references_follow_finite_data() {
6161 let source = r#"<for id="a" each="item in items" /><for id="a" each="item in empty"><b>{{item.name}}</b><for id="b" each="item in item.children" /></for><for id="b" each="item in empty"><i>{{item.name}}</i><for id="a" each="item in item.children" /></for>"#;
6162 let state = test_json!({
6163 "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
6164 "empty": []
6165 });
6166 assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
6167 }
6168
6169 #[test]
6170 fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
6171 let source = r#"<for each="group in groups"><for id="tree" each="item in group.items">{{group.name}}:{{item.name}};<for id="tree" each="item in item.children" /></for></for>"#;
6172 let state = test_json!({
6173 "groups": [
6174 { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
6175 { "name": "two", "items": [{ "name": "last" }] }
6176 ]
6177 });
6178 assert_eq!(
6179 render_named_for(source, &state),
6180 "one:<root>;one:&leaf;two:last;"
6181 );
6182 }
6183
6184 #[test]
6185 fn named_for_non_array_collection_returns_the_existing_type_error() {
6186 let mut parser = HtmlParser::new();
6187 parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
6188 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
6189 let mut writer = TestWriter::new();
6190 let error = WebUIHandler::new()
6191 .render(
6192 &protocol,
6193 &test_json!({"items": [{"children": 1}]}),
6194 &RenderOptions::new("index.html", "/"),
6195 &mut writer,
6196 )
6197 .unwrap_err();
6198 assert!(matches!(error, HandlerError::TypeError(_)));
6199 }
6200
6201 #[test]
6202 fn test_nested_for_loop() {
6203 let mut fragments = HashMap::new();
6204 fragments.insert(
6205 "index.html".to_string(),
6206 FragmentList {
6207 fragments: vec![
6208 WebUIFragment::raw("<div>"),
6209 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
6210 WebUIFragment::raw("</div>"),
6211 ],
6212 contains_boundary: false,
6213 },
6214 );
6215 fragments.insert(
6216 "outer".to_string(),
6217 FragmentList {
6218 fragments: vec![
6219 WebUIFragment::raw("<div>"),
6220 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
6221 WebUIFragment::raw("</div>"),
6222 ],
6223 contains_boundary: false,
6224 },
6225 );
6226 fragments.insert(
6227 "inner".to_string(),
6228 FragmentList {
6229 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
6230 contains_boundary: false,
6231 },
6232 );
6233 let protocol = WebUIProtocol::new(fragments);
6234 let state = test_json!({
6235 "outerItems": [
6236 {"innerItems": [{"name": "A"}, {"name": "B"}]},
6237 {"innerItems": [{"name": "C"}]}
6238 ]
6239 });
6240 let mut writer = TestWriter::new();
6241 handle(
6242 &protocol,
6243 &state,
6244 &RenderOptions::new("index.html", "/"),
6245 &mut writer,
6246 )
6247 .unwrap();
6248 assert_eq!(
6249 writer.get_content(),
6250 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
6251 );
6252 }
6253
6254 #[test]
6255 fn test_nested_for_with_signals() {
6256 let mut fragments = HashMap::new();
6257 fragments.insert(
6258 "index.html".to_string(),
6259 FragmentList {
6260 fragments: vec![
6261 WebUIFragment::raw("<div>"),
6262 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6263 WebUIFragment::raw("</div>"),
6264 ],
6265 contains_boundary: false,
6266 },
6267 );
6268 fragments.insert(
6269 "outerTemplate".to_string(),
6270 FragmentList {
6271 fragments: vec![
6272 WebUIFragment::raw("<div>"),
6273 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6274 WebUIFragment::raw("</div>"),
6275 ],
6276 contains_boundary: false,
6277 },
6278 );
6279 fragments.insert(
6280 "innerTemplate".to_string(),
6281 FragmentList {
6282 fragments: vec![
6283 WebUIFragment::raw("<span>"),
6284 WebUIFragment::signal("innerItem.name", false),
6285 WebUIFragment::raw("</span>"),
6286 ],
6287 contains_boundary: false,
6288 },
6289 );
6290 let protocol = WebUIProtocol::new(fragments);
6291 let state = test_json!({
6292 "outerItems": [
6293 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6294 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
6295 ]
6296 });
6297 let mut writer = TestWriter::new();
6298 handle(
6299 &protocol,
6300 &state,
6301 &RenderOptions::new("index.html", "/"),
6302 &mut writer,
6303 )
6304 .unwrap();
6305 assert_eq!(
6306 writer.get_content(),
6307 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
6308 );
6309 }
6310
6311 #[test]
6312 fn test_nested_for_with_global_state() {
6313 let mut fragments = HashMap::new();
6314 fragments.insert(
6315 "index.html".to_string(),
6316 FragmentList {
6317 fragments: vec![
6318 WebUIFragment::raw("<div>"),
6319 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6320 WebUIFragment::raw("</div>"),
6321 ],
6322 contains_boundary: false,
6323 },
6324 );
6325 fragments.insert(
6326 "outerTemplate".to_string(),
6327 FragmentList {
6328 fragments: vec![
6329 WebUIFragment::raw("<div>"),
6330 WebUIFragment::signal("globalOuter", false),
6331 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6332 WebUIFragment::raw("</div>"),
6333 ],
6334 contains_boundary: false,
6335 },
6336 );
6337 fragments.insert(
6338 "innerTemplate".to_string(),
6339 FragmentList {
6340 fragments: vec![
6341 WebUIFragment::raw("<span>"),
6342 WebUIFragment::signal("innerItem.name", false),
6343 WebUIFragment::signal("globalInner", false),
6344 WebUIFragment::raw("</span>"),
6345 ],
6346 contains_boundary: false,
6347 },
6348 );
6349 let protocol = WebUIProtocol::new(fragments);
6350 let state = test_json!({
6351 "globalOuter": "GO",
6352 "globalInner": "GI",
6353 "outerItems": [
6354 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6355 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
6356 ]
6357 });
6358 let mut writer = TestWriter::new();
6359 handle(
6360 &protocol,
6361 &state,
6362 &RenderOptions::new("index.html", "/"),
6363 &mut writer,
6364 )
6365 .unwrap();
6366 assert_eq!(
6367 writer.get_content(),
6368 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
6369 );
6370 }
6371
6372 #[test]
6375 fn test_if_in_for_uses_local_state() {
6376 let mut fragments = HashMap::new();
6377 fragments.insert(
6378 "index.html".to_string(),
6379 FragmentList {
6380 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6381 contains_boundary: false,
6382 },
6383 );
6384 fragments.insert(
6385 "item-tpl".to_string(),
6386 FragmentList {
6387 fragments: vec![WebUIFragment::if_cond(
6388 ConditionExpr::identifier("item.visible"),
6389 "visible-tpl",
6390 )],
6391 contains_boundary: false,
6392 },
6393 );
6394 fragments.insert(
6395 "visible-tpl".to_string(),
6396 FragmentList {
6397 fragments: vec![WebUIFragment::signal("item.name", false)],
6398 contains_boundary: false,
6399 },
6400 );
6401 let protocol = WebUIProtocol::new(fragments);
6402 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
6403 let mut writer = TestWriter::new();
6404 handle(
6405 &protocol,
6406 &state,
6407 &RenderOptions::new("index.html", "/"),
6408 &mut writer,
6409 )
6410 .unwrap();
6411 assert_eq!(writer.get_content(), "Show");
6412 }
6413
6414 #[test]
6415 fn test_for_if_local_overrides_global() {
6416 let mut fragments = HashMap::new();
6417 fragments.insert(
6418 "index.html".to_string(),
6419 FragmentList {
6420 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6421 contains_boundary: false,
6422 },
6423 );
6424 fragments.insert(
6425 "item-tpl".to_string(),
6426 FragmentList {
6427 fragments: vec![WebUIFragment::if_cond(
6428 ConditionExpr::identifier("item.flag"),
6429 "show-tpl",
6430 )],
6431 contains_boundary: false,
6432 },
6433 );
6434 fragments.insert(
6435 "show-tpl".to_string(),
6436 FragmentList {
6437 fragments: vec![WebUIFragment::raw("yes")],
6438 contains_boundary: false,
6439 },
6440 );
6441 let protocol = WebUIProtocol::new(fragments);
6442 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6444 let mut writer = TestWriter::new();
6445 handle(
6446 &protocol,
6447 &state,
6448 &RenderOptions::new("index.html", "/"),
6449 &mut writer,
6450 )
6451 .unwrap();
6452 assert_eq!(writer.get_content(), "yes");
6453 }
6454
6455 #[test]
6458 fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6459 let cases = [
6460 (
6461 r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6462 test_json!({}),
6463 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6464 ),
6465 (
6466 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6467 test_json!({"open": true}),
6468 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6469 ),
6470 (
6471 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6472 test_json!({"open": false}),
6473 r#"<dialog aria-label="Canvas information"></dialog>"#,
6474 ),
6475 (
6476 r#"<my-drawer aria-label=""></my-drawer>"#,
6477 test_json!({"ariaLabel": "Fallback"}),
6478 r#"<dialog aria-label=""></dialog>"#,
6479 ),
6480 (
6481 r#"<my-drawer open aria-label="Canvas & information"></my-drawer>"#,
6482 test_json!({}),
6483 r#"<dialog open aria-label="Canvas & information"></dialog>"#,
6484 ),
6485 ];
6486 for (input, state, expected) in cases {
6487 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6488 parser
6489 .component_registry_mut()
6490 .register_component(ComponentRegistration::new(
6491 "my-drawer",
6492 r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6493 None,
6494 true,
6495 ))
6496 .unwrap();
6497 parser.parse("index.html", input).unwrap();
6498 let protocol = WebUIProtocol::new(parser.into_fragment_records());
6499 let mut writer = TestWriter::new();
6500 handle(
6501 &protocol,
6502 &state,
6503 &RenderOptions::new("index.html", "/"),
6504 &mut writer,
6505 )
6506 .unwrap();
6507 assert!(
6508 writer.get_content().contains(expected),
6509 "{input}: {}",
6510 writer.get_content()
6511 );
6512 }
6513 }
6514
6515 #[test]
6516 fn test_component_attr_state_simple() {
6517 let mut fragments = HashMap::new();
6518 fragments.insert(
6519 "index.html".to_string(),
6520 FragmentList {
6521 fragments: vec![
6522 WebUIFragment::raw("<my-comp"),
6523 WebUIFragment {
6524 fragment: Some(web_ui_fragment::Fragment::Attribute(
6525 WebUIFragmentAttribute {
6526 name: "title".into(),
6527 value: "Attribute Title".into(),
6528 attr_start: true,
6529 raw_value: true,
6530 ..Default::default()
6531 },
6532 )),
6533 },
6534 WebUIFragment::raw(">"),
6535 WebUIFragment::component("my-comp"),
6536 WebUIFragment::raw("</my-comp>"),
6537 ],
6538
6539 contains_boundary: false,
6540 },
6541 );
6542 fragments.insert(
6543 "my-comp".to_string(),
6544 FragmentList {
6545 fragments: vec![
6546 WebUIFragment::raw("<span>"),
6547 WebUIFragment::signal("title", false),
6548 WebUIFragment::raw("</span>"),
6549 ],
6550 contains_boundary: false,
6551 },
6552 );
6553 let protocol = WebUIProtocol::new(fragments);
6554 let state = test_json!({"title": "Global Title"});
6555 let mut writer = TestWriter::new();
6556 handle(
6557 &protocol,
6558 &state,
6559 &RenderOptions::new("index.html", "/"),
6560 &mut writer,
6561 )
6562 .unwrap();
6563 assert_eq!(
6564 writer.get_content(),
6565 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6566 );
6567 }
6568
6569 #[test]
6570 fn test_component_attr_state_template() {
6571 let mut fragments = HashMap::new();
6572 fragments.insert(
6573 "index.html".to_string(),
6574 FragmentList {
6575 fragments: vec![
6576 WebUIFragment::raw("<my-comp"),
6577 WebUIFragment {
6578 fragment: Some(web_ui_fragment::Fragment::Attribute(
6579 WebUIFragmentAttribute {
6580 name: "title".into(),
6581 template: "title-attr".into(),
6582 attr_start: true,
6583 ..Default::default()
6584 },
6585 )),
6586 },
6587 WebUIFragment::raw(">"),
6588 WebUIFragment::component("my-comp"),
6589 WebUIFragment::raw("</my-comp>"),
6590 ],
6591
6592 contains_boundary: false,
6593 },
6594 );
6595 fragments.insert(
6596 "title-attr".to_string(),
6597 FragmentList {
6598 fragments: vec![
6599 WebUIFragment::raw("hello "),
6600 WebUIFragment::signal("item", false),
6601 ],
6602 contains_boundary: false,
6603 },
6604 );
6605 fragments.insert(
6606 "my-comp".to_string(),
6607 FragmentList {
6608 fragments: vec![
6609 WebUIFragment::raw("<span>"),
6610 WebUIFragment::signal("title", false),
6611 WebUIFragment::raw("</span>"),
6612 ],
6613 contains_boundary: false,
6614 },
6615 );
6616 let protocol = WebUIProtocol::new(fragments);
6617 let state = test_json!({"item": "<world>"});
6618 let mut writer = TestWriter::new();
6619 handle(
6620 &protocol,
6621 &state,
6622 &RenderOptions::new("index.html", "/"),
6623 &mut writer,
6624 )
6625 .unwrap();
6626 assert_eq!(
6627 writer.get_content(),
6628 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
6629 );
6630 }
6631
6632 #[test]
6633 fn test_component_attr_camel_case() {
6634 let mut fragments = HashMap::new();
6635 fragments.insert(
6636 "index.html".to_string(),
6637 FragmentList {
6638 fragments: vec![
6639 WebUIFragment::raw("<my-comp"),
6640 WebUIFragment {
6641 fragment: Some(web_ui_fragment::Fragment::Attribute(
6642 WebUIFragmentAttribute {
6643 name: "data-title".into(),
6644 template: "dt-attr".into(),
6645 attr_start: true,
6646 ..Default::default()
6647 },
6648 )),
6649 },
6650 WebUIFragment::raw(">"),
6651 WebUIFragment::component("my-comp"),
6652 WebUIFragment::raw("</my-comp>"),
6653 ],
6654
6655 contains_boundary: false,
6656 },
6657 );
6658 fragments.insert(
6659 "dt-attr".to_string(),
6660 FragmentList {
6661 fragments: vec![
6662 WebUIFragment::raw("prefix "),
6663 WebUIFragment::signal("item", false),
6664 ],
6665 contains_boundary: false,
6666 },
6667 );
6668 fragments.insert(
6669 "my-comp".to_string(),
6670 FragmentList {
6671 fragments: vec![
6672 WebUIFragment::raw("<span>"),
6673 WebUIFragment::signal("dataTitle", false),
6674 WebUIFragment::raw("</span>"),
6675 ],
6676 contains_boundary: false,
6677 },
6678 );
6679 let protocol = WebUIProtocol::new(fragments);
6680 let state = test_json!({"item": "a&b"});
6681 let mut writer = TestWriter::new();
6682 handle(
6683 &protocol,
6684 &state,
6685 &RenderOptions::new("index.html", "/"),
6686 &mut writer,
6687 )
6688 .unwrap();
6689 assert_eq!(
6690 writer.get_content(),
6691 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
6692 );
6693 }
6694
6695 #[test]
6696 fn test_component_complex_attr() {
6697 let mut fragments = HashMap::new();
6698 fragments.insert(
6699 "index.html".to_string(),
6700 FragmentList {
6701 fragments: vec![
6702 WebUIFragment::raw("<my-comp"),
6703 WebUIFragment {
6704 fragment: Some(web_ui_fragment::Fragment::Attribute(
6705 WebUIFragmentAttribute {
6706 name: ":item".into(),
6707 value: "complexItem".into(),
6708 attr_start: true,
6709 complex: true,
6710 ..Default::default()
6711 },
6712 )),
6713 },
6714 WebUIFragment::raw(">"),
6715 WebUIFragment::component("my-comp"),
6716 WebUIFragment::raw("</my-comp>"),
6717 ],
6718
6719 contains_boundary: false,
6720 },
6721 );
6722 fragments.insert(
6723 "my-comp".to_string(),
6724 FragmentList {
6725 fragments: vec![
6726 WebUIFragment::raw("<span>"),
6727 WebUIFragment::signal("item.foo", false),
6728 WebUIFragment::raw("</span><p>"),
6729 WebUIFragment::signal("item.bar", false),
6730 WebUIFragment::raw("</p>"),
6731 ],
6732 contains_boundary: false,
6733 },
6734 );
6735 let protocol = WebUIProtocol::new(fragments);
6736 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6737 let mut writer = TestWriter::new();
6738 handle(
6739 &protocol,
6740 &state,
6741 &RenderOptions::new("index.html", "/"),
6742 &mut writer,
6743 )
6744 .unwrap();
6745 assert_eq!(
6746 writer.get_content(),
6747 "<my-comp><span>1</span><p>true</p></my-comp>"
6748 );
6749 }
6750
6751 #[test]
6752 fn test_component_no_parent_pollution() {
6753 let mut fragments = HashMap::new();
6754 fragments.insert(
6755 "index.html".to_string(),
6756 FragmentList {
6757 fragments: vec![
6758 WebUIFragment::raw("<parent"),
6759 WebUIFragment {
6760 fragment: Some(web_ui_fragment::Fragment::Attribute(
6761 WebUIFragmentAttribute {
6762 name: "var".into(),
6763 value: "var".into(),
6764 attr_start: true,
6765 ..Default::default()
6766 },
6767 )),
6768 },
6769 WebUIFragment::raw(">"),
6770 WebUIFragment::component("parent"),
6771 WebUIFragment::raw("</parent>"),
6772 ],
6773
6774 contains_boundary: false,
6775 },
6776 );
6777 fragments.insert(
6778 "parent".to_string(),
6779 FragmentList {
6780 fragments: vec![
6781 WebUIFragment::raw("Before: "),
6782 WebUIFragment::signal("var", false),
6783 WebUIFragment::raw("<child foo"),
6784 WebUIFragment {
6785 fragment: Some(web_ui_fragment::Fragment::Attribute(
6786 WebUIFragmentAttribute {
6787 name: "var".into(),
6788 value: "replaced".into(),
6789 raw_value: true,
6790 ..Default::default()
6791 },
6792 )),
6793 },
6794 WebUIFragment::raw(">"),
6795 WebUIFragment::component("child"),
6796 WebUIFragment::raw("Label</child>After: "),
6797 WebUIFragment::signal("var", false),
6798 ],
6799
6800 contains_boundary: false,
6801 },
6802 );
6803 fragments.insert(
6804 "child".to_string(),
6805 FragmentList {
6806 fragments: vec![],
6807 contains_boundary: false,
6808 },
6809 );
6810 let protocol = WebUIProtocol::new(fragments);
6811 let state = test_json!({"var": "original<&"});
6812 let mut writer = TestWriter::new();
6813 handle(
6814 &protocol,
6815 &state,
6816 &RenderOptions::new("index.html", "/"),
6817 &mut writer,
6818 )
6819 .unwrap();
6820 assert_eq!(
6821 writer.get_content(),
6822 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
6823 );
6824 }
6825
6826 #[test]
6827 fn test_component_boolean_attr_state() {
6828 let mut fragments = HashMap::new();
6829 fragments.insert(
6830 "index.html".to_string(),
6831 FragmentList {
6832 fragments: vec![
6833 WebUIFragment::raw("<my-comp"),
6834 WebUIFragment {
6835 fragment: Some(web_ui_fragment::Fragment::Attribute(
6836 WebUIFragmentAttribute {
6837 name: "disabled".into(),
6838 attr_start: true,
6839 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6840 ..Default::default()
6841 },
6842 )),
6843 },
6844 WebUIFragment::raw(">"),
6845 WebUIFragment::component("my-comp"),
6846 WebUIFragment::raw("</my-comp>"),
6847 ],
6848
6849 contains_boundary: false,
6850 },
6851 );
6852 fragments.insert(
6853 "my-comp".to_string(),
6854 FragmentList {
6855 fragments: vec![WebUIFragment::if_cond(
6856 ConditionExpr::identifier("disabled"),
6857 "show",
6858 )],
6859 contains_boundary: false,
6860 },
6861 );
6862 fragments.insert(
6863 "show".to_string(),
6864 FragmentList {
6865 fragments: vec![WebUIFragment::raw("disabled!")],
6866 contains_boundary: false,
6867 },
6868 );
6869 let protocol = WebUIProtocol::new(fragments);
6870 let state = test_json!({"isDisabled": true});
6871 let mut writer = TestWriter::new();
6872 handle(
6873 &protocol,
6874 &state,
6875 &RenderOptions::new("index.html", "/"),
6876 &mut writer,
6877 )
6878 .unwrap();
6879 assert_eq!(
6880 writer.get_content(),
6881 "<my-comp disabled>disabled!</my-comp>"
6882 );
6883 }
6884
6885 fn render_signal(value: &str) -> String {
6889 let mut fragments = HashMap::new();
6890 fragments.insert(
6891 "index.html".to_string(),
6892 FragmentList {
6893 fragments: vec![WebUIFragment::signal("v", false)],
6894 contains_boundary: false,
6895 },
6896 );
6897 let protocol = WebUIProtocol::new(fragments);
6898 let state = test_json!({"v": value});
6899 let mut writer = TestWriter::new();
6900 handle(
6901 &protocol,
6902 &state,
6903 &RenderOptions::new("index.html", "/"),
6904 &mut writer,
6905 )
6906 .unwrap();
6907 writer.get_content()
6908 }
6909
6910 #[test]
6911 fn test_escape_ampersand() {
6912 assert_eq!(render_signal("&"), "&");
6913 }
6914
6915 #[test]
6916 fn test_escape_less_than() {
6917 assert_eq!(render_signal("<"), "<");
6918 }
6919
6920 #[test]
6921 fn test_escape_greater_than() {
6922 assert_eq!(render_signal(">"), ">");
6923 }
6924
6925 #[test]
6926 fn test_escape_double_quote() {
6927 assert_eq!(render_signal("\""), """);
6928 }
6929
6930 #[test]
6931 fn test_escape_single_quote() {
6932 let result = render_signal("'");
6934 assert_eq!(
6935 result, "'",
6936 "Expected ' for single quote, got: {result}"
6937 );
6938 }
6939
6940 #[test]
6941 fn test_escape_multiple_special_chars() {
6942 let result = render_signal("<script>alert('xss');</script>");
6943 assert!(
6944 result.contains("<") && result.contains(">"),
6945 "Expected escaped HTML, got: {}",
6946 result
6947 );
6948 assert!(
6949 !result.contains("<script>"),
6950 "Should not contain raw <script> tag"
6951 );
6952 }
6953
6954 #[test]
6955 fn test_escape_no_special_chars() {
6956 assert_eq!(render_signal("Hello World"), "Hello World");
6957 }
6958
6959 #[test]
6960 fn test_escape_empty_string() {
6961 assert_eq!(render_signal(""), "");
6962 }
6963
6964 #[test]
6965 fn test_escape_special_at_beginning() {
6966 let result = render_signal("<Hello");
6967 assert!(
6968 result.starts_with("<"),
6969 "Expected < at start, got: {}",
6970 result
6971 );
6972 }
6973
6974 #[test]
6975 fn test_escape_special_at_end() {
6976 let result = render_signal("Hello>");
6977 assert!(
6978 result.ends_with(">"),
6979 "Expected > at end, got: {}",
6980 result
6981 );
6982 }
6983
6984 #[test]
6985 fn test_escape_special_in_middle() {
6986 let result = render_signal("Hel&lo");
6987 assert!(
6988 result.contains("&"),
6989 "Expected & in middle, got: {}",
6990 result
6991 );
6992 }
6993
6994 #[test]
6997 fn test_boolean_attr_truthy_values() {
6998 {
7000 let mut fragments = HashMap::new();
7001 fragments.insert(
7002 "index.html".to_string(),
7003 FragmentList {
7004 fragments: vec![
7005 WebUIFragment::raw("<input"),
7006 WebUIFragment::attribute_boolean(
7007 "checked",
7008 ConditionExpr::identifier("checked"),
7009 ),
7010 WebUIFragment::raw(">"),
7011 ],
7012 contains_boundary: false,
7013 },
7014 );
7015 let protocol = WebUIProtocol::new(fragments);
7016 let state = test_json!({"checked": 1});
7017 let mut writer = TestWriter::new();
7018 handle(
7019 &protocol,
7020 &state,
7021 &RenderOptions::new("index.html", "/"),
7022 &mut writer,
7023 )
7024 .unwrap();
7025 assert_eq!(writer.get_content(), "<input checked>");
7026 }
7027 {
7029 let mut fragments = HashMap::new();
7030 fragments.insert(
7031 "index.html".to_string(),
7032 FragmentList {
7033 fragments: vec![
7034 WebUIFragment::raw("<input"),
7035 WebUIFragment::attribute_boolean(
7036 "checked",
7037 ConditionExpr::identifier("checked"),
7038 ),
7039 WebUIFragment::raw(">"),
7040 ],
7041 contains_boundary: false,
7042 },
7043 );
7044 let protocol = WebUIProtocol::new(fragments);
7045 let state = test_json!({"checked": "yes"});
7046 let mut writer = TestWriter::new();
7047 handle(
7048 &protocol,
7049 &state,
7050 &RenderOptions::new("index.html", "/"),
7051 &mut writer,
7052 )
7053 .unwrap();
7054 assert_eq!(writer.get_content(), "<input checked>");
7055 }
7056 {
7058 let mut fragments = HashMap::new();
7059 fragments.insert(
7060 "index.html".to_string(),
7061 FragmentList {
7062 fragments: vec![
7063 WebUIFragment::raw("<input"),
7064 WebUIFragment::attribute_boolean(
7065 "checked",
7066 ConditionExpr::identifier("checked"),
7067 ),
7068 WebUIFragment::raw(">"),
7069 ],
7070 contains_boundary: false,
7071 },
7072 );
7073 let protocol = WebUIProtocol::new(fragments);
7074 let state = test_json!({"checked": {}});
7075 let mut writer = TestWriter::new();
7076 handle(
7077 &protocol,
7078 &state,
7079 &RenderOptions::new("index.html", "/"),
7080 &mut writer,
7081 )
7082 .unwrap();
7083 assert_eq!(writer.get_content(), "<input>");
7085 }
7086 {
7088 let mut fragments = HashMap::new();
7089 fragments.insert(
7090 "index.html".to_string(),
7091 FragmentList {
7092 fragments: vec![
7093 WebUIFragment::raw("<input"),
7094 WebUIFragment::attribute_boolean(
7095 "checked",
7096 ConditionExpr::identifier("checked"),
7097 ),
7098 WebUIFragment::raw(">"),
7099 ],
7100 contains_boundary: false,
7101 },
7102 );
7103 let protocol = WebUIProtocol::new(fragments);
7104 let state = test_json!({"checked": "false"});
7105 let mut writer = TestWriter::new();
7106 handle(
7107 &protocol,
7108 &state,
7109 &RenderOptions::new("index.html", "/"),
7110 &mut writer,
7111 )
7112 .unwrap();
7113 assert_eq!(writer.get_content(), "<input checked>");
7114 }
7115 }
7116
7117 #[test]
7118 fn test_boolean_attr_falsy_values() {
7119 {
7121 let mut fragments = HashMap::new();
7122 fragments.insert(
7123 "index.html".to_string(),
7124 FragmentList {
7125 fragments: vec![
7126 WebUIFragment::raw("<input"),
7127 WebUIFragment::attribute_boolean(
7128 "checked",
7129 ConditionExpr::identifier("checked"),
7130 ),
7131 WebUIFragment::raw(">"),
7132 ],
7133 contains_boundary: false,
7134 },
7135 );
7136 let protocol = WebUIProtocol::new(fragments);
7137 let state = test_json!({"checked": 0});
7138 let mut writer = TestWriter::new();
7139 handle(
7140 &protocol,
7141 &state,
7142 &RenderOptions::new("index.html", "/"),
7143 &mut writer,
7144 )
7145 .unwrap();
7146 assert_eq!(writer.get_content(), "<input>");
7147 }
7148 {
7150 let mut fragments = HashMap::new();
7151 fragments.insert(
7152 "index.html".to_string(),
7153 FragmentList {
7154 fragments: vec![
7155 WebUIFragment::raw("<input"),
7156 WebUIFragment::attribute_boolean(
7157 "checked",
7158 ConditionExpr::identifier("checked"),
7159 ),
7160 WebUIFragment::raw(">"),
7161 ],
7162 contains_boundary: false,
7163 },
7164 );
7165 let protocol = WebUIProtocol::new(fragments);
7166 let state = test_json!({"checked": ""});
7167 let mut writer = TestWriter::new();
7168 handle(
7169 &protocol,
7170 &state,
7171 &RenderOptions::new("index.html", "/"),
7172 &mut writer,
7173 )
7174 .unwrap();
7175 assert_eq!(writer.get_content(), "<input>");
7176 }
7177 {
7179 let mut fragments = HashMap::new();
7180 fragments.insert(
7181 "index.html".to_string(),
7182 FragmentList {
7183 fragments: vec![
7184 WebUIFragment::raw("<input"),
7185 WebUIFragment::attribute_boolean(
7186 "checked",
7187 ConditionExpr::identifier("checked"),
7188 ),
7189 WebUIFragment::raw(">"),
7190 ],
7191 contains_boundary: false,
7192 },
7193 );
7194 let protocol = WebUIProtocol::new(fragments);
7195 let state = test_json!({"checked": false});
7196 let mut writer = TestWriter::new();
7197 handle(
7198 &protocol,
7199 &state,
7200 &RenderOptions::new("index.html", "/"),
7201 &mut writer,
7202 )
7203 .unwrap();
7204 assert_eq!(writer.get_content(), "<input>");
7205 }
7206 {
7208 let mut fragments = HashMap::new();
7209 fragments.insert(
7210 "index.html".to_string(),
7211 FragmentList {
7212 fragments: vec![
7213 WebUIFragment::raw("<input"),
7214 WebUIFragment::attribute_boolean(
7215 "checked",
7216 ConditionExpr::identifier("checked"),
7217 ),
7218 WebUIFragment::raw(">"),
7219 ],
7220 contains_boundary: false,
7221 },
7222 );
7223 let protocol = WebUIProtocol::new(fragments);
7224 let state = test_json!({});
7225 let mut writer = TestWriter::new();
7226 handle(
7227 &protocol,
7228 &state,
7229 &RenderOptions::new("index.html", "/"),
7230 &mut writer,
7231 )
7232 .unwrap();
7233 assert_eq!(writer.get_content(), "<input>");
7234 }
7235 }
7236
7237 #[test]
7238 fn test_boolean_attr_expression_true() {
7239 let mut fragments = HashMap::new();
7240 fragments.insert(
7241 "index.html".to_string(),
7242 FragmentList {
7243 fragments: vec![
7244 WebUIFragment::raw("<button"),
7245 WebUIFragment::attribute_boolean(
7246 "disabled",
7247 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
7248 ),
7249 WebUIFragment::raw(">Click</button>"),
7250 ],
7251 contains_boundary: false,
7252 },
7253 );
7254 let protocol = WebUIProtocol::new(fragments);
7255 let state = test_json!({"itemCount": 5});
7256 let mut writer = TestWriter::new();
7257 handle(
7258 &protocol,
7259 &state,
7260 &RenderOptions::new("index.html", "/"),
7261 &mut writer,
7262 )
7263 .unwrap();
7264 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
7265 }
7266
7267 #[test]
7268 fn test_boolean_attr_expression_false() {
7269 let mut fragments = HashMap::new();
7270 fragments.insert(
7271 "index.html".to_string(),
7272 FragmentList {
7273 fragments: vec![
7274 WebUIFragment::raw("<button"),
7275 WebUIFragment::attribute_boolean(
7276 "disabled",
7277 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
7278 ),
7279 WebUIFragment::raw(">Click</button>"),
7280 ],
7281 contains_boundary: false,
7282 },
7283 );
7284 let protocol = WebUIProtocol::new(fragments);
7285 let state = test_json!({"itemCount": 3});
7286 let mut writer = TestWriter::new();
7287 handle(
7288 &protocol,
7289 &state,
7290 &RenderOptions::new("index.html", "/"),
7291 &mut writer,
7292 )
7293 .unwrap();
7294 assert_eq!(writer.get_content(), "<button>Click</button>");
7295 }
7296
7297 #[test]
7300 fn test_nested_component_attr_capture() {
7301 let mut fragments = HashMap::new();
7302 fragments.insert(
7303 "index.html".to_string(),
7304 FragmentList {
7305 fragments: vec![
7306 WebUIFragment::raw("<parent-component"),
7307 WebUIFragment {
7308 fragment: Some(web_ui_fragment::Fragment::Attribute(
7309 WebUIFragmentAttribute {
7310 name: "title".into(),
7311 template: "parent-title".into(),
7312 attr_start: true,
7313 ..Default::default()
7314 },
7315 )),
7316 },
7317 WebUIFragment::raw(">"),
7318 WebUIFragment::component("parent-component"),
7319 WebUIFragment::raw("</parent-component>"),
7320 ],
7321
7322 contains_boundary: false,
7323 },
7324 );
7325 fragments.insert(
7326 "parent-title".to_string(),
7327 FragmentList {
7328 fragments: vec![
7329 WebUIFragment::raw("Hello "),
7330 WebUIFragment::signal("who", false),
7331 ],
7332 contains_boundary: false,
7333 },
7334 );
7335 fragments.insert(
7336 "parent-component".to_string(),
7337 FragmentList {
7338 fragments: vec![
7339 WebUIFragment::raw("<child-component"),
7340 WebUIFragment {
7341 fragment: Some(web_ui_fragment::Fragment::Attribute(
7342 WebUIFragmentAttribute {
7343 name: "title".into(),
7344 template: "child-title".into(),
7345 attr_start: true,
7346 ..Default::default()
7347 },
7348 )),
7349 },
7350 WebUIFragment::raw(">"),
7351 WebUIFragment::component("child-component"),
7352 WebUIFragment::raw("</child-component>"),
7353 ],
7354
7355 contains_boundary: false,
7356 },
7357 );
7358 fragments.insert(
7359 "child-title".to_string(),
7360 FragmentList {
7361 fragments: vec![
7362 WebUIFragment::raw("Child of "),
7363 WebUIFragment::signal("title", false),
7364 ],
7365 contains_boundary: false,
7366 },
7367 );
7368 fragments.insert(
7369 "child-component".to_string(),
7370 FragmentList {
7371 fragments: vec![
7372 WebUIFragment::raw("<span>"),
7373 WebUIFragment::signal("title", false),
7374 WebUIFragment::raw("</span>"),
7375 ],
7376 contains_boundary: false,
7377 },
7378 );
7379 let protocol = WebUIProtocol::new(fragments);
7380 let state = test_json!({"who": "<world>"});
7381 let mut writer = TestWriter::new();
7382 handle(
7383 &protocol,
7384 &state,
7385 &RenderOptions::new("index.html", "/"),
7386 &mut writer,
7387 )
7388 .unwrap();
7389 assert_eq!(
7390 writer.get_content(),
7391 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
7392 );
7393 }
7394
7395 #[test]
7396 fn test_grandchild_attr_propagation() {
7397 let mut fragments = HashMap::new();
7398 fragments.insert(
7399 "index.html".to_string(),
7400 FragmentList {
7401 fragments: vec![
7402 WebUIFragment::raw("<parent-component"),
7403 WebUIFragment {
7404 fragment: Some(web_ui_fragment::Fragment::Attribute(
7405 WebUIFragmentAttribute {
7406 name: "title".into(),
7407 template: "p-title".into(),
7408 attr_start: true,
7409 ..Default::default()
7410 },
7411 )),
7412 },
7413 WebUIFragment::raw(">"),
7414 WebUIFragment::component("parent-component"),
7415 WebUIFragment::raw("</parent-component>"),
7416 ],
7417
7418 contains_boundary: false,
7419 },
7420 );
7421 fragments.insert(
7422 "p-title".to_string(),
7423 FragmentList {
7424 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7425 contains_boundary: false,
7426 },
7427 );
7428 fragments.insert(
7429 "parent-component".to_string(),
7430 FragmentList {
7431 fragments: vec![
7432 WebUIFragment::raw("<child-component"),
7433 WebUIFragment {
7434 fragment: Some(web_ui_fragment::Fragment::Attribute(
7435 WebUIFragmentAttribute {
7436 name: "title".into(),
7437 template: "c-title".into(),
7438 attr_start: true,
7439 ..Default::default()
7440 },
7441 )),
7442 },
7443 WebUIFragment::raw(">"),
7444 WebUIFragment::component("child-component"),
7445 WebUIFragment::raw("</child-component>"),
7446 ],
7447
7448 contains_boundary: false,
7449 },
7450 );
7451 fragments.insert(
7452 "c-title".to_string(),
7453 FragmentList {
7454 fragments: vec![
7455 WebUIFragment::raw("C("),
7456 WebUIFragment::signal("title", false),
7457 WebUIFragment::raw(")-"),
7458 WebUIFragment::signal("cExtra", false),
7459 ],
7460 contains_boundary: false,
7461 },
7462 );
7463 fragments.insert(
7464 "child-component".to_string(),
7465 FragmentList {
7466 fragments: vec![
7467 WebUIFragment::raw("<grandchild-component"),
7468 WebUIFragment {
7469 fragment: Some(web_ui_fragment::Fragment::Attribute(
7470 WebUIFragmentAttribute {
7471 name: "title".into(),
7472 value: "title".into(),
7473 attr_start: true,
7474 ..Default::default()
7475 },
7476 )),
7477 },
7478 WebUIFragment::raw(">"),
7479 WebUIFragment::component("grandchild-component"),
7480 WebUIFragment::raw("</grandchild-component>"),
7481 ],
7482
7483 contains_boundary: false,
7484 },
7485 );
7486 fragments.insert(
7487 "grandchild-component".to_string(),
7488 FragmentList {
7489 fragments: vec![
7490 WebUIFragment::raw("<span>"),
7491 WebUIFragment::signal("title", false),
7492 WebUIFragment::raw("</span>"),
7493 ],
7494 contains_boundary: false,
7495 },
7496 );
7497 let protocol = WebUIProtocol::new(fragments);
7498 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
7499 let mut writer = TestWriter::new();
7500 handle(
7501 &protocol,
7502 &state,
7503 &RenderOptions::new("index.html", "/"),
7504 &mut writer,
7505 )
7506 .unwrap();
7507 assert_eq!(
7508 writer.get_content(),
7509 "<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>"
7510 );
7511 }
7512
7513 #[test]
7514 fn test_for_loop_component_attr() {
7515 let mut fragments = HashMap::new();
7516 fragments.insert(
7517 "index.html".to_string(),
7518 FragmentList {
7519 fragments: vec![
7520 WebUIFragment::raw("<parent-component"),
7521 WebUIFragment {
7522 fragment: Some(web_ui_fragment::Fragment::Attribute(
7523 WebUIFragmentAttribute {
7524 name: "title".into(),
7525 template: "parent-title-loop".into(),
7526 attr_start: true,
7527 ..Default::default()
7528 },
7529 )),
7530 },
7531 WebUIFragment::raw(">"),
7532 WebUIFragment::component("parent-component"),
7533 WebUIFragment::raw("</parent-component>"),
7534 ],
7535
7536 contains_boundary: false,
7537 },
7538 );
7539 fragments.insert(
7540 "parent-title-loop".to_string(),
7541 FragmentList {
7542 fragments: vec![
7543 WebUIFragment::raw("Parent:"),
7544 WebUIFragment::signal("who", false),
7545 ],
7546 contains_boundary: false,
7547 },
7548 );
7549 fragments.insert(
7550 "parent-component".to_string(),
7551 FragmentList {
7552 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7553 contains_boundary: false,
7554 },
7555 );
7556 fragments.insert(
7557 "child-loop".to_string(),
7558 FragmentList {
7559 fragments: vec![
7560 WebUIFragment::raw("<child-component"),
7561 WebUIFragment {
7562 fragment: Some(web_ui_fragment::Fragment::Attribute(
7563 WebUIFragmentAttribute {
7564 name: "title".into(),
7565 template: "child-title-loop".into(),
7566 attr_start: true,
7567 ..Default::default()
7568 },
7569 )),
7570 },
7571 WebUIFragment::raw(">"),
7572 WebUIFragment::component("child-component"),
7573 WebUIFragment::raw("</child-component>"),
7574 ],
7575
7576 contains_boundary: false,
7577 },
7578 );
7579 fragments.insert(
7580 "child-title-loop".to_string(),
7581 FragmentList {
7582 fragments: vec![
7583 WebUIFragment::raw("Hi "),
7584 WebUIFragment::signal("item.name", false),
7585 WebUIFragment::raw(" / "),
7586 WebUIFragment::signal("title", false),
7587 ],
7588 contains_boundary: false,
7589 },
7590 );
7591 fragments.insert(
7592 "child-component".to_string(),
7593 FragmentList {
7594 fragments: vec![
7595 WebUIFragment::raw("<span>"),
7596 WebUIFragment::signal("title", false),
7597 WebUIFragment::raw("</span>"),
7598 ],
7599 contains_boundary: false,
7600 },
7601 );
7602 let protocol = WebUIProtocol::new(fragments);
7603 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7604 let mut writer = TestWriter::new();
7605 handle(
7606 &protocol,
7607 &state,
7608 &RenderOptions::new("index.html", "/"),
7609 &mut writer,
7610 )
7611 .unwrap();
7612 assert_eq!(
7613 writer.get_content(),
7614 "<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>"
7615 );
7616 }
7617
7618 #[test]
7619 fn test_multiple_template_attrs() {
7620 let mut fragments = HashMap::new();
7621 fragments.insert(
7622 "index.html".to_string(),
7623 FragmentList {
7624 fragments: vec![
7625 WebUIFragment::raw("<my-component"),
7626 WebUIFragment {
7627 fragment: Some(web_ui_fragment::Fragment::Attribute(
7628 WebUIFragmentAttribute {
7629 name: "title".into(),
7630 template: "attr-title".into(),
7631 attr_start: true,
7632 ..Default::default()
7633 },
7634 )),
7635 },
7636 WebUIFragment {
7637 fragment: Some(web_ui_fragment::Fragment::Attribute(
7638 WebUIFragmentAttribute {
7639 name: "data-title".into(),
7640 template: "attr-data-title".into(),
7641 attr_start: false,
7642 ..Default::default()
7643 },
7644 )),
7645 },
7646 WebUIFragment {
7647 fragment: Some(web_ui_fragment::Fragment::Attribute(
7648 WebUIFragmentAttribute {
7649 name: "aria-label".into(),
7650 template: "attr-aria-label".into(),
7651 attr_start: false,
7652 ..Default::default()
7653 },
7654 )),
7655 },
7656 WebUIFragment::raw(">"),
7657 WebUIFragment::component("my-component"),
7658 WebUIFragment::raw("</my-component>"),
7659 ],
7660
7661 contains_boundary: false,
7662 },
7663 );
7664 fragments.insert(
7665 "attr-title".to_string(),
7666 FragmentList {
7667 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7668 contains_boundary: false,
7669 },
7670 );
7671 fragments.insert(
7672 "attr-data-title".to_string(),
7673 FragmentList {
7674 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7675 contains_boundary: false,
7676 },
7677 );
7678 fragments.insert(
7679 "attr-aria-label".to_string(),
7680 FragmentList {
7681 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7682 contains_boundary: false,
7683 },
7684 );
7685 fragments.insert(
7686 "my-component".to_string(),
7687 FragmentList {
7688 fragments: vec![
7689 WebUIFragment::raw("<span>"),
7690 WebUIFragment::signal("title", false),
7691 WebUIFragment::raw("|"),
7692 WebUIFragment::signal("dataTitle", false),
7693 WebUIFragment::raw("|"),
7694 WebUIFragment::signal("ariaLabel", false),
7695 WebUIFragment::raw("</span>"),
7696 ],
7697 contains_boundary: false,
7698 },
7699 );
7700 let protocol = WebUIProtocol::new(fragments);
7701 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7702 let mut writer = TestWriter::new();
7703 handle(
7704 &protocol,
7705 &state,
7706 &RenderOptions::new("index.html", "/"),
7707 &mut writer,
7708 )
7709 .unwrap();
7710 assert_eq!(
7711 writer.get_content(),
7712 "<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>"
7713 );
7714 }
7715
7716 #[test]
7717 fn test_attr_priority_over_global() {
7718 let mut fragments = HashMap::new();
7719 fragments.insert(
7720 "index.html".to_string(),
7721 FragmentList {
7722 fragments: vec![
7723 WebUIFragment::raw("<my-component"),
7724 WebUIFragment {
7725 fragment: Some(web_ui_fragment::Fragment::Attribute(
7726 WebUIFragmentAttribute {
7727 name: "title".into(),
7728 value: "Attribute Title".into(),
7729 raw_value: true,
7730 attr_start: true,
7731 ..Default::default()
7732 },
7733 )),
7734 },
7735 WebUIFragment::raw(">"),
7736 WebUIFragment::component("my-component"),
7737 WebUIFragment::raw("</my-component>"),
7738 ],
7739
7740 contains_boundary: false,
7741 },
7742 );
7743 fragments.insert(
7744 "my-component".to_string(),
7745 FragmentList {
7746 fragments: vec![
7747 WebUIFragment::raw("<span>"),
7748 WebUIFragment::signal("title", false),
7749 WebUIFragment::raw("</span>"),
7750 ],
7751 contains_boundary: false,
7752 },
7753 );
7754 let protocol = WebUIProtocol::new(fragments);
7755 let state = test_json!({"title": "Global Title"});
7756 let mut writer = TestWriter::new();
7757 handle(
7758 &protocol,
7759 &state,
7760 &RenderOptions::new("index.html", "/"),
7761 &mut writer,
7762 )
7763 .unwrap();
7764 assert_eq!(
7765 writer.get_content(),
7766 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7767 );
7768 }
7769
7770 #[test]
7771 fn test_attr_priority_over_local_and_global() {
7772 let mut fragments = HashMap::new();
7773 fragments.insert(
7774 "index.html".to_string(),
7775 FragmentList {
7776 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7777 contains_boundary: false,
7778 },
7779 );
7780 fragments.insert(
7781 "loop".to_string(),
7782 FragmentList {
7783 fragments: vec![
7784 WebUIFragment::raw("<my-component"),
7785 WebUIFragment {
7786 fragment: Some(web_ui_fragment::Fragment::Attribute(
7787 WebUIFragmentAttribute {
7788 name: "title".into(),
7789 value: "Attribute Title".into(),
7790 raw_value: true,
7791 attr_start: true,
7792 ..Default::default()
7793 },
7794 )),
7795 },
7796 WebUIFragment::raw(">"),
7797 WebUIFragment::component("my-component"),
7798 WebUIFragment::raw("</my-component>"),
7799 ],
7800
7801 contains_boundary: false,
7802 },
7803 );
7804 fragments.insert(
7805 "my-component".to_string(),
7806 FragmentList {
7807 fragments: vec![
7808 WebUIFragment::raw("<span>"),
7809 WebUIFragment::signal("title", false),
7810 WebUIFragment::raw("</span>"),
7811 ],
7812 contains_boundary: false,
7813 },
7814 );
7815 let protocol = WebUIProtocol::new(fragments);
7816 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7817 let mut writer = TestWriter::new();
7818 handle(
7819 &protocol,
7820 &state,
7821 &RenderOptions::new("index.html", "/"),
7822 &mut writer,
7823 )
7824 .unwrap();
7825 assert_eq!(
7826 writer.get_content(),
7827 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7828 );
7829 }
7830
7831 #[test]
7832 fn test_boolean_attr_first_component_attr() {
7833 let mut fragments = HashMap::new();
7834 fragments.insert(
7835 "index.html".to_string(),
7836 FragmentList {
7837 fragments: vec![
7838 WebUIFragment::raw("<my-component"),
7839 WebUIFragment {
7840 fragment: Some(web_ui_fragment::Fragment::Attribute(
7841 WebUIFragmentAttribute {
7842 name: "disabled".into(),
7843 attr_start: true,
7844 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7845 ..Default::default()
7846 },
7847 )),
7848 },
7849 WebUIFragment {
7850 fragment: Some(web_ui_fragment::Fragment::Attribute(
7851 WebUIFragmentAttribute {
7852 name: "label".into(),
7853 value: "Component Label".into(),
7854 raw_value: true,
7855 attr_start: false,
7856 ..Default::default()
7857 },
7858 )),
7859 },
7860 WebUIFragment::raw(">"),
7861 WebUIFragment::component("my-component"),
7862 WebUIFragment::raw("</my-component>"),
7863 ],
7864
7865 contains_boundary: false,
7866 },
7867 );
7868 fragments.insert(
7869 "my-component".to_string(),
7870 FragmentList {
7871 fragments: vec![
7872 WebUIFragment::if_cond(
7873 ConditionExpr::identifier("disabled"),
7874 "disabledTemplate",
7875 ),
7876 WebUIFragment::raw("<span>"),
7877 WebUIFragment::signal("label", false),
7878 WebUIFragment::raw("</span>"),
7879 ],
7880 contains_boundary: false,
7881 },
7882 );
7883 fragments.insert(
7884 "disabledTemplate".to_string(),
7885 FragmentList {
7886 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7887 contains_boundary: false,
7888 },
7889 );
7890 let protocol = WebUIProtocol::new(fragments);
7891 let state = test_json!({"isDisabled": true});
7892 let mut writer = TestWriter::new();
7893 handle(
7894 &protocol,
7895 &state,
7896 &RenderOptions::new("index.html", "/"),
7897 &mut writer,
7898 )
7899 .unwrap();
7900 assert_eq!(
7901 writer.get_content(),
7902 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7903 );
7904 }
7905
7906 #[test]
7907 fn test_hyphenated_attr_camelcase() {
7908 let mut fragments = HashMap::new();
7909 fragments.insert(
7910 "index.html".to_string(),
7911 FragmentList {
7912 fragments: vec![
7913 WebUIFragment::raw("<my-component"),
7914 WebUIFragment {
7915 fragment: Some(web_ui_fragment::Fragment::Attribute(
7916 WebUIFragmentAttribute {
7917 name: "key-hyphen".into(),
7918 value: "Local Value".into(),
7919 raw_value: true,
7920 attr_start: true,
7921 ..Default::default()
7922 },
7923 )),
7924 },
7925 WebUIFragment::raw(">"),
7926 WebUIFragment::component("my-component"),
7927 WebUIFragment::raw("</my-component>"),
7928 ],
7929
7930 contains_boundary: false,
7931 },
7932 );
7933 fragments.insert(
7934 "my-component".to_string(),
7935 FragmentList {
7936 fragments: vec![
7937 WebUIFragment::raw("<span>"),
7938 WebUIFragment::signal("keyHyphen", false),
7939 WebUIFragment::raw("</span>"),
7940 ],
7941 contains_boundary: false,
7942 },
7943 );
7944 let protocol = WebUIProtocol::new(fragments);
7945 let state = test_json!({"keyHyphen": "Global Value"});
7946 let mut writer = TestWriter::new();
7947 handle(
7948 &protocol,
7949 &state,
7950 &RenderOptions::new("index.html", "/"),
7951 &mut writer,
7952 )
7953 .unwrap();
7954 assert_eq!(
7955 writer.get_content(),
7956 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7957 );
7958 }
7959
7960 #[test]
7961 fn test_skipped_component_attrs() {
7962 let mut fragments = HashMap::new();
7966 fragments.insert(
7967 "index.html".to_string(),
7968 FragmentList {
7969 fragments: vec![
7970 WebUIFragment::raw("<test-component"),
7971 WebUIFragment {
7973 fragment: Some(web_ui_fragment::Fragment::Attribute(
7974 WebUIFragmentAttribute {
7975 name: "class".into(),
7976 value: "skippedClass".into(),
7977 attr_start: true,
7978 attr_skip: true,
7979 ..Default::default()
7980 },
7981 )),
7982 },
7983 WebUIFragment {
7985 fragment: Some(web_ui_fragment::Fragment::Attribute(
7986 WebUIFragmentAttribute {
7987 name: "style".into(),
7988 value: "skippedStyle".into(),
7989 attr_skip: true,
7990 ..Default::default()
7991 },
7992 )),
7993 },
7994 WebUIFragment {
7996 fragment: Some(web_ui_fragment::Fragment::Attribute(
7997 WebUIFragmentAttribute {
7998 name: "role".into(),
7999 value: "skippedRole".into(),
8000 attr_skip: true,
8001 ..Default::default()
8002 },
8003 )),
8004 },
8005 WebUIFragment {
8007 fragment: Some(web_ui_fragment::Fragment::Attribute(
8008 WebUIFragmentAttribute {
8009 name: "data-testid".into(),
8010 value: "skippedDataTestid".into(),
8011 attr_skip: true,
8012 ..Default::default()
8013 },
8014 )),
8015 },
8016 WebUIFragment {
8018 fragment: Some(web_ui_fragment::Fragment::Attribute(
8019 WebUIFragmentAttribute {
8020 name: "aria-label".into(),
8021 value: "skippedAriaLabel".into(),
8022 attr_skip: true,
8023 ..Default::default()
8024 },
8025 )),
8026 },
8027 WebUIFragment {
8029 fragment: Some(web_ui_fragment::Fragment::Attribute(
8030 WebUIFragmentAttribute {
8031 name: "title".into(),
8032 value: "title".into(),
8033 ..Default::default()
8034 },
8035 )),
8036 },
8037 WebUIFragment::raw(">"),
8038 WebUIFragment::component("test-component"),
8039 WebUIFragment::raw("</test-component>"),
8040 ],
8041
8042 contains_boundary: false,
8043 },
8044 );
8045 fragments.insert(
8046 "test-component".to_string(),
8047 FragmentList {
8048 fragments: vec![
8049 WebUIFragment::raw("<span>"),
8050 WebUIFragment::signal("title", false),
8051 WebUIFragment::raw("-"),
8052 WebUIFragment::signal("class", false),
8053 WebUIFragment::raw("-"),
8054 WebUIFragment::signal("style", false),
8055 WebUIFragment::raw("-"),
8056 WebUIFragment::signal("role", false),
8057 WebUIFragment::raw("-"),
8058 WebUIFragment::signal("dataTestid", false),
8059 WebUIFragment::raw("-"),
8060 WebUIFragment::signal("ariaLabel", false),
8061 WebUIFragment::raw("</span>"),
8062 ],
8063 contains_boundary: false,
8064 },
8065 );
8066 let protocol = WebUIProtocol::new(fragments);
8067 let state = test_json!({
8068 "title": "Hello",
8069 "skippedClass": "my-class",
8070 "skippedStyle": "color:red",
8071 "skippedRole": "button",
8072 "skippedDataTestid": "test-id",
8073 "skippedAriaLabel": "label-text"
8074 });
8075 let mut writer = TestWriter::new();
8076 handle(
8077 &protocol,
8078 &state,
8079 &RenderOptions::new("index.html", "/"),
8080 &mut writer,
8081 )
8082 .unwrap();
8083 assert_eq!(
8087 writer.get_content(),
8088 "<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>"
8089 );
8090 }
8091
8092 #[test]
8095 fn test_attr_inherit_parent_to_child() {
8096 let mut fragments = HashMap::new();
8097 fragments.insert(
8098 "index.html".to_string(),
8099 FragmentList {
8100 fragments: vec![
8101 WebUIFragment::raw("<parent-component"),
8102 WebUIFragment {
8103 fragment: Some(web_ui_fragment::Fragment::Attribute(
8104 WebUIFragmentAttribute {
8105 name: "title".into(),
8106 value: "Parent Title".into(),
8107 raw_value: true,
8108 attr_start: true,
8109 ..Default::default()
8110 },
8111 )),
8112 },
8113 WebUIFragment::raw(">"),
8114 WebUIFragment::component("parent-component"),
8115 WebUIFragment::raw("</parent-component>"),
8116 ],
8117
8118 contains_boundary: false,
8119 },
8120 );
8121 fragments.insert(
8122 "parent-component".to_string(),
8123 FragmentList {
8124 fragments: vec![
8125 WebUIFragment::raw("<h1>"),
8126 WebUIFragment::signal("title", false),
8127 WebUIFragment::raw("</h1><child-component"),
8128 WebUIFragment {
8129 fragment: Some(web_ui_fragment::Fragment::Attribute(
8130 WebUIFragmentAttribute {
8131 name: "title".into(),
8132 value: "title".into(),
8133 attr_start: true,
8134 ..Default::default()
8135 },
8136 )),
8137 },
8138 WebUIFragment::raw(">"),
8139 WebUIFragment::component("child-component"),
8140 WebUIFragment::raw("</child-component>"),
8141 ],
8142
8143 contains_boundary: false,
8144 },
8145 );
8146 fragments.insert(
8147 "child-component".to_string(),
8148 FragmentList {
8149 fragments: vec![
8150 WebUIFragment::raw("<h2>"),
8151 WebUIFragment::signal("title", false),
8152 WebUIFragment::raw("</h2>"),
8153 ],
8154 contains_boundary: false,
8155 },
8156 );
8157 let protocol = WebUIProtocol::new(fragments);
8158 let state = test_json!({});
8159 let mut writer = TestWriter::new();
8160 handle(
8161 &protocol,
8162 &state,
8163 &RenderOptions::new("index.html", "/"),
8164 &mut writer,
8165 )
8166 .unwrap();
8167 assert_eq!(
8168 writer.get_content(),
8169 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
8170 );
8171 }
8172
8173 #[test]
8174 fn test_attr_inherit_deep() {
8175 let mut fragments = HashMap::new();
8176 fragments.insert(
8177 "index.html".to_string(),
8178 FragmentList {
8179 fragments: vec![
8180 WebUIFragment::raw("<parent-component"),
8181 WebUIFragment {
8182 fragment: Some(web_ui_fragment::Fragment::Attribute(
8183 WebUIFragmentAttribute {
8184 name: "title".into(),
8185 value: "Parent Title".into(),
8186 raw_value: true,
8187 attr_start: true,
8188 ..Default::default()
8189 },
8190 )),
8191 },
8192 WebUIFragment::raw(">"),
8193 WebUIFragment::component("parent-component"),
8194 WebUIFragment::raw("</parent-component>"),
8195 ],
8196
8197 contains_boundary: false,
8198 },
8199 );
8200 fragments.insert(
8201 "parent-component".to_string(),
8202 FragmentList {
8203 fragments: vec![
8204 WebUIFragment::raw("<child-component"),
8205 WebUIFragment {
8206 fragment: Some(web_ui_fragment::Fragment::Attribute(
8207 WebUIFragmentAttribute {
8208 name: "title".into(),
8209 value: "Child Title".into(),
8210 raw_value: true,
8211 attr_start: true,
8212 ..Default::default()
8213 },
8214 )),
8215 },
8216 WebUIFragment::raw(">"),
8217 WebUIFragment::component("child-component"),
8218 WebUIFragment::raw("</child-component>"),
8219 ],
8220
8221 contains_boundary: false,
8222 },
8223 );
8224 fragments.insert(
8225 "child-component".to_string(),
8226 FragmentList {
8227 fragments: vec![
8228 WebUIFragment::raw("<grandchild-component"),
8229 WebUIFragment {
8230 fragment: Some(web_ui_fragment::Fragment::Attribute(
8231 WebUIFragmentAttribute {
8232 name: "title".into(),
8233 value: "title".into(),
8234 attr_start: true,
8235 ..Default::default()
8236 },
8237 )),
8238 },
8239 WebUIFragment::raw(">"),
8240 WebUIFragment::component("grandchild-component"),
8241 WebUIFragment::raw("</grandchild-component>"),
8242 ],
8243
8244 contains_boundary: false,
8245 },
8246 );
8247 fragments.insert(
8248 "grandchild-component".to_string(),
8249 FragmentList {
8250 fragments: vec![
8251 WebUIFragment::raw("<h3>"),
8252 WebUIFragment::signal("title", false),
8253 WebUIFragment::raw("</h3>"),
8254 ],
8255 contains_boundary: false,
8256 },
8257 );
8258 let protocol = WebUIProtocol::new(fragments);
8259 let state = test_json!({});
8260 let mut writer = TestWriter::new();
8261 handle(
8262 &protocol,
8263 &state,
8264 &RenderOptions::new("index.html", "/"),
8265 &mut writer,
8266 )
8267 .unwrap();
8268 assert_eq!(
8269 writer.get_content(),
8270 "<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>"
8271 );
8272 }
8273
8274 #[test]
8275 fn test_complex_attr_access() {
8276 let mut fragments = HashMap::new();
8277 fragments.insert(
8278 "index.html".to_string(),
8279 FragmentList {
8280 fragments: vec![
8281 WebUIFragment::raw("<my-component"),
8282 WebUIFragment {
8283 fragment: Some(web_ui_fragment::Fragment::Attribute(
8284 WebUIFragmentAttribute {
8285 name: ":item".into(),
8286 value: "complexItem".into(),
8287 attr_start: true,
8288 complex: true,
8289 ..Default::default()
8290 },
8291 )),
8292 },
8293 WebUIFragment::raw(">"),
8294 WebUIFragment::component("my-component"),
8295 WebUIFragment::raw("</my-component>"),
8296 ],
8297
8298 contains_boundary: false,
8299 },
8300 );
8301 fragments.insert(
8302 "my-component".to_string(),
8303 FragmentList {
8304 fragments: vec![
8305 WebUIFragment::raw("<span>"),
8306 WebUIFragment::signal("item.foo", false),
8307 WebUIFragment::raw("</span><p>"),
8308 WebUIFragment::signal("item.bar", false),
8309 WebUIFragment::raw("</p>"),
8310 ],
8311 contains_boundary: false,
8312 },
8313 );
8314 let protocol = WebUIProtocol::new(fragments);
8315 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
8316 let mut writer = TestWriter::new();
8317 handle(
8318 &protocol,
8319 &state,
8320 &RenderOptions::new("index.html", "/"),
8321 &mut writer,
8322 )
8323 .unwrap();
8324 assert_eq!(
8325 writer.get_content(),
8326 "<my-component><span>1</span><p>true</p></my-component>"
8327 );
8328 }
8329
8330 #[test]
8331 fn test_complex_attr_for_loop() {
8332 let mut fragments = HashMap::new();
8333 fragments.insert(
8334 "index.html".to_string(),
8335 FragmentList {
8336 fragments: vec![WebUIFragment::for_loop(
8337 "item",
8338 "list.items",
8339 "listTemplate",
8340 )],
8341 contains_boundary: false,
8342 },
8343 );
8344 fragments.insert(
8345 "listTemplate".to_string(),
8346 FragmentList {
8347 fragments: vec![
8348 WebUIFragment {
8349 fragment: Some(web_ui_fragment::Fragment::Attribute(
8350 WebUIFragmentAttribute {
8351 name: ":item".into(),
8352 value: "item".into(),
8353 attr_start: true,
8354 complex: true,
8355 ..Default::default()
8356 },
8357 )),
8358 },
8359 WebUIFragment::component("item_component"),
8360 ],
8361
8362 contains_boundary: false,
8363 },
8364 );
8365 fragments.insert(
8366 "item_component".to_string(),
8367 FragmentList {
8368 fragments: vec![
8369 WebUIFragment::raw("<span>"),
8370 WebUIFragment::signal("item.name", false),
8371 WebUIFragment::raw("</span>"),
8372 ],
8373 contains_boundary: false,
8374 },
8375 );
8376 let protocol = WebUIProtocol::new(fragments);
8377 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
8378 let mut writer = TestWriter::new();
8379 handle(
8380 &protocol,
8381 &state,
8382 &RenderOptions::new("index.html", "/"),
8383 &mut writer,
8384 )
8385 .unwrap();
8386 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
8387 }
8388
8389 #[test]
8390 fn test_complex_attr_nested_for() {
8391 let mut fragments = HashMap::new();
8392 fragments.insert(
8393 "index.html".to_string(),
8394 FragmentList {
8395 fragments: vec![WebUIFragment::for_loop(
8396 "outer",
8397 "data.outer",
8398 "outerTemplate",
8399 )],
8400 contains_boundary: false,
8401 },
8402 );
8403 fragments.insert(
8404 "outerTemplate".to_string(),
8405 FragmentList {
8406 fragments: vec![WebUIFragment::for_loop(
8407 "middle",
8408 "outer.middle",
8409 "middleTemplate",
8410 )],
8411 contains_boundary: false,
8412 },
8413 );
8414 fragments.insert(
8415 "middleTemplate".to_string(),
8416 FragmentList {
8417 fragments: vec![WebUIFragment::for_loop(
8418 "inner",
8419 "middle.inner",
8420 "innerTemplate",
8421 )],
8422 contains_boundary: false,
8423 },
8424 );
8425 fragments.insert(
8426 "innerTemplate".to_string(),
8427 FragmentList {
8428 fragments: vec![
8429 WebUIFragment::raw("<card"),
8430 WebUIFragment {
8431 fragment: Some(web_ui_fragment::Fragment::Attribute(
8432 WebUIFragmentAttribute {
8433 name: ":outer".into(),
8434 value: "outer".into(),
8435 attr_start: true,
8436 complex: true,
8437 ..Default::default()
8438 },
8439 )),
8440 },
8441 WebUIFragment {
8442 fragment: Some(web_ui_fragment::Fragment::Attribute(
8443 WebUIFragmentAttribute {
8444 name: ":middle".into(),
8445 value: "middle".into(),
8446 attr_start: false,
8447 complex: true,
8448 ..Default::default()
8449 },
8450 )),
8451 },
8452 WebUIFragment {
8453 fragment: Some(web_ui_fragment::Fragment::Attribute(
8454 WebUIFragmentAttribute {
8455 name: ":inner".into(),
8456 value: "inner".into(),
8457 attr_start: false,
8458 complex: true,
8459 ..Default::default()
8460 },
8461 )),
8462 },
8463 WebUIFragment::raw(">"),
8464 WebUIFragment::component("card_component"),
8465 WebUIFragment::raw("</card>"),
8466 ],
8467
8468 contains_boundary: false,
8469 },
8470 );
8471 fragments.insert(
8472 "card_component".to_string(),
8473 FragmentList {
8474 fragments: vec![
8475 WebUIFragment::raw("<p>"),
8476 WebUIFragment::signal("outer.label", false),
8477 WebUIFragment::raw(" / "),
8478 WebUIFragment::signal("middle.label", false),
8479 WebUIFragment::raw(" / "),
8480 WebUIFragment::signal("inner.label", false),
8481 WebUIFragment::raw("</p>"),
8482 ],
8483 contains_boundary: false,
8484 },
8485 );
8486 let protocol = WebUIProtocol::new(fragments);
8487 let state = test_json!({"data": {"outer": [
8488 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8489 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8490 ]}});
8491 let mut writer = TestWriter::new();
8492 handle(
8493 &protocol,
8494 &state,
8495 &RenderOptions::new("index.html", "/"),
8496 &mut writer,
8497 )
8498 .unwrap();
8499 assert_eq!(
8500 writer.get_content(),
8501 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8502 );
8503 }
8504
8505 #[test]
8508 fn test_bool_component_state_true() {
8509 let mut fragments = HashMap::new();
8510 fragments.insert(
8511 "index.html".to_string(),
8512 FragmentList {
8513 fragments: vec![
8514 WebUIFragment::raw("<my-component"),
8515 WebUIFragment {
8516 fragment: Some(web_ui_fragment::Fragment::Attribute(
8517 WebUIFragmentAttribute {
8518 name: "disabled".into(),
8519 attr_start: true,
8520 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8521 ..Default::default()
8522 },
8523 )),
8524 },
8525 WebUIFragment::raw(">"),
8526 WebUIFragment::component("my-component"),
8527 WebUIFragment::raw("</my-component>"),
8528 ],
8529
8530 contains_boundary: false,
8531 },
8532 );
8533 fragments.insert(
8534 "my-component".to_string(),
8535 FragmentList {
8536 fragments: vec![
8537 WebUIFragment::if_cond(
8538 ConditionExpr::identifier("disabled"),
8539 "disabledTemplate",
8540 ),
8541 WebUIFragment::if_cond(
8542 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8543 "enabledTemplate",
8544 ),
8545 ],
8546 contains_boundary: false,
8547 },
8548 );
8549 fragments.insert(
8550 "disabledTemplate".to_string(),
8551 FragmentList {
8552 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8553 contains_boundary: false,
8554 },
8555 );
8556 fragments.insert(
8557 "enabledTemplate".to_string(),
8558 FragmentList {
8559 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8560 contains_boundary: false,
8561 },
8562 );
8563 let protocol = WebUIProtocol::new(fragments);
8564 let state = test_json!({"isDisabled": true});
8565 let mut writer = TestWriter::new();
8566 handle(
8567 &protocol,
8568 &state,
8569 &RenderOptions::new("index.html", "/"),
8570 &mut writer,
8571 )
8572 .unwrap();
8573 assert_eq!(
8574 writer.get_content(),
8575 "<my-component disabled><span>Disabled</span></my-component>"
8576 );
8577 }
8578
8579 #[test]
8580 fn test_bool_component_state_false() {
8581 let mut fragments = HashMap::new();
8582 fragments.insert(
8583 "index.html".to_string(),
8584 FragmentList {
8585 fragments: vec![
8586 WebUIFragment::raw("<my-component"),
8587 WebUIFragment {
8588 fragment: Some(web_ui_fragment::Fragment::Attribute(
8589 WebUIFragmentAttribute {
8590 name: "disabled".into(),
8591 attr_start: true,
8592 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8593 ..Default::default()
8594 },
8595 )),
8596 },
8597 WebUIFragment::raw(">"),
8598 WebUIFragment::component("my-component"),
8599 WebUIFragment::raw("</my-component>"),
8600 ],
8601
8602 contains_boundary: false,
8603 },
8604 );
8605 fragments.insert(
8606 "my-component".to_string(),
8607 FragmentList {
8608 fragments: vec![
8609 WebUIFragment::if_cond(
8610 ConditionExpr::identifier("disabled"),
8611 "disabledTemplate",
8612 ),
8613 WebUIFragment::if_cond(
8614 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8615 "enabledTemplate",
8616 ),
8617 ],
8618 contains_boundary: false,
8619 },
8620 );
8621 fragments.insert(
8622 "disabledTemplate".to_string(),
8623 FragmentList {
8624 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8625 contains_boundary: false,
8626 },
8627 );
8628 fragments.insert(
8629 "enabledTemplate".to_string(),
8630 FragmentList {
8631 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8632 contains_boundary: false,
8633 },
8634 );
8635 let protocol = WebUIProtocol::new(fragments);
8636 let state = test_json!({"isDisabled": false});
8637 let mut writer = TestWriter::new();
8638 handle(
8639 &protocol,
8640 &state,
8641 &RenderOptions::new("index.html", "/"),
8642 &mut writer,
8643 )
8644 .unwrap();
8645 assert_eq!(
8646 writer.get_content(),
8647 "<my-component><span>Enabled</span></my-component>"
8648 );
8649 }
8650
8651 #[test]
8652 fn test_bool_component_state_forward() {
8653 let mut fragments = HashMap::new();
8654 fragments.insert(
8655 "index.html".to_string(),
8656 FragmentList {
8657 fragments: vec![
8658 WebUIFragment::raw("<parent-component"),
8659 WebUIFragment {
8660 fragment: Some(web_ui_fragment::Fragment::Attribute(
8661 WebUIFragmentAttribute {
8662 name: "disabled".into(),
8663 attr_start: true,
8664 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8665 ..Default::default()
8666 },
8667 )),
8668 },
8669 WebUIFragment::raw(">"),
8670 WebUIFragment::component("parent-component"),
8671 WebUIFragment::raw("</parent-component>"),
8672 ],
8673
8674 contains_boundary: false,
8675 },
8676 );
8677 fragments.insert(
8678 "parent-component".to_string(),
8679 FragmentList {
8680 fragments: vec![
8681 WebUIFragment::if_cond(
8682 ConditionExpr::identifier("disabled"),
8683 "parentDisabledTemplate",
8684 ),
8685 WebUIFragment::raw("<child-component"),
8686 WebUIFragment {
8687 fragment: Some(web_ui_fragment::Fragment::Attribute(
8688 WebUIFragmentAttribute {
8689 name: "disabled".into(),
8690 attr_start: true,
8691 condition_tree: Some(ConditionExpr::identifier("disabled")),
8692 ..Default::default()
8693 },
8694 )),
8695 },
8696 WebUIFragment::raw(">"),
8697 WebUIFragment::component("child-component"),
8698 WebUIFragment::raw("</child-component>"),
8699 ],
8700
8701 contains_boundary: false,
8702 },
8703 );
8704 fragments.insert(
8705 "parentDisabledTemplate".to_string(),
8706 FragmentList {
8707 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8708 contains_boundary: false,
8709 },
8710 );
8711 fragments.insert(
8712 "child-component".to_string(),
8713 FragmentList {
8714 fragments: vec![
8715 WebUIFragment::if_cond(
8716 ConditionExpr::identifier("disabled"),
8717 "childDisabledTemplate",
8718 ),
8719 WebUIFragment::if_cond(
8720 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8721 "childEnabledTemplate",
8722 ),
8723 ],
8724 contains_boundary: false,
8725 },
8726 );
8727 fragments.insert(
8728 "childDisabledTemplate".to_string(),
8729 FragmentList {
8730 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8731 contains_boundary: false,
8732 },
8733 );
8734 fragments.insert(
8735 "childEnabledTemplate".to_string(),
8736 FragmentList {
8737 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8738 contains_boundary: false,
8739 },
8740 );
8741
8742 {
8744 let protocol = WebUIProtocol::new(fragments.clone());
8745 let state = test_json!({"isDisabled": true});
8746 let mut writer = TestWriter::new();
8747 handle(
8748 &protocol,
8749 &state,
8750 &RenderOptions::new("index.html", "/"),
8751 &mut writer,
8752 )
8753 .unwrap();
8754 assert_eq!(
8755 writer.get_content(),
8756 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8757 );
8758 }
8759
8760 {
8762 let protocol = WebUIProtocol::new(fragments.clone());
8763 let state = test_json!({"isDisabled": false});
8764 let mut writer = TestWriter::new();
8765 handle(
8766 &protocol,
8767 &state,
8768 &RenderOptions::new("index.html", "/"),
8769 &mut writer,
8770 )
8771 .unwrap();
8772 assert_eq!(
8773 writer.get_content(),
8774 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8775 );
8776 }
8777 }
8778
8779 #[test]
8786 fn test_component_with_template() {
8787 let mut fragments = HashMap::new();
8788 fragments.insert(
8789 "index.html".to_string(),
8790 FragmentList {
8791 fragments: vec![
8792 WebUIFragment::raw("<custom-element>"),
8793 WebUIFragment::component("custom-element"),
8794 WebUIFragment::raw("</custom-element>"),
8795 ],
8796 contains_boundary: false,
8797 },
8798 );
8799 fragments.insert(
8800 "custom-element".to_string(),
8801 FragmentList {
8802 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8803 contains_boundary: false,
8804 },
8805 );
8806 let protocol = WebUIProtocol::new(fragments);
8807 let state = test_json!({});
8808 let mut writer = TestWriter::new();
8809 handle(
8810 &protocol,
8811 &state,
8812 &RenderOptions::new("index.html", "/"),
8813 &mut writer,
8814 )
8815 .unwrap();
8816 assert_eq!(
8817 writer.get_content(),
8818 "<custom-element><div>Custom Element</div></custom-element>"
8819 );
8820 assert!(writer.is_ended());
8821 }
8822
8823 #[test]
8824 fn test_component_with_slots() {
8825 let mut fragments = HashMap::new();
8826 fragments.insert(
8827 "index.html".to_string(),
8828 FragmentList {
8829 fragments: vec![
8830 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8831 WebUIFragment::component("custom-element"),
8832 WebUIFragment::raw("Hello World</custom-element>"),
8833 ],
8834 contains_boundary: false,
8835 },
8836 );
8837 fragments.insert(
8838 "custom-element".to_string(),
8839 FragmentList {
8840 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8841 contains_boundary: false,
8842 },
8843 );
8844 let protocol = WebUIProtocol::new(fragments);
8845 let state = test_json!({});
8846 let mut writer = TestWriter::new();
8847 handle(
8848 &protocol,
8849 &state,
8850 &RenderOptions::new("index.html", "/"),
8851 &mut writer,
8852 )
8853 .unwrap();
8854 assert_eq!(
8855 writer.get_content(),
8856 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8857 );
8858 assert!(writer.is_ended());
8859 }
8860
8861 #[test]
8862 fn test_multiple_nested_components() {
8863 let mut fragments = HashMap::new();
8864 fragments.insert(
8865 "index.html".to_string(),
8866 FragmentList {
8867 fragments: vec![
8868 WebUIFragment::raw("<div>"),
8869 WebUIFragment::for_loop("item", "items", "templateRepeat"),
8870 WebUIFragment::raw("</div>"),
8871 ],
8872 contains_boundary: false,
8873 },
8874 );
8875 fragments.insert(
8876 "custom-button".to_string(),
8877 FragmentList {
8878 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8879 contains_boundary: false,
8880 },
8881 );
8882 fragments.insert(
8883 "custom-element".to_string(),
8884 FragmentList {
8885 fragments: vec![
8886 WebUIFragment::raw("<custom-child>"),
8887 WebUIFragment::component("custom-child"),
8888 WebUIFragment::raw("</custom-child><slot></slot>"),
8889 ],
8890 contains_boundary: false,
8891 },
8892 );
8893 fragments.insert(
8894 "custom-child".to_string(),
8895 FragmentList {
8896 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8897 contains_boundary: false,
8898 },
8899 );
8900 fragments.insert(
8901 "templateRepeat".to_string(),
8902 FragmentList {
8903 fragments: vec![
8904 WebUIFragment::raw("<custom-element>"),
8905 WebUIFragment::component("custom-element"),
8906 WebUIFragment::raw("<custom-button>"),
8907 WebUIFragment::component("custom-button"),
8908 WebUIFragment::raw("Ok</custom-button></custom-element>"),
8909 ],
8910 contains_boundary: false,
8911 },
8912 );
8913 let protocol = WebUIProtocol::new(fragments);
8914 let state = test_json!({"items": [{"name": "Item1"}]});
8915 let mut writer = TestWriter::new();
8916 handle(
8917 &protocol,
8918 &state,
8919 &RenderOptions::new("index.html", "/"),
8920 &mut writer,
8921 )
8922 .unwrap();
8923 assert_eq!(
8924 writer.get_content(),
8925 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8926 );
8927 assert!(writer.is_ended());
8928 }
8929
8930 #[test]
8933 fn test_if_with_binary_expression() {
8934 let mut fragments = HashMap::new();
8935 fragments.insert(
8936 "index.html".to_string(),
8937 FragmentList {
8938 fragments: vec![
8939 WebUIFragment::raw("<div>"),
8940 WebUIFragment::if_cond(
8941 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8942 "if-1",
8943 ),
8944 WebUIFragment::raw("</div>"),
8945 ],
8946 contains_boundary: false,
8947 },
8948 );
8949 fragments.insert(
8950 "if-1".to_string(),
8951 FragmentList {
8952 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8953 contains_boundary: false,
8954 },
8955 );
8956 let protocol = WebUIProtocol::new(fragments);
8957
8958 let state_true = test_json!({"x": 10});
8960 let mut writer_true = TestWriter::new();
8961 handle(
8962 &protocol,
8963 &state_true,
8964 &RenderOptions::new("index.html", "/"),
8965 &mut writer_true,
8966 )
8967 .unwrap();
8968 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8969
8970 let state_false = test_json!({"x": 1});
8972 let mut writer_false = TestWriter::new();
8973 handle(
8974 &protocol,
8975 &state_false,
8976 &RenderOptions::new("index.html", "/"),
8977 &mut writer_false,
8978 )
8979 .unwrap();
8980 assert_eq!(writer_false.get_content(), "<div></div>");
8981 }
8982
8983 #[test]
8984 fn test_for_if_overlapping_local_state() {
8985 let mut fragments = HashMap::new();
8986 fragments.insert(
8987 "index.html".to_string(),
8988 FragmentList {
8989 fragments: vec![
8990 WebUIFragment::raw("<div>"),
8991 WebUIFragment::for_loop("item", "items", "template1"),
8992 WebUIFragment::raw("</div>"),
8993 ],
8994 contains_boundary: false,
8995 },
8996 );
8997 fragments.insert(
8998 "template1".to_string(),
8999 FragmentList {
9000 fragments: vec![
9001 WebUIFragment::raw("<div>"),
9002 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
9003 WebUIFragment::raw("</div>"),
9004 ],
9005 contains_boundary: false,
9006 },
9007 );
9008 fragments.insert(
9009 "ifBlock".to_string(),
9010 FragmentList {
9011 fragments: vec![
9012 WebUIFragment::raw("<span>"),
9013 WebUIFragment::signal("item.label", false),
9014 WebUIFragment::raw("</span>"),
9015 ],
9016 contains_boundary: false,
9017 },
9018 );
9019 let protocol = WebUIProtocol::new(fragments);
9020 let state = test_json!({
9021 "flag": false,
9022 "items": [
9023 {"label": "A", "flag": true},
9024 {"label": "B", "flag": false},
9025 {"label": "C", "flag": true}
9026 ]
9027 });
9028 let mut writer = TestWriter::new();
9029 handle(
9030 &protocol,
9031 &state,
9032 &RenderOptions::new("index.html", "/"),
9033 &mut writer,
9034 )
9035 .unwrap();
9036 assert_eq!(
9037 writer.get_content(),
9038 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
9039 );
9040 }
9041
9042 #[test]
9043 fn test_for_if_global_flag_no_effect() {
9044 let mut fragments = HashMap::new();
9045 fragments.insert(
9046 "index.html".to_string(),
9047 FragmentList {
9048 fragments: vec![
9049 WebUIFragment::raw("<div>"),
9050 WebUIFragment::for_loop("item", "items", "template1"),
9051 WebUIFragment::raw("</div>"),
9052 ],
9053 contains_boundary: false,
9054 },
9055 );
9056 fragments.insert(
9057 "template1".to_string(),
9058 FragmentList {
9059 fragments: vec![
9060 WebUIFragment::raw("<div>"),
9061 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
9062 WebUIFragment::raw("</div>"),
9063 ],
9064 contains_boundary: false,
9065 },
9066 );
9067 fragments.insert(
9068 "ifBlock".to_string(),
9069 FragmentList {
9070 fragments: vec![
9071 WebUIFragment::raw("<span>"),
9072 WebUIFragment::signal("item.label", false),
9073 WebUIFragment::raw("</span>"),
9074 ],
9075 contains_boundary: false,
9076 },
9077 );
9078 let protocol = WebUIProtocol::new(fragments);
9079 let state = test_json!({
9080 "item": {"flag": true},
9081 "items": [
9082 {"label": "A", "flag": false},
9083 {"label": "B", "flag": true},
9084 {"label": "C", "flag": false}
9085 ]
9086 });
9087 let mut writer = TestWriter::new();
9088 handle(
9089 &protocol,
9090 &state,
9091 &RenderOptions::new("index.html", "/"),
9092 &mut writer,
9093 )
9094 .unwrap();
9095 assert_eq!(
9096 writer.get_content(),
9097 "<div><div></div><div><span>B</span></div><div></div></div>"
9098 );
9099 }
9100
9101 #[test]
9104 fn test_recursive_template_refs() {
9105 let mut fragments = HashMap::new();
9106 fragments.insert(
9107 "index.html".to_string(),
9108 FragmentList {
9109 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
9110 contains_boundary: false,
9111 },
9112 );
9113 fragments.insert(
9114 "static".to_string(),
9115 FragmentList {
9116 fragments: vec![
9117 WebUIFragment::raw("<div expanded=\""),
9118 WebUIFragment::signal("item.expanded", false),
9119 WebUIFragment::raw("\" class=\""),
9120 WebUIFragment::signal("testScenario", false),
9121 WebUIFragment::raw("\"><span>"),
9122 WebUIFragment::signal("item.name", false),
9123 WebUIFragment::raw("</span>"),
9124 WebUIFragment::for_loop("item", "item.children", "static"),
9125 WebUIFragment::raw("</div>"),
9126 ],
9127 contains_boundary: false,
9128 },
9129 );
9130 let protocol = WebUIProtocol::new(fragments);
9131 let state = test_json!({
9132 "testScenario": "RecursiveTemplatesWithGlobalState",
9133 "items": [
9134 {"name": "A", "expanded": "false", "children": []},
9135 {"name": "B", "expanded": "true", "children": [
9136 {"name": "C", "expanded": "false"},
9137 {"name": "D", "expanded": "false"}
9138 ]},
9139 {"name": "E", "expanded": "false"}
9140 ]
9141 });
9142 let mut writer = TestWriter::new();
9143 handle(
9144 &protocol,
9145 &state,
9146 &RenderOptions::new("index.html", "/"),
9147 &mut writer,
9148 )
9149 .unwrap();
9150 assert_eq!(
9151 writer.get_content(),
9152 "<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>"
9153 );
9154 }
9155
9156 #[test]
9159 fn test_component_in_for_no_local_access() {
9160 let mut fragments = HashMap::new();
9161 fragments.insert(
9162 "index.html".to_string(),
9163 FragmentList {
9164 fragments: vec![
9165 WebUIFragment::raw("<div>"),
9166 WebUIFragment::for_loop("item", "items", "templateComponent"),
9167 WebUIFragment::raw("</div>"),
9168 ],
9169 contains_boundary: false,
9170 },
9171 );
9172 fragments.insert(
9173 "templateComponent".to_string(),
9174 FragmentList {
9175 fragments: vec![
9176 WebUIFragment::raw("<component-tag>"),
9177 WebUIFragment::component("my-component"),
9178 WebUIFragment::raw("</component-tag>"),
9179 ],
9180 contains_boundary: false,
9181 },
9182 );
9183 fragments.insert(
9184 "my-component".to_string(),
9185 FragmentList {
9186 fragments: vec![
9187 WebUIFragment::raw("<span>"),
9188 WebUIFragment::signal("name", false),
9189 WebUIFragment::raw("</span>"),
9190 ],
9191 contains_boundary: false,
9192 },
9193 );
9194 let protocol = WebUIProtocol::new(fragments);
9195 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
9196 let mut writer = TestWriter::new();
9197 handle(
9198 &protocol,
9199 &state,
9200 &RenderOptions::new("index.html", "/"),
9201 &mut writer,
9202 )
9203 .unwrap();
9204 assert_eq!(
9205 writer.get_content(),
9206 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
9207 );
9208 }
9209
9210 #[test]
9211 fn test_nested_for_hierarchical_state() {
9212 let mut fragments = HashMap::new();
9213 fragments.insert(
9214 "index.html".to_string(),
9215 FragmentList {
9216 fragments: vec![
9217 WebUIFragment::raw("<div>"),
9218 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9219 WebUIFragment::raw("</div>"),
9220 ],
9221 contains_boundary: false,
9222 },
9223 );
9224 fragments.insert(
9225 "outerTemplate".to_string(),
9226 FragmentList {
9227 fragments: vec![
9228 WebUIFragment::raw("<section>"),
9229 WebUIFragment::signal("globalPrefix", false),
9230 WebUIFragment::signal("outerItem.outerLabel", false),
9231 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9232 WebUIFragment::raw("</section>"),
9233 ],
9234 contains_boundary: false,
9235 },
9236 );
9237 fragments.insert(
9238 "innerTemplate".to_string(),
9239 FragmentList {
9240 fragments: vec![
9241 WebUIFragment::raw("<p>"),
9242 WebUIFragment::signal("globalPrefix", false),
9243 WebUIFragment::signal("outerItem.outerLabel", false),
9244 WebUIFragment::raw(": "),
9245 WebUIFragment::signal("innerItem.innerLabel", false),
9246 WebUIFragment::raw("</p>"),
9247 ],
9248 contains_boundary: false,
9249 },
9250 );
9251 let protocol = WebUIProtocol::new(fragments);
9252 let state = test_json!({
9253 "globalPrefix": "Prefix: ",
9254 "outerItems": [
9255 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9256 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
9257 ]
9258 });
9259 let mut writer = TestWriter::new();
9260 handle(
9261 &protocol,
9262 &state,
9263 &RenderOptions::new("index.html", "/"),
9264 &mut writer,
9265 )
9266 .unwrap();
9267 assert_eq!(
9268 writer.get_content(),
9269 "<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>"
9270 );
9271 }
9272
9273 #[test]
9274 fn test_component_in_for_global_only() {
9275 let mut fragments = HashMap::new();
9276 fragments.insert(
9277 "index.html".to_string(),
9278 FragmentList {
9279 fragments: vec![
9280 WebUIFragment::raw("<div>"),
9281 WebUIFragment::for_loop("item", "items", "templateComponent"),
9282 WebUIFragment::raw("</div>"),
9283 ],
9284 contains_boundary: false,
9285 },
9286 );
9287 fragments.insert(
9288 "templateComponent".to_string(),
9289 FragmentList {
9290 fragments: vec![
9291 WebUIFragment::raw("<component-tag>"),
9292 WebUIFragment::component("my-component"),
9293 WebUIFragment::raw("</component-tag>"),
9294 ],
9295 contains_boundary: false,
9296 },
9297 );
9298 fragments.insert(
9299 "my-component".to_string(),
9300 FragmentList {
9301 fragments: vec![
9302 WebUIFragment::raw("<span>"),
9303 WebUIFragment::signal("name", false),
9304 WebUIFragment::raw("-"),
9305 WebUIFragment::signal("globalSuffix", false),
9306 WebUIFragment::raw("</span>"),
9307 ],
9308 contains_boundary: false,
9309 },
9310 );
9311 let protocol = WebUIProtocol::new(fragments);
9312 let state =
9313 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
9314 let mut writer = TestWriter::new();
9315 handle(
9316 &protocol,
9317 &state,
9318 &RenderOptions::new("index.html", "/"),
9319 &mut writer,
9320 )
9321 .unwrap();
9322 assert_eq!(
9323 writer.get_content(),
9324 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9325 );
9326 }
9327
9328 #[test]
9329 fn test_component_no_item_moniker() {
9330 let mut fragments = HashMap::new();
9331 fragments.insert(
9332 "index.html".to_string(),
9333 FragmentList {
9334 fragments: vec![
9335 WebUIFragment::raw("<div>"),
9336 WebUIFragment::for_loop("item", "items", "templateComponent"),
9337 WebUIFragment::raw("</div>"),
9338 ],
9339 contains_boundary: false,
9340 },
9341 );
9342 fragments.insert(
9343 "templateComponent".to_string(),
9344 FragmentList {
9345 fragments: vec![
9346 WebUIFragment::raw("<component-tag>"),
9347 WebUIFragment::component("my-component"),
9348 WebUIFragment::raw("</component-tag>"),
9349 ],
9350 contains_boundary: false,
9351 },
9352 );
9353 fragments.insert(
9354 "my-component".to_string(),
9355 FragmentList {
9356 fragments: vec![
9357 WebUIFragment::raw("<span>"),
9358 WebUIFragment::signal("item.name", false),
9359 WebUIFragment::raw("-"),
9360 WebUIFragment::signal("globalSuffix", false),
9361 WebUIFragment::raw("</span>"),
9362 ],
9363 contains_boundary: false,
9364 },
9365 );
9366 let protocol = WebUIProtocol::new(fragments);
9367 let state =
9368 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
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!(
9378 writer.get_content(),
9379 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9380 );
9381 }
9382
9383 #[test]
9384 fn test_for_nonqualified_uses_global() {
9385 let mut fragments = HashMap::new();
9386 fragments.insert(
9387 "index.html".to_string(),
9388 FragmentList {
9389 fragments: vec![
9390 WebUIFragment::raw("<div>"),
9391 WebUIFragment::for_loop("item", "items", "template1"),
9392 WebUIFragment::raw("</div>"),
9393 ],
9394 contains_boundary: false,
9395 },
9396 );
9397 fragments.insert(
9398 "template1".to_string(),
9399 FragmentList {
9400 fragments: vec![
9401 WebUIFragment::raw("<span>"),
9402 WebUIFragment::signal("name", false),
9403 WebUIFragment::raw("</span>"),
9404 ],
9405 contains_boundary: false,
9406 },
9407 );
9408 let protocol = WebUIProtocol::new(fragments);
9409 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
9410 let mut writer = TestWriter::new();
9411 handle(
9412 &protocol,
9413 &state,
9414 &RenderOptions::new("index.html", "/"),
9415 &mut writer,
9416 )
9417 .unwrap();
9418 assert_eq!(
9419 writer.get_content(),
9420 "<div><span>GlobalName</span><span>GlobalName</span></div>"
9421 );
9422 }
9423
9424 #[test]
9425 fn test_nested_for_if_interleaved() {
9426 let mut fragments = HashMap::new();
9427 fragments.insert(
9428 "index.html".to_string(),
9429 FragmentList {
9430 fragments: vec![
9431 WebUIFragment::raw("<div>"),
9432 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9433 WebUIFragment::raw("</div>"),
9434 ],
9435 contains_boundary: false,
9436 },
9437 );
9438 fragments.insert(
9439 "outerTemplate".to_string(),
9440 FragmentList {
9441 fragments: vec![
9442 WebUIFragment::raw("<section>"),
9443 WebUIFragment::signal("globalPrefix", false),
9444 WebUIFragment::signal("outerItem.outerLabel", false),
9445 WebUIFragment::if_cond(
9446 ConditionExpr::identifier("outerItem.include"),
9447 "ifTemplate",
9448 ),
9449 WebUIFragment::raw("</section>"),
9450 ],
9451 contains_boundary: false,
9452 },
9453 );
9454 fragments.insert(
9455 "ifTemplate".to_string(),
9456 FragmentList {
9457 fragments: vec![
9458 WebUIFragment::raw("<div>"),
9459 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9460 WebUIFragment::raw("</div>"),
9461 ],
9462 contains_boundary: false,
9463 },
9464 );
9465 fragments.insert(
9466 "innerTemplate".to_string(),
9467 FragmentList {
9468 fragments: vec![
9469 WebUIFragment::raw("<p>"),
9470 WebUIFragment::signal("globalSuffix", false),
9471 WebUIFragment::raw(": "),
9472 WebUIFragment::signal("innerItem.innerLabel", false),
9473 WebUIFragment::raw("</p>"),
9474 ],
9475 contains_boundary: false,
9476 },
9477 );
9478 let protocol = WebUIProtocol::new(fragments);
9479 let state = test_json!({
9480 "globalPrefix": "Prefix: ",
9481 "globalSuffix": "Suffix",
9482 "outerItems": [
9483 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9484 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9485 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9486 ]
9487 });
9488 let mut writer = TestWriter::new();
9489 handle(
9490 &protocol,
9491 &state,
9492 &RenderOptions::new("index.html", "/"),
9493 &mut writer,
9494 )
9495 .unwrap();
9496 assert_eq!(
9497 writer.get_content(),
9498 "<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>"
9499 );
9500 }
9501
9502 #[test]
9503 fn test_nested_for_if_outer_state() {
9504 let mut fragments = HashMap::new();
9505 fragments.insert(
9506 "index.html".to_string(),
9507 FragmentList {
9508 fragments: vec![
9509 WebUIFragment::raw("<div>"),
9510 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9511 WebUIFragment::raw("</div>"),
9512 ],
9513 contains_boundary: false,
9514 },
9515 );
9516 fragments.insert(
9517 "outerTemplate".to_string(),
9518 FragmentList {
9519 fragments: vec![
9520 WebUIFragment::raw("<section>"),
9521 WebUIFragment::signal("globalPrefix", false),
9522 WebUIFragment::signal("outerItem.label", false),
9523 WebUIFragment::for_loop(
9524 "middleItem",
9525 "outerItem.middleItems",
9526 "middleTemplate",
9527 ),
9528 WebUIFragment::raw("</section>"),
9529 ],
9530 contains_boundary: false,
9531 },
9532 );
9533 fragments.insert(
9534 "middleTemplate".to_string(),
9535 FragmentList {
9536 fragments: vec![
9537 WebUIFragment::raw("<div>"),
9538 WebUIFragment::if_cond(
9539 ConditionExpr::identifier("outerItem.active"),
9540 "ifTemplate",
9541 ),
9542 WebUIFragment::raw("</div>"),
9543 ],
9544 contains_boundary: false,
9545 },
9546 );
9547 fragments.insert(
9548 "ifTemplate".to_string(),
9549 FragmentList {
9550 fragments: vec![
9551 WebUIFragment::raw("<p>"),
9552 WebUIFragment::signal("middleItem.value", false),
9553 WebUIFragment::raw("</p>"),
9554 ],
9555 contains_boundary: false,
9556 },
9557 );
9558 let protocol = WebUIProtocol::new(fragments);
9559 let state = test_json!({
9560 "globalPrefix": "GP-",
9561 "outerItems": [
9562 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9563 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9564 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9565 ]
9566 });
9567 let mut writer = TestWriter::new();
9568 handle(
9569 &protocol,
9570 &state,
9571 &RenderOptions::new("index.html", "/"),
9572 &mut writer,
9573 )
9574 .unwrap();
9575 assert_eq!(
9576 writer.get_content(),
9577 "<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>"
9578 );
9579 }
9580
9581 #[test]
9582 fn test_nested_for_if_inner_state() {
9583 let mut fragments = HashMap::new();
9584 fragments.insert(
9585 "index.html".to_string(),
9586 FragmentList {
9587 fragments: vec![
9588 WebUIFragment::raw("<div>"),
9589 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9590 WebUIFragment::raw("</div>"),
9591 ],
9592 contains_boundary: false,
9593 },
9594 );
9595 fragments.insert(
9596 "outerTemplate".to_string(),
9597 FragmentList {
9598 fragments: vec![
9599 WebUIFragment::raw("<section>"),
9600 WebUIFragment::signal("outerItem.label", false),
9601 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9602 WebUIFragment::raw("</section>"),
9603 ],
9604 contains_boundary: false,
9605 },
9606 );
9607 fragments.insert(
9608 "innerTemplate".to_string(),
9609 FragmentList {
9610 fragments: vec![
9611 WebUIFragment::raw("<article>"),
9612 WebUIFragment::if_cond(
9613 ConditionExpr::identifier("innerItem.show"),
9614 "ifTemplate",
9615 ),
9616 WebUIFragment::raw("</article>"),
9617 ],
9618 contains_boundary: false,
9619 },
9620 );
9621 fragments.insert(
9622 "ifTemplate".to_string(),
9623 FragmentList {
9624 fragments: vec![
9625 WebUIFragment::raw("<p>"),
9626 WebUIFragment::signal("innerItem.detail", false),
9627 WebUIFragment::raw("</p>"),
9628 ],
9629 contains_boundary: false,
9630 },
9631 );
9632 let protocol = WebUIProtocol::new(fragments);
9633 let state = test_json!({
9634 "outerItems": [
9635 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9636 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9637 ]
9638 });
9639 let mut writer = TestWriter::new();
9640 handle(
9641 &protocol,
9642 &state,
9643 &RenderOptions::new("index.html", "/"),
9644 &mut writer,
9645 )
9646 .unwrap();
9647 assert_eq!(
9648 writer.get_content(),
9649 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9650 );
9651 }
9652
9653 #[test]
9654 fn test_for_merge_local_global_monikers() {
9655 let mut fragments = HashMap::new();
9656 fragments.insert(
9657 "index.html".to_string(),
9658 FragmentList {
9659 fragments: vec![
9660 WebUIFragment::raw("<div>"),
9661 WebUIFragment::for_loop("item", "items", "template1"),
9662 WebUIFragment::raw("</div>"),
9663 ],
9664 contains_boundary: false,
9665 },
9666 );
9667 fragments.insert(
9668 "template1".to_string(),
9669 FragmentList {
9670 fragments: vec![
9671 WebUIFragment::raw("<span>"),
9672 WebUIFragment::signal("item.name", false),
9673 WebUIFragment::raw("-"),
9674 WebUIFragment::signal("item.globalValue", false),
9675 WebUIFragment::raw("-"),
9676 WebUIFragment::signal("item.localOnly", false),
9677 WebUIFragment::raw("-"),
9678 WebUIFragment::signal("item.otherVal", false),
9679 WebUIFragment::raw("</span>"),
9680 ],
9681 contains_boundary: false,
9682 },
9683 );
9684 let protocol = WebUIProtocol::new(fragments);
9685 let state = test_json!({
9686 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9687 "items": [
9688 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9689 {"name": "Local2", "localOnly": "Only2"}
9690 ]
9691 });
9692 let mut writer = TestWriter::new();
9693 handle(
9694 &protocol,
9695 &state,
9696 &RenderOptions::new("index.html", "/"),
9697 &mut writer,
9698 )
9699 .unwrap();
9700 assert_eq!(
9701 writer.get_content(),
9702 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9703 );
9704 }
9705
9706 #[test]
9707 fn test_component_in_for_global_moniker_shadow() {
9708 let mut fragments = HashMap::new();
9709 fragments.insert(
9710 "index.html".to_string(),
9711 FragmentList {
9712 fragments: vec![
9713 WebUIFragment::raw("<div>"),
9714 WebUIFragment::for_loop("item", "items", "templateComponent"),
9715 WebUIFragment::raw("</div>"),
9716 ],
9717 contains_boundary: false,
9718 },
9719 );
9720 fragments.insert(
9721 "templateComponent".to_string(),
9722 FragmentList {
9723 fragments: vec![
9724 WebUIFragment::raw("<component-tag>"),
9725 WebUIFragment::component("my-component"),
9726 WebUIFragment::raw("</component-tag>"),
9727 ],
9728 contains_boundary: false,
9729 },
9730 );
9731 fragments.insert(
9732 "my-component".to_string(),
9733 FragmentList {
9734 fragments: vec![
9735 WebUIFragment::raw("<span>"),
9736 WebUIFragment::signal("name", false),
9737 WebUIFragment::raw("-"),
9738 WebUIFragment::signal("item.globalValue", false),
9739 WebUIFragment::raw("-"),
9740 WebUIFragment::signal("localOnly", false),
9741 WebUIFragment::raw("-"),
9742 WebUIFragment::signal("item.otherVal", false),
9743 WebUIFragment::raw("</span>"),
9744 ],
9745 contains_boundary: false,
9746 },
9747 );
9748 let protocol = WebUIProtocol::new(fragments);
9749 let state = test_json!({
9750 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9751 "items": [
9752 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9753 {"name": "Local2", "localOnly": "Only2"}
9754 ]
9755 });
9756 let mut writer = TestWriter::new();
9757 handle(
9758 &protocol,
9759 &state,
9760 &RenderOptions::new("index.html", "/"),
9761 &mut writer,
9762 )
9763 .unwrap();
9764 assert_eq!(
9765 writer.get_content(),
9766 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9767 );
9768 }
9769
9770 #[test]
9771 fn test_if_in_nested_for_local_flag() {
9772 let mut fragments = HashMap::new();
9773 fragments.insert(
9774 "index.html".to_string(),
9775 FragmentList {
9776 fragments: vec![
9777 WebUIFragment::raw("<div>"),
9778 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9779 WebUIFragment::raw("</div>"),
9780 ],
9781 contains_boundary: false,
9782 },
9783 );
9784 fragments.insert(
9785 "outerTemplate".to_string(),
9786 FragmentList {
9787 fragments: vec![
9788 WebUIFragment::raw("<section>"),
9789 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9790 WebUIFragment::raw("</section>"),
9791 ],
9792 contains_boundary: false,
9793 },
9794 );
9795 fragments.insert(
9796 "innerTemplate".to_string(),
9797 FragmentList {
9798 fragments: vec![WebUIFragment::if_cond(
9799 ConditionExpr::identifier("inner_item.flag"),
9800 "ifInner",
9801 )],
9802 contains_boundary: false,
9803 },
9804 );
9805 fragments.insert(
9806 "ifInner".to_string(),
9807 FragmentList {
9808 fragments: vec![
9809 WebUIFragment::raw("<p>"),
9810 WebUIFragment::signal("inner_item.value", false),
9811 WebUIFragment::raw("</p>"),
9812 ],
9813 contains_boundary: false,
9814 },
9815 );
9816 let protocol = WebUIProtocol::new(fragments);
9817 let state = test_json!({
9818 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9819 "inner_item": {"flag": false}
9820 });
9821 let mut writer = TestWriter::new();
9822 handle(
9823 &protocol,
9824 &state,
9825 &RenderOptions::new("index.html", "/"),
9826 &mut writer,
9827 )
9828 .unwrap();
9829 assert_eq!(
9830 writer.get_content(),
9831 "<div><section><p>X</p></section></div>"
9832 );
9833 }
9834
9835 #[test]
9836 fn test_if_in_nested_for_global_fallback() {
9837 let mut fragments = HashMap::new();
9838 fragments.insert(
9839 "index.html".to_string(),
9840 FragmentList {
9841 fragments: vec![
9842 WebUIFragment::raw("<div>"),
9843 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9844 WebUIFragment::raw("</div>"),
9845 ],
9846 contains_boundary: false,
9847 },
9848 );
9849 fragments.insert(
9850 "outerTemplate".to_string(),
9851 FragmentList {
9852 fragments: vec![
9853 WebUIFragment::raw("<section>"),
9854 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9855 WebUIFragment::raw("</section>"),
9856 ],
9857 contains_boundary: false,
9858 },
9859 );
9860 fragments.insert(
9861 "innerTemplate".to_string(),
9862 FragmentList {
9863 fragments: vec![WebUIFragment::if_cond(
9864 ConditionExpr::identifier("inner_item.flag"),
9865 "ifInner",
9866 )],
9867 contains_boundary: false,
9868 },
9869 );
9870 fragments.insert(
9871 "ifInner".to_string(),
9872 FragmentList {
9873 fragments: vec![
9874 WebUIFragment::raw("<p>"),
9875 WebUIFragment::signal("inner_item.value", false),
9876 WebUIFragment::raw("</p>"),
9877 ],
9878 contains_boundary: false,
9879 },
9880 );
9881 let protocol = WebUIProtocol::new(fragments);
9882 let state = test_json!({
9883 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9884 "inner_item": {"flag": true}
9885 });
9886 let mut writer = TestWriter::new();
9887 handle(
9888 &protocol,
9889 &state,
9890 &RenderOptions::new("index.html", "/"),
9891 &mut writer,
9892 )
9893 .unwrap();
9894 assert_eq!(
9895 writer.get_content(),
9896 "<div><section><p>X</p><p>Y</p></section></div>"
9897 );
9898 }
9899
9900 #[test]
9901 fn test_if_mixed_for_monikers() {
9902 let mut fragments = HashMap::new();
9903 fragments.insert(
9904 "index.html".to_string(),
9905 FragmentList {
9906 fragments: vec![
9907 WebUIFragment::raw("<div>"),
9908 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9909 WebUIFragment::raw("</div>"),
9910 ],
9911 contains_boundary: false,
9912 },
9913 );
9914 fragments.insert(
9915 "outerTemplate".to_string(),
9916 FragmentList {
9917 fragments: vec![
9918 WebUIFragment::raw("<section>"),
9919 WebUIFragment::signal("outer.outerLabel", false),
9920 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9921 WebUIFragment::raw("</section>"),
9922 ],
9923 contains_boundary: false,
9924 },
9925 );
9926 fragments.insert(
9927 "innerTemplate".to_string(),
9928 FragmentList {
9929 fragments: vec![WebUIFragment::if_cond(
9930 ConditionExpr::compound(
9931 ConditionExpr::identifier("outer.active"),
9932 LogicalOperator::And,
9933 ConditionExpr::predicate(
9934 "inner.value",
9935 ComparisonOperator::GreaterThan,
9936 "globalLimit",
9937 ),
9938 ),
9939 "ifInner",
9940 )],
9941 contains_boundary: false,
9942 },
9943 );
9944 fragments.insert(
9945 "ifInner".to_string(),
9946 FragmentList {
9947 fragments: vec![
9948 WebUIFragment::raw("<p>"),
9949 WebUIFragment::signal("inner.value", false),
9950 WebUIFragment::raw("</p>"),
9951 ],
9952 contains_boundary: false,
9953 },
9954 );
9955 let protocol = WebUIProtocol::new(fragments);
9956 let state = test_json!({
9957 "globalLimit": 10,
9958 "list": {"outerItems": [
9959 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9960 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9961 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9962 ]}
9963 });
9964 let mut writer = TestWriter::new();
9965 handle(
9966 &protocol,
9967 &state,
9968 &RenderOptions::new("index.html", "/"),
9969 &mut writer,
9970 )
9971 .unwrap();
9972 assert_eq!(
9973 writer.get_content(),
9974 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9975 );
9976 }
9977
9978 fn make_route_protocol() -> WebUIProtocol {
9981 use webui_protocol::WebUiFragmentRoute;
9982
9983 let mut fragments = HashMap::new();
9984
9985 fragments.insert(
9987 "index.html".to_string(),
9988 FragmentList {
9989 fragments: vec![
9990 WebUIFragment::raw("<h1>Shell</h1>"),
9991 WebUIFragment::route_from(WebUiFragmentRoute {
9992 path: "/".into(),
9993 fragment_id: "dash-page".into(),
9994 exact: true,
9995 keep_alive: false,
9996 ..Default::default()
9997 }),
9998 WebUIFragment::route_from(WebUiFragmentRoute {
9999 path: "/contacts/:id".into(),
10000 fragment_id: "detail-page".into(),
10001 exact: true,
10002 keep_alive: true,
10003 ..Default::default()
10004 }),
10005 ],
10006
10007 contains_boundary: false,
10008 },
10009 );
10010
10011 fragments.insert(
10013 "dash-page".to_string(),
10014 FragmentList {
10015 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
10016 contains_boundary: false,
10017 },
10018 );
10019
10020 fragments.insert(
10022 "detail-page".to_string(),
10023 FragmentList {
10024 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
10025 contains_boundary: false,
10026 },
10027 );
10028
10029 WebUIProtocol::new(fragments)
10030 }
10031
10032 fn make_nested_route_protocol() -> WebUIProtocol {
10033 use webui_protocol::WebUiFragmentRoute;
10034
10035 let mut fragments = HashMap::new();
10036
10037 fragments.insert(
10038 "index.html".to_string(),
10039 FragmentList {
10040 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
10041 path: "/".into(),
10042 fragment_id: "app-shell".into(),
10043 exact: false,
10044 children: vec![WebUiFragmentRoute {
10045 path: "sections/:id".into(),
10046 fragment_id: "section-comp".into(),
10047 exact: false,
10048 children: vec![WebUiFragmentRoute {
10049 path: "topics/:topicId".into(),
10050 fragment_id: "topic-comp".into(),
10051 exact: true,
10052 children: vec![],
10053 keep_alive: false,
10054 ..Default::default()
10055 }],
10056 keep_alive: false,
10057 ..Default::default()
10058 }],
10059 keep_alive: false,
10060 ..Default::default()
10061 })],
10062
10063 contains_boundary: false,
10064 },
10065 );
10066
10067 fragments.insert(
10068 "app-shell".to_string(),
10069 FragmentList {
10070 fragments: vec![
10071 WebUIFragment::raw("<h1>Shell</h1>"),
10072 WebUIFragment::outlet(),
10073 ],
10074 contains_boundary: false,
10075 },
10076 );
10077
10078 fragments.insert(
10079 "section-comp".to_string(),
10080 FragmentList {
10081 fragments: vec![
10082 WebUIFragment::raw("<h2>Section</h2>"),
10083 WebUIFragment::outlet(),
10084 ],
10085 contains_boundary: false,
10086 },
10087 );
10088
10089 fragments.insert(
10090 "topic-comp".to_string(),
10091 FragmentList {
10092 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
10093 contains_boundary: false,
10094 },
10095 );
10096
10097 WebUIProtocol::new(fragments)
10098 }
10099
10100 #[test]
10101 fn test_route_renders_shell_always() {
10102 let protocol = make_route_protocol();
10103 let state = test_json!({});
10104 let mut writer = TestWriter::new();
10105 handle(
10106 &protocol,
10107 &state,
10108 &RenderOptions::new("index.html", "/"),
10109 &mut writer,
10110 )
10111 .unwrap();
10112 let html = writer.get_content();
10113
10114 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
10116 assert!(html.contains(" active>"), "matched route should be active");
10118 assert!(
10120 html.contains("style=\"display:none\""),
10121 "non-matched routes should be hidden"
10122 );
10123 }
10124
10125 #[test]
10126 fn test_route_matched_renders_visible() {
10127 let protocol = make_route_protocol();
10128 let state = test_json!({});
10129 let mut writer = TestWriter::new();
10130 handle(
10131 &protocol,
10132 &state,
10133 &RenderOptions::new("index.html", "/"),
10134 &mut writer,
10135 )
10136 .unwrap();
10137 let html = writer.get_content();
10138
10139 assert!(
10141 html.contains("<webui-route path=\"/\""),
10142 "dashboard route should exist"
10143 );
10144 assert!(
10145 html.contains("active>") && html.contains("<dash-page"),
10146 "matched route should be active with component tag: {html}"
10147 );
10148 assert!(
10149 html.contains("<p>Dashboard</p>"),
10150 "matched route should have content"
10151 );
10152 }
10153
10154 #[test]
10155 fn test_route_non_matched_renders_hidden_empty() {
10156 let protocol = make_route_protocol();
10157 let state = test_json!({});
10158 let mut writer = TestWriter::new();
10159 handle(
10160 &protocol,
10161 &state,
10162 &RenderOptions::new("index.html", "/"),
10163 &mut writer,
10164 )
10165 .unwrap();
10166 let html = writer.get_content();
10167
10168 assert!(
10170 html.contains("<webui-route path=\"/contacts/:id\""),
10171 "detail route element should exist"
10172 );
10173 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
10175 let after_detail = &html[detail_start..];
10176 assert!(
10177 after_detail.contains("style=\"display:none\">")
10178 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
10179 "non-matched route should be hidden: {after_detail}"
10180 );
10181 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
10183 let detail_body = &after_detail[..detail_end];
10184 assert!(
10185 !detail_body.contains("<detail-page"),
10186 "non-matched route should not render component content: {detail_body}"
10187 );
10188 }
10189
10190 #[test]
10191 fn test_route_parameterized_match() {
10192 let protocol = make_route_protocol();
10193 let state = test_json!({});
10194 let mut writer = TestWriter::new();
10195 handle(
10196 &protocol,
10197 &state,
10198 &RenderOptions::new("index.html", "/contacts/42"),
10199 &mut writer,
10200 )
10201 .unwrap();
10202 let html = writer.get_content();
10203
10204 assert!(
10206 html.contains("active>") && html.contains("<detail-page"),
10207 "detail route should be active: {html}"
10208 );
10209 assert!(html.contains("<p>Detail</p>"), "detail should have content");
10210 let dash_start = html
10212 .find("component=\"dash-page\"")
10213 .expect("dashboard route");
10214 let after_dash = &html[dash_start..];
10215 assert!(
10216 after_dash.contains("style=\"display:none\">"),
10217 "dashboard should be hidden when detail matches: {after_dash}"
10218 );
10219 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
10220 let dash_body = &after_dash[..dash_end];
10221 assert!(
10222 !dash_body.contains("<dash-page"),
10223 "dashboard should not render component content: {dash_body}"
10224 );
10225 }
10226
10227 #[test]
10228 fn test_route_no_match_all_hidden_empty() {
10229 let protocol = make_route_protocol();
10230 let state = test_json!({});
10231 let mut writer = TestWriter::new();
10232 handle(
10233 &protocol,
10234 &state,
10235 &RenderOptions::new("index.html", "/nonexistent"),
10236 &mut writer,
10237 )
10238 .unwrap();
10239 let html = writer.get_content();
10240
10241 assert!(html.contains("<h1>Shell</h1>"));
10243 assert!(
10245 !html.contains("<p>Dashboard</p>"),
10246 "no route content when nothing matches"
10247 );
10248 assert!(
10249 !html.contains("<p>Detail</p>"),
10250 "no route content when nothing matches"
10251 );
10252 }
10253
10254 #[test]
10255 fn test_route_component_attr_emitted() {
10256 let protocol = make_route_protocol();
10257 let state = test_json!({});
10258 let mut writer = TestWriter::new();
10259 handle(
10260 &protocol,
10261 &state,
10262 &RenderOptions::new("index.html", "/"),
10263 &mut writer,
10264 )
10265 .unwrap();
10266 let html = writer.get_content();
10267 assert!(
10269 html.contains("component=\"dash-page\""),
10270 "component attr should be on webui-route: {html}"
10271 );
10272 assert!(
10273 html.contains("component=\"detail-page\""),
10274 "component attr should be on webui-route: {html}"
10275 );
10276 assert!(
10277 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
10278 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
10279 );
10280 }
10281
10282 #[test]
10283 fn test_no_plugin_no_state_attributes() {
10284 let protocol = make_route_protocol();
10285 let state = test_json!({
10286 "title": "Fish & Chips",
10287 "cartOpen": true,
10288 "items": [{"name": "A&B"}]
10289 });
10290 let mut writer = TestWriter::new();
10291
10292 handle(
10293 &protocol,
10294 &state,
10295 &RenderOptions::new("index.html", "/"),
10296 &mut writer,
10297 )
10298 .unwrap();
10299
10300 let html = writer.get_content();
10301 assert!(
10303 !html.contains("data-state"),
10304 "no data-state without plugin: {html}"
10305 );
10306 assert!(
10307 !html.contains(r#"title="Fish"#),
10308 "no scalar attrs without plugin: {html}"
10309 );
10310 }
10311
10312 #[test]
10313 fn test_nested_routes_render_webui_route_as_light_dom() {
10314 let protocol = make_nested_route_protocol();
10315 let state = test_json!({"title": "Test"});
10316 let handler = WebUIHandler::new();
10317 let mut writer = TestWriter::new();
10318
10319 handler
10320 .handle(
10321 &protocol,
10322 &state,
10323 &RenderOptions::new("index.html", "/sections/frontend"),
10324 &mut writer,
10325 )
10326 .expect("render failed");
10327
10328 let html = writer.get_content();
10329
10330 assert!(
10331 html.contains("component=\"app-shell\"") && html.contains("active>"),
10332 "root route should be active: {html}"
10333 );
10334 assert!(
10336 !html.contains("<template shadowrootmode"),
10337 "webui-route should be light DOM (no shadow template): {html}"
10338 );
10339 }
10340
10341 #[test]
10342 fn test_nested_routes_render_outlet_as_light_dom() {
10343 let protocol = make_nested_route_protocol();
10344 let state = test_json!({"title": "Test"});
10345 let handler = WebUIHandler::new();
10346 let mut writer = TestWriter::new();
10347
10348 handler
10349 .handle(
10350 &protocol,
10351 &state,
10352 &RenderOptions::new("index.html", "/sections/frontend"),
10353 &mut writer,
10354 )
10355 .expect("render failed");
10356
10357 let html = writer.get_content();
10358
10359 assert!(
10361 !html.contains("<webui-outlet>"),
10362 "should not contain webui-outlet wrapper: {html}"
10363 );
10364 assert!(
10366 html.contains("<webui-route"),
10367 "should contain webui-route elements: {html}"
10368 );
10369 }
10370
10371 #[test]
10372 fn test_nested_routes_match_child_at_outlet() {
10373 let protocol = make_nested_route_protocol();
10374 let state = test_json!({"title": "Test"});
10375 let handler = WebUIHandler::new();
10376 let mut writer = TestWriter::new();
10377
10378 handler
10379 .handle(
10380 &protocol,
10381 &state,
10382 &RenderOptions::new("index.html", "/sections/frontend"),
10383 &mut writer,
10384 )
10385 .expect("render failed");
10386
10387 let html = writer.get_content();
10388
10389 assert!(
10390 html.contains("component=\"section-comp\"") && html.contains("active>"),
10391 "section route should be active: {html}"
10392 );
10393 assert!(
10394 html.contains("<h2>Section</h2>"),
10395 "section content should be present: {html}"
10396 );
10397 }
10398
10399 #[test]
10400 fn test_nested_routes_three_levels_deep() {
10401 let protocol = make_nested_route_protocol();
10402 let state = test_json!({"title": "Test"});
10403 let handler = WebUIHandler::new();
10404 let mut writer = TestWriter::new();
10405
10406 handler
10407 .handle(
10408 &protocol,
10409 &state,
10410 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
10411 &mut writer,
10412 )
10413 .expect("render failed");
10414
10415 let html = writer.get_content();
10416
10417 assert!(
10418 html.contains("component=\"app-shell\"") && html.contains("active>"),
10419 "root active: {html}"
10420 );
10421 assert!(
10422 html.contains("component=\"section-comp\"") && html.contains("active>"),
10423 "section active: {html}"
10424 );
10425 assert!(
10426 html.contains("component=\"topic-comp\"")
10427 && html.contains("exact")
10428 && html.contains("active>"),
10429 "topic active: {html}"
10430 );
10431 assert!(
10432 html.contains("<p>Topic content</p>"),
10433 "leaf content present: {html}"
10434 );
10435 }
10436
10437 #[test]
10438 fn test_nested_routes_nonmatched_siblings_hidden() {
10439 let protocol = make_nested_route_protocol();
10440 let state = test_json!({"title": "Test"});
10441 let handler = WebUIHandler::new();
10442 let mut writer = TestWriter::new();
10443
10444 handler
10445 .handle(
10446 &protocol,
10447 &state,
10448 &RenderOptions::new("index.html", "/sections/frontend"),
10449 &mut writer,
10450 )
10451 .expect("render failed");
10452
10453 let html = writer.get_content();
10454
10455 assert!(
10456 html.contains(r#"component="topic-comp" exact style="display:none">"#),
10457 "topic should be hidden: {html}"
10458 );
10459 }
10460
10461 #[test]
10462 fn test_nested_routes_preserve_sibling_declaration_order() {
10463 let mut protocol = make_nested_route_protocol();
10464 let Some(Fragment::Route(root)) = protocol
10465 .fragments
10466 .get_mut("index.html")
10467 .and_then(|entry| entry.fragments.first_mut())
10468 .and_then(|fragment| fragment.fragment.as_mut())
10469 else {
10470 panic!("nested route fixture must have a root route");
10471 };
10472 root.children.insert(
10473 0,
10474 webui_protocol::WebUiFragmentRoute {
10475 path: String::new(),
10476 fragment_id: "topic-comp".into(),
10477 exact: true,
10478 ..Default::default()
10479 },
10480 );
10481 root.children.push(webui_protocol::WebUiFragmentRoute {
10482 path: "*rest".into(),
10483 fragment_id: "topic-comp".into(),
10484 exact: true,
10485 ..Default::default()
10486 });
10487
10488 for path in ["/", "/sections/frontend", "/missing"] {
10489 let mut writer = TestWriter::new();
10490 WebUIHandler::new()
10491 .handle(
10492 &protocol,
10493 &test_json!({}),
10494 &RenderOptions::new("index.html", path),
10495 &mut writer,
10496 )
10497 .expect("render failed");
10498 let html = writer.get_content();
10499 let first = html.find(r#"<webui-route path="""#).expect("empty route");
10500 let middle = html
10501 .find(r#"<webui-route path="sections/:id""#)
10502 .expect("section route");
10503 let last = html
10504 .find(r#"<webui-route path="*rest""#)
10505 .expect("catch-all route");
10506 assert!(first < middle && middle < last, "{path}: {html}");
10507 }
10508 }
10509
10510 #[test]
10511 fn test_nested_routes_root_only() {
10512 let protocol = make_nested_route_protocol();
10513 let state = test_json!({"title": "Test"});
10514 let handler = WebUIHandler::new();
10515 let mut writer = TestWriter::new();
10516
10517 handler
10518 .handle(
10519 &protocol,
10520 &state,
10521 &RenderOptions::new("index.html", "/"),
10522 &mut writer,
10523 )
10524 .expect("render failed");
10525
10526 let html = writer.get_content();
10527
10528 assert!(
10529 html.contains("component=\"app-shell\"") && html.contains("active>"),
10530 "root active at /: {html}"
10531 );
10532 assert!(
10533 html.contains("<h1>Shell</h1>"),
10534 "shell renders at /: {html}"
10535 );
10536 assert!(
10537 html.contains(r#"component="section-comp" style="display:none">"#),
10538 "section hidden at /: {html}"
10539 );
10540 }
10541
10542 #[test]
10545 fn test_css_module_emitted_once_inline_in_component() {
10546 let template = r#"<p><slot></slot></p>"#;
10549
10550 let mut fragments = HashMap::new();
10551 fragments.insert(
10552 "index.html".to_string(),
10553 FragmentList {
10554 fragments: vec![
10555 WebUIFragment::raw("<html><head>".to_string()),
10556 structural_fragment("head_end"),
10557 WebUIFragment::raw("</head><body><div>".to_string()),
10558 WebUIFragment::component("my-card"),
10559 WebUIFragment::raw("A".to_string()),
10560 WebUIFragment::component("my-card"),
10561 WebUIFragment::raw("B</div>".to_string()),
10562 structural_fragment("body_end"),
10563 WebUIFragment::raw("</body></html>".to_string()),
10564 ],
10565 contains_boundary: false,
10566 },
10567 );
10568 fragments.insert(
10569 "my-card".to_string(),
10570 FragmentList {
10571 fragments: vec![WebUIFragment::raw(template.to_string())],
10572 contains_boundary: false,
10573 },
10574 );
10575
10576 let mut protocol = WebUIProtocol::new(fragments);
10577 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10578 protocol
10579 .components
10580 .entry("my-card".to_string())
10581 .or_default()
10582 .css = "p{color:red}".to_string();
10583 protocol.populate_style_closures(&["index.html"]);
10584 let state = test_json!({});
10585 let mut writer = TestWriter::new();
10586
10587 handle(
10588 &protocol,
10589 &state,
10590 &RenderOptions::new("index.html", "/"),
10591 &mut writer,
10592 )
10593 .unwrap();
10594
10595 let html = writer.get_content();
10596
10597 let count = html.matches(r#"<script type="importmap""#).count();
10599 assert_eq!(
10600 count, 1,
10601 "CSS module importmap should be emitted once, got {count} in: {html}"
10602 );
10603 assert!(
10604 html.contains(r#""my-card":"data:text/css,"#),
10605 "Importmap must register my-card under a data: URI: {html}"
10606 );
10607
10608 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10610 assert_eq!(
10611 tmpl_count, 2,
10612 "Template should render twice, got {tmpl_count} in: {html}"
10613 );
10614
10615 let css_pos = html
10617 .find(r#"<script type="importmap""#)
10618 .expect("CSS module importmap missing");
10619 let body_pos = html.find("<body>").expect("<body> missing");
10620 assert!(
10621 css_pos > body_pos,
10622 "CSS module should be inline in component, not in <head>: {html}"
10623 );
10624 }
10625
10626 #[test]
10627 fn test_component_without_css_renders_normally() {
10628 let mut fragments = HashMap::new();
10630 fragments.insert(
10631 "index.html".to_string(),
10632 FragmentList {
10633 fragments: vec![WebUIFragment::component("my-card")],
10634 contains_boundary: false,
10635 },
10636 );
10637 fragments.insert(
10638 "my-card".to_string(),
10639 FragmentList {
10640 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10641 contains_boundary: false,
10642 },
10643 );
10644
10645 let protocol = WebUIProtocol::new(fragments);
10646 let state = test_json!({});
10647 let mut writer = TestWriter::new();
10648
10649 handle(
10650 &protocol,
10651 &state,
10652 &RenderOptions::new("index.html", "/"),
10653 &mut writer,
10654 )
10655 .unwrap();
10656
10657 let html = writer.get_content();
10658 assert!(
10659 html.contains("<p>hello</p>"),
10660 "Non-module component should render normally: {html}"
10661 );
10662 }
10663
10664 #[test]
10665 fn test_non_module_strategy_no_css_in_head() {
10666 let template = r#"<p>hello</p>"#;
10669
10670 let mut fragments = HashMap::new();
10671 fragments.insert(
10672 "index.html".to_string(),
10673 FragmentList {
10674 fragments: vec![
10675 WebUIFragment::raw("<html><head>".to_string()),
10676 structural_fragment("head_end"),
10677 WebUIFragment::raw("</head><body>".to_string()),
10678 WebUIFragment::component("my-card"),
10679 WebUIFragment::raw("</body></html>".to_string()),
10680 ],
10681 contains_boundary: false,
10682 },
10683 );
10684 fragments.insert(
10685 "my-card".to_string(),
10686 FragmentList {
10687 fragments: vec![WebUIFragment::raw(template.to_string())],
10688 contains_boundary: false,
10689 },
10690 );
10691
10692 let protocol = WebUIProtocol::new(fragments);
10694 let state = test_json!({});
10695 let mut writer = TestWriter::new();
10696
10697 handle(
10698 &protocol,
10699 &state,
10700 &RenderOptions::new("index.html", "/"),
10701 &mut writer,
10702 )
10703 .unwrap();
10704
10705 let html = writer.get_content();
10706
10707 assert!(
10708 !html.contains(r#"<style type="module""#),
10709 "Non-module strategy should not emit CSS module tags in <head>: {html}"
10710 );
10711 assert!(
10712 !html.contains(r#"<script type="importmap""#),
10713 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10714 );
10715 assert!(
10716 html.contains("<p>hello</p>"),
10717 "Component should still render: {html}"
10718 );
10719 }
10720
10721 #[test]
10722 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10723 let mut fragments = HashMap::new();
10724 fragments.insert(
10725 "index.html".to_string(),
10726 FragmentList {
10727 fragments: vec![
10728 WebUIFragment::raw("<html><head>".to_string()),
10729 structural_fragment("head_end"),
10730 WebUIFragment::raw("</head><body><my-card>".to_string()),
10731 WebUIFragment::component("my-card"),
10732 WebUIFragment::raw("</my-card></body></html>".to_string()),
10733 ],
10734 contains_boundary: false,
10735 },
10736 );
10737 fragments.insert(
10738 "my-card".to_string(),
10739 FragmentList {
10740 fragments: vec![WebUIFragment::raw(
10741 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10742 .to_string(),
10743 )],
10744 contains_boundary: false,
10745},
10746 );
10747
10748 let protocol = WebUIProtocol::new(fragments);
10749 let state = test_json!({});
10750 let mut writer = TestWriter::new();
10751
10752 handle(
10753 &protocol,
10754 &state,
10755 &RenderOptions::new("index.html", "/"),
10756 &mut writer,
10757 )
10758 .unwrap();
10759
10760 let html = writer.get_content();
10761
10762 assert!(
10763 html.contains("<style>.card{color:red}</style>"),
10764 "Style strategy should embed inline CSS in shadow template: {html}"
10765 );
10766 assert!(
10767 !html.contains(r#"<style type="module""#),
10768 "Style strategy should not emit legacy module CSS in <head>: {html}"
10769 );
10770 assert!(
10771 !html.contains(r#"<script type="importmap""#),
10772 "Style strategy should not emit CSS module importmaps in <head>: {html}"
10773 );
10774 }
10775
10776 #[test]
10777 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10778 let mut fragments = HashMap::new();
10781 fragments.insert(
10782 "index.html".to_string(),
10783 FragmentList {
10784 fragments: vec![
10785 WebUIFragment::raw("<html><head>".to_string()),
10786 structural_fragment("head_end"),
10787 WebUIFragment::raw("</head><body><my-card>".to_string()),
10788 WebUIFragment::component("my-card"),
10789 WebUIFragment::raw("</my-card>".to_string()),
10790 structural_fragment("body_end"),
10791 WebUIFragment::raw("</body></html>".to_string()),
10792 ],
10793 contains_boundary: false,
10794 },
10795 );
10796 fragments.insert(
10797 "my-card".to_string(),
10798 FragmentList {
10799 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10800 contains_boundary: false,
10801 },
10802 );
10803
10804 let mut protocol = WebUIProtocol::new(fragments);
10805 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10806
10807 let comp = protocol
10808 .components
10809 .entry("my-card".to_string())
10810 .or_default();
10811 comp.css_href = "my-card.css".to_string();
10812 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10813 protocol.populate_style_closures(&["index.html"]);
10814
10815 let state = test_json!({});
10816 let mut writer = TestWriter::new();
10817
10818 handle(
10819 &protocol,
10820 &state,
10821 &RenderOptions::new("index.html", "/"),
10822 &mut writer,
10823 )
10824 .unwrap();
10825
10826 let html = writer.get_content();
10827
10828 let head_end = html.find("</head>").expect("</head> missing");
10829 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10830 assert!(
10831 link_pos.is_some_and(|p| p < head_end),
10832 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10833 );
10834 assert!(
10835 !html.contains(r#"<link rel="preload""#),
10836 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10837 );
10838 assert!(
10839 !html.contains(r#"<style type="module""#),
10840 "Link strategy should not emit legacy module CSS: {html}"
10841 );
10842 assert!(
10843 !html.contains(r#"<script type="importmap""#),
10844 "Link strategy should not emit CSS module importmaps: {html}"
10845 );
10846 }
10847
10848 #[test]
10849 fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10850 let mut fragments = HashMap::new();
10857 fragments.insert(
10858 "index.html".to_string(),
10859 FragmentList {
10860 fragments: vec![
10861 WebUIFragment::raw("<html><head>".to_string()),
10862 structural_fragment("head_end"),
10863 WebUIFragment::raw(
10864 "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10865 .to_string(),
10866 ),
10867 WebUIFragment::component("comp-a"),
10868 WebUIFragment::component("comp-b"),
10869 WebUIFragment::component("comp-c"),
10870 structural_fragment("body_end"),
10871 WebUIFragment::raw("</body></html>".to_string()),
10872 ],
10873 contains_boundary: false,
10874 },
10875 );
10876 for tag in ["comp-a", "comp-b", "comp-c"] {
10877 fragments.insert(
10878 tag.to_string(),
10879 FragmentList {
10880 fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10881 contains_boundary: false,
10882 },
10883 );
10884 }
10885
10886 let mut protocol = WebUIProtocol::new(fragments);
10887 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10888 for tag in ["comp-a", "comp-b", "comp-c"] {
10889 let comp = protocol.components.entry(tag.to_string()).or_default();
10890 comp.css_href = format!("{tag}.css");
10891 comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10892 }
10893 protocol.populate_style_closures(&["index.html"]);
10894
10895 let state = test_json!({});
10896 let mut writer = TestWriter::new();
10897 handle(
10898 &protocol,
10899 &state,
10900 &RenderOptions::new("index.html", "/"),
10901 &mut writer,
10902 )
10903 .unwrap();
10904 let html = writer.get_content();
10905
10906 let pos_a = html
10907 .find(r#"href="comp-a.css""#)
10908 .expect("comp-a.css link missing");
10909 let pos_b = html
10910 .find(r#"href="comp-b.css""#)
10911 .expect("comp-b.css link missing");
10912 let pos_c = html
10913 .find(r#"href="comp-c.css""#)
10914 .expect("comp-c.css link missing");
10915 assert!(
10916 pos_a < pos_b && pos_b < pos_c,
10917 "stylesheet <link> tags must follow document/traversal order \
10918 (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10919 );
10920 }
10921
10922 #[test]
10923 fn link_strategy_preloads_static_shadow_css_in_head() {
10924 let mut fragments = HashMap::new();
10925 fragments.insert(
10926 "index.html".to_string(),
10927 FragmentList {
10928 fragments: vec![
10929 WebUIFragment::raw("<html><head>".to_string()),
10930 structural_fragment("head_end"),
10931 WebUIFragment::raw("</head><body><my-card>".to_string()),
10932 WebUIFragment::component("my-card"),
10933 WebUIFragment::raw("</my-card></body></html>".to_string()),
10934 ],
10935 contains_boundary: false,
10936 },
10937 );
10938 fragments.insert(
10939 "o-loading-state".to_string(),
10940 FragmentList {
10941 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10942 contains_boundary: false,
10943 },
10944 );
10945 fragments.insert(
10946 "my-card".to_string(),
10947 FragmentList {
10948 fragments: vec![
10949 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10950 structural_fragment("shadow_styles:my-card"),
10951 WebUIFragment::raw("<div>card</div></template>".to_string()),
10952 ],
10953 contains_boundary: false,
10954 },
10955 );
10956
10957 let mut protocol = WebUIProtocol::new(fragments);
10958 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10959 let component = protocol
10960 .components
10961 .entry("my-card".to_string())
10962 .or_default();
10963 component.css_href = "my-card.css".to_string();
10964 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10965 component.uses_shadow_dom = true;
10966 protocol.populate_style_closures(&["index.html"]);
10967
10968 let mut writer = TestWriter::new();
10969 handle(
10970 &protocol,
10971 &test_json!({}),
10972 &RenderOptions::new("index.html", "/"),
10973 &mut writer,
10974 )
10975 .unwrap();
10976
10977 let html = writer.get_content();
10978 let head_end = html.find("</head>").expect("</head> missing");
10979 let preload = html
10980 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10981 .expect("Shadow preload missing");
10982 let stylesheet = html
10983 .find(r#"<link rel="stylesheet" href="my-card.css""#)
10984 .expect("Shadow stylesheet missing");
10985 assert!(preload < head_end);
10986 assert!(stylesheet > head_end);
10987 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10988 }
10989
10990 #[test]
10991 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10992 let fragments = HashMap::from([
10993 (
10994 "index.html".to_string(),
10995 FragmentList {
10996 fragments: vec![
10997 WebUIFragment::raw("<html><head>".to_string()),
10998 structural_fragment("head_end"),
10999 WebUIFragment::raw("</head><body><outer-box>".to_string()),
11000 WebUIFragment::component("outer-box"),
11001 WebUIFragment::raw("</outer-box></body></html>".to_string()),
11002 ],
11003 contains_boundary: false,
11004 },
11005 ),
11006 (
11007 "outer-box".to_string(),
11008 FragmentList {
11009 fragments: vec![
11010 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11011 structural_fragment("shadow_styles:outer-box"),
11012 WebUIFragment::raw("<light-card>".to_string()),
11013 WebUIFragment::component("light-card"),
11014 WebUIFragment::raw("</light-card><light-card>".to_string()),
11015 WebUIFragment::component("light-card"),
11016 WebUIFragment::raw("</light-card><inner-box>".to_string()),
11017 WebUIFragment::component("inner-box"),
11018 WebUIFragment::raw("</inner-box><light-card>".to_string()),
11019 WebUIFragment::component("light-card"),
11020 WebUIFragment::raw("</light-card></template>".to_string()),
11021 ],
11022 contains_boundary: false,
11023 },
11024 ),
11025 (
11026 "inner-box".to_string(),
11027 FragmentList {
11028 fragments: vec![
11029 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11030 structural_fragment("shadow_styles:inner-box"),
11031 WebUIFragment::raw("<light-card>".to_string()),
11032 WebUIFragment::component("light-card"),
11033 WebUIFragment::raw("</light-card></template>".to_string()),
11034 ],
11035 contains_boundary: false,
11036 },
11037 ),
11038 (
11039 "light-card".to_string(),
11040 FragmentList {
11041 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
11042 contains_boundary: false,
11043 },
11044 ),
11045 ]);
11046 let mut protocol = WebUIProtocol::new(fragments);
11047 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11048 for (tag, css, uses_shadow_dom) in [
11049 ("outer-box", ".outer{}", true),
11050 ("inner-box", ".inner{}", true),
11051 ("light-card", ".card{}", false),
11052 ] {
11053 let component = protocol.components.entry(tag.to_string()).or_default();
11054 component.css = css.to_string();
11055 component.uses_shadow_dom = uses_shadow_dom;
11056 }
11057 protocol.populate_style_closures(&["index.html"]);
11058
11059 let mut writer = TestWriter::new();
11060 handle(
11061 &protocol,
11062 &test_json!({}),
11063 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11064 &mut writer,
11065 )
11066 .unwrap();
11067 let html = writer.get_content();
11068
11069 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
11070 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
11071 assert_eq!(
11072 html.matches("data-webui-resource=\"light-card\"").count(),
11073 2,
11074 "the Light resource installs once in each Shadow tree: {html}"
11075 );
11076 assert_eq!(
11077 html.matches("data-webui-resource=").count(),
11078 4,
11079 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
11080 );
11081 assert_eq!(
11082 html.matches("nonce=\"css-nonce\"").count(),
11083 4,
11084 "each inline style resource carries the nonce"
11085 );
11086 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
11087 let inner_end = html.find("</inner-box>").expect("inner host close");
11088 let caller_continuation = &html[inner_end..];
11089 let trailing_card = caller_continuation
11090 .find("<p>card</p>")
11091 .expect("caller continues after nested Shadow root");
11092 assert!(trailing_card > 0);
11093 assert!(
11094 !caller_continuation.contains("data-webui-resource=\"light-card\""),
11095 "returning from the nested Shadow root must restore the outer frame's delivered set"
11096 );
11097 }
11098
11099 #[test]
11100 fn routed_document_styles_are_hoisted_for_the_active_chain() {
11101 let route = WebUiFragmentRoute {
11102 path: "/".to_string(),
11103 fragment_id: "app-shell".to_string(),
11104 children: vec![
11105 WebUiFragmentRoute {
11106 path: String::new(),
11107 fragment_id: "dashboard-page".to_string(),
11108 exact: true,
11109 ..Default::default()
11110 },
11111 WebUiFragmentRoute {
11112 path: "details".to_string(),
11113 fragment_id: "detail-page".to_string(),
11114 exact: true,
11115 ..Default::default()
11116 },
11117 ],
11118 ..Default::default()
11119 };
11120 let fragments = HashMap::from([
11121 (
11122 "index.html".to_string(),
11123 FragmentList {
11124 fragments: vec![
11125 WebUIFragment::raw("<html><head>"),
11126 structural_fragment("head_start"),
11127 structural_fragment("head_end"),
11128 WebUIFragment::raw("</head><body>"),
11129 structural_fragment("body_start"),
11130 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11131 WebUIFragment::route_from(route),
11132 WebUIFragment::boundary_end(0),
11133 structural_fragment("body_end"),
11134 WebUIFragment::raw("</body></html>"),
11135 ],
11136 contains_boundary: true,
11137 },
11138 ),
11139 (
11140 "app-shell".to_string(),
11141 FragmentList {
11142 fragments: vec![
11143 WebUIFragment::raw("<header>App</header>"),
11144 WebUIFragment::outlet(),
11145 ],
11146 contains_boundary: false,
11147 },
11148 ),
11149 (
11150 "dashboard-page".to_string(),
11151 FragmentList {
11152 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11153 contains_boundary: false,
11154 },
11155 ),
11156 (
11157 "detail-page".to_string(),
11158 FragmentList {
11159 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11160 contains_boundary: false,
11161 },
11162 ),
11163 ]);
11164 let mut protocol = WebUIProtocol::new(fragments);
11165 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11166 for tag in ["app-shell", "dashboard-page", "detail-page"] {
11167 let component = protocol.components.entry(tag.to_string()).or_default();
11168 component.css_href = format!("/{tag}.css");
11169 component.template_json = r#"{"h":""}"#.to_string();
11170 }
11171 protocol.populate_style_closures(&["index.html"]);
11172
11173 let mut writer = TestWriter::new();
11174 handle(
11175 &protocol,
11176 &test_json!({}),
11177 &RenderOptions::new("index.html", "/"),
11178 &mut writer,
11179 )
11180 .unwrap();
11181 let html = writer.get_content();
11182
11183 assert_eq!(
11184 html.matches(r#"data-webui-resource="app-shell""#).count(),
11185 1,
11186 "the matched route shell must install once: {html}"
11187 );
11188 assert_eq!(
11189 html.matches(r#"data-webui-resource="dashboard-page""#)
11190 .count(),
11191 1,
11192 "the matched child route must install once: {html}"
11193 );
11194 assert!(
11195 !html.contains(r#"data-webui-resource="detail-page""#),
11196 "inactive route CSS must not be delivered: {html}"
11197 );
11198 let head_end = html.find("</head>").expect("head close");
11199 let head = &html[..head_end];
11200 for (resource, href) in [
11201 ("app-shell", "/app-shell.css"),
11202 ("dashboard-page", "/dashboard-page.css"),
11203 ] {
11204 let encoded_href = crate::html_encode::encode_safe(href);
11205 assert!(
11206 head.contains(&format!(
11207 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
11208 )),
11209 "Document-targeted route CSS must be render-blocking in head: {html}"
11210 );
11211 assert!(
11212 !head.contains(&format!(
11213 r#"<link rel="preload" as="style" href="{encoded_href}">"#
11214 )),
11215 "a hoisted stylesheet must not also be preloaded: {html}"
11216 );
11217 }
11218 assert!(
11219 !head.contains("detail-page.css"),
11220 "inactive route CSS must not be preloaded: {html}"
11221 );
11222 let app_style = html
11223 .find(r#"data-webui-resource="app-shell""#)
11224 .expect("app style");
11225 let dashboard_style = html
11226 .find(r#"data-webui-resource="dashboard-page""#)
11227 .expect("dashboard style");
11228 let app_host = html.find("<app-shell").expect("app route host");
11229 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11230 assert!(
11231 app_style < dashboard_style
11232 && dashboard_style < head_end
11233 && head_end < app_host
11234 && app_host < dashboard_host,
11235 "matched Document route closures must be hoisted in chain order: {html}"
11236 );
11237
11238 let protocol = Protocol::new(protocol);
11239 let mut streamed = FlushTestWriter::default();
11240 WebUIHandler::new()
11241 .render_streaming(
11242 &protocol,
11243 &test_json!({}),
11244 &RenderOptions::new("index.html", "/"),
11245 &mut streamed,
11246 )
11247 .unwrap();
11248 let streamed_head_end = streamed
11249 .output
11250 .find("</head>")
11251 .expect("streamed head close");
11252 for resource in ["app-shell", "dashboard-page"] {
11253 let marker = format!(r#"data-webui-resource="{resource}""#);
11254 let position = streamed.output.find(&marker).expect("streamed route style");
11255 assert!(
11256 position < streamed_head_end,
11257 "streaming must hoist {resource} into head: {}",
11258 streamed.output
11259 );
11260 assert_eq!(
11261 streamed.output.matches(&marker).count(),
11262 1,
11263 "streaming must not emit {resource} twice: {}",
11264 streamed.output
11265 );
11266 }
11267 }
11268
11269 #[test]
11270 fn bundled_routed_document_stylesheet_is_hoisted_once() {
11271 let route = WebUiFragmentRoute {
11272 path: "/".to_string(),
11273 fragment_id: "dashboard-page".to_string(),
11274 exact: true,
11275 ..Default::default()
11276 };
11277 let mut protocol = WebUIProtocol::new(HashMap::from([
11278 (
11279 "index.html".to_string(),
11280 FragmentList {
11281 fragments: vec![
11282 WebUIFragment::raw("<html><head>"),
11283 structural_fragment("head_start"),
11284 structural_fragment("head_end"),
11285 WebUIFragment::raw("</head><body>"),
11286 WebUIFragment::route_from(route),
11287 structural_fragment("body_end"),
11288 WebUIFragment::raw("</body></html>"),
11289 ],
11290 contains_boundary: false,
11291 },
11292 ),
11293 (
11294 "dashboard-page".to_string(),
11295 FragmentList {
11296 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11297 contains_boundary: false,
11298 },
11299 ),
11300 ]));
11301 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11302 for tag in ["dashboard-page", "summary-card", "activity-list"] {
11303 protocol
11304 .components
11305 .entry(tag.to_string())
11306 .or_default()
11307 .css_href = format!("/{tag}.css");
11308 }
11309 protocol.style_closures.insert(
11310 "index.html".to_string(),
11311 webui_protocol::ComponentStyleClosure::default(),
11312 );
11313 protocol.style_closures.insert(
11314 "dashboard-page".to_string(),
11315 webui_protocol::ComponentStyleClosure {
11316 component_tags: vec![
11317 "dashboard-page".to_string(),
11318 "summary-card".to_string(),
11319 "activity-list".to_string(),
11320 ],
11321 style_chunks: vec![0],
11322 },
11323 );
11324 protocol.style_chunks.push(webui_protocol::StyleChunk {
11325 name: "_chunk-dashboard-page-3".to_string(),
11326 css: String::new(),
11327 css_href: "/_chunk-dashboard-page-3.css".to_string(),
11328 component_tags: vec![
11329 "dashboard-page".to_string(),
11330 "summary-card".to_string(),
11331 "activity-list".to_string(),
11332 ],
11333 });
11334
11335 let mut writer = TestWriter::new();
11336 handle(
11337 &protocol,
11338 &test_json!({}),
11339 &RenderOptions::new("index.html", "/"),
11340 &mut writer,
11341 )
11342 .unwrap();
11343 let html = writer.get_content();
11344 let head_end = html.find("</head>").expect("head close");
11345 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
11346 let style = html.find(marker).expect("bundled route stylesheet");
11347
11348 assert!(
11349 style < head_end,
11350 "bundled route CSS must be in head: {html}"
11351 );
11352 assert_eq!(
11353 html.matches(marker).count(),
11354 1,
11355 "the route host must not emit the hoisted chunk again: {html}"
11356 );
11357 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
11358 assert!(
11359 !html.contains(&format!(
11360 r#"<link rel="preload" as="style" href="{bundled_href}">"#
11361 )),
11362 "the applied bundled stylesheet must not also be preloaded: {html}"
11363 );
11364 }
11365
11366 #[test]
11367 fn routed_document_inline_styles_are_hoisted() {
11368 for strategy in [
11369 webui_protocol::CssStrategy::Style,
11370 webui_protocol::CssStrategy::Module,
11371 ] {
11372 let route = WebUiFragmentRoute {
11373 path: "/".to_string(),
11374 fragment_id: "dashboard-page".to_string(),
11375 exact: true,
11376 ..Default::default()
11377 };
11378 let mut protocol = WebUIProtocol::new(HashMap::from([
11379 (
11380 "index.html".to_string(),
11381 FragmentList {
11382 fragments: vec![
11383 WebUIFragment::raw("<html><head>"),
11384 structural_fragment("head_start"),
11385 structural_fragment("head_end"),
11386 WebUIFragment::raw("</head><body>"),
11387 WebUIFragment::route_from(route),
11388 structural_fragment("body_end"),
11389 WebUIFragment::raw("</body></html>"),
11390 ],
11391 contains_boundary: false,
11392 },
11393 ),
11394 (
11395 "dashboard-page".to_string(),
11396 FragmentList {
11397 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11398 contains_boundary: false,
11399 },
11400 ),
11401 ]));
11402 protocol.set_css_strategy(strategy);
11403 protocol
11404 .components
11405 .entry("dashboard-page".to_string())
11406 .or_default()
11407 .css = ".dashboard{display:grid}".to_string();
11408 protocol.populate_style_closures(&["index.html"]);
11409
11410 let mut writer = TestWriter::new();
11411 handle(
11412 &protocol,
11413 &test_json!({}),
11414 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11415 &mut writer,
11416 )
11417 .unwrap();
11418 let html = writer.get_content();
11419 let head_end = html.find("</head>").expect("head close");
11420 let marker = r#"data-webui-resource="dashboard-page""#;
11421 let style = html.find(marker).expect("route style fallback");
11422 let host = html.find("<dashboard-page").expect("dashboard route host");
11423 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
11424 "module"
11425 } else {
11426 "style"
11427 };
11428 let style_marker = format!(
11429 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11430 );
11431
11432 assert!(
11433 style < head_end && head_end < host,
11434 "{strategy:?} route CSS must be applied in head before its host: {html}"
11435 );
11436 assert_eq!(
11437 html.matches(&style_marker).count(),
11438 1,
11439 "{strategy:?} route CSS must not be emitted twice: {html}"
11440 );
11441 }
11442 }
11443
11444 #[test]
11445 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11446 let route = WebUiFragmentRoute {
11447 path: "/".to_string(),
11448 fragment_id: "dashboard-page".to_string(),
11449 exact: true,
11450 ..Default::default()
11451 };
11452 let mut protocol = WebUIProtocol::new(HashMap::from([
11453 (
11454 "index.html".to_string(),
11455 FragmentList {
11456 fragments: vec![
11457 WebUIFragment::raw("<html><head>"),
11458 structural_fragment("head_start"),
11459 structural_fragment("head_end"),
11460 WebUIFragment::raw("</head><body><outer-box>"),
11461 WebUIFragment::component("outer-box"),
11462 WebUIFragment::raw("</outer-box></body></html>"),
11463 ],
11464 contains_boundary: false,
11465 },
11466 ),
11467 (
11468 "outer-box".to_string(),
11469 FragmentList {
11470 fragments: vec![
11471 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11472 structural_fragment("shadow_styles:outer-box"),
11473 WebUIFragment::route_from(route),
11474 WebUIFragment::raw("</template>"),
11475 ],
11476 contains_boundary: false,
11477 },
11478 ),
11479 (
11480 "dashboard-page".to_string(),
11481 FragmentList {
11482 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11483 contains_boundary: false,
11484 },
11485 ),
11486 ]));
11487 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11488 for tag in ["outer-box", "dashboard-page"] {
11489 protocol
11490 .components
11491 .entry(tag.to_string())
11492 .or_default()
11493 .css_href = format!("/{tag}.css");
11494 }
11495 protocol
11496 .components
11497 .get_mut("outer-box")
11498 .expect("outer-box component")
11499 .uses_shadow_dom = true;
11500 protocol.populate_style_closures(&["index.html"]);
11501
11502 let mut writer = TestWriter::new();
11503 handle(
11504 &protocol,
11505 &test_json!({}),
11506 &RenderOptions::new("index.html", "/"),
11507 &mut writer,
11508 )
11509 .unwrap();
11510 let html = writer.get_content();
11511 let head_end = html.find("</head>").expect("head close");
11512 let head = &html[..head_end];
11513 let dashboard_style = html
11514 .find(r#"data-webui-resource="dashboard-page""#)
11515 .expect("tree-local dashboard stylesheet");
11516 let shadow_start = html
11517 .find("<template shadowrootmode=\"open\">")
11518 .expect("Shadow template");
11519 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11520 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11521
11522 assert!(
11523 head.contains(&format!(
11524 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11525 )),
11526 "Shadow-targeted route CSS must start loading from head: {html}"
11527 );
11528 assert!(
11529 !head.contains(r#"data-webui-resource="dashboard-page""#),
11530 "Shadow-targeted route CSS must not be applied to Document: {html}"
11531 );
11532 assert!(
11533 shadow_start < dashboard_style && dashboard_style < dashboard_host,
11534 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11535 );
11536 }
11537
11538 #[test]
11539 fn isolated_shadow_entry_does_not_emit_document_preloads() {
11540 let route = WebUiFragmentRoute {
11541 path: "/".to_string(),
11542 fragment_id: "dashboard-page".to_string(),
11543 exact: true,
11544 ..Default::default()
11545 };
11546 let mut protocol = WebUIProtocol::new(HashMap::from([
11547 (
11548 "outer-box".to_string(),
11549 FragmentList {
11550 fragments: vec![
11551 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11552 structural_fragment("shadow_styles:outer-box"),
11553 WebUIFragment::route_from(route),
11554 WebUIFragment::raw("</template>"),
11555 ],
11556 contains_boundary: false,
11557 },
11558 ),
11559 (
11560 "dashboard-page".to_string(),
11561 FragmentList {
11562 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11563 contains_boundary: false,
11564 },
11565 ),
11566 ]));
11567 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11568 for tag in ["outer-box", "dashboard-page"] {
11569 protocol
11570 .components
11571 .entry(tag.to_string())
11572 .or_default()
11573 .css_href = format!("/{tag}.css");
11574 }
11575 protocol
11576 .components
11577 .get_mut("outer-box")
11578 .expect("outer-box component")
11579 .uses_shadow_dom = true;
11580 protocol.populate_style_closures(&["outer-box"]);
11581
11582 let mut writer = TestWriter::new();
11583 handle(
11584 &protocol,
11585 &test_json!({}),
11586 &RenderOptions::new("outer-box", "/"),
11587 &mut writer,
11588 )
11589 .unwrap();
11590 let html = writer.get_content();
11591
11592 assert!(
11593 html.starts_with("<template shadowrootmode=\"open\">"),
11594 "isolated component output must not be prefixed with document metadata: {html}"
11595 );
11596 assert!(
11597 !html.contains(r#"<link rel="preload""#),
11598 "an isolated Shadow entry has no Document head for preloads: {html}"
11599 );
11600 assert!(
11601 html.contains(r#"data-webui-resource="dashboard-page""#),
11602 "the route stylesheet must still install inside the ShadowRoot: {html}"
11603 );
11604 }
11605
11606 #[test]
11607 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11608 let route = WebUiFragmentRoute {
11609 path: "/".to_string(),
11610 fragment_id: "app-shell".to_string(),
11611 children: vec![
11612 WebUiFragmentRoute {
11613 path: String::new(),
11614 fragment_id: "dashboard-page".to_string(),
11615 exact: true,
11616 ..Default::default()
11617 },
11618 WebUiFragmentRoute {
11619 path: "details".to_string(),
11620 fragment_id: "detail-page".to_string(),
11621 exact: true,
11622 ..Default::default()
11623 },
11624 ],
11625 ..Default::default()
11626 };
11627 let fragments = HashMap::from([
11628 (
11629 "index.html".to_string(),
11630 FragmentList {
11631 fragments: vec![
11632 WebUIFragment::raw("<html><head>"),
11633 structural_fragment("head_start"),
11634 structural_fragment("head_end"),
11635 WebUIFragment::raw("</head><body>"),
11636 structural_fragment("body_start"),
11637 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11638 WebUIFragment::route_from(route),
11639 WebUIFragment::boundary_end(0),
11640 structural_fragment("body_end"),
11641 WebUIFragment::raw("</body></html>"),
11642 ],
11643 contains_boundary: true,
11644 },
11645 ),
11646 (
11647 "app-shell".to_string(),
11648 FragmentList {
11649 fragments: vec![
11650 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11651 structural_fragment("shadow_styles:app-shell"),
11652 WebUIFragment::raw("<shared-card"),
11653 structural_fragment("streaming_root:shared-card"),
11654 WebUIFragment::raw(">"),
11655 WebUIFragment::component("shared-card"),
11656 WebUIFragment::raw("</shared-card>"),
11657 WebUIFragment::outlet(),
11658 WebUIFragment::raw("</template>"),
11659 ],
11660 contains_boundary: false,
11661 },
11662 ),
11663 (
11664 "dashboard-page".to_string(),
11665 FragmentList {
11666 fragments: vec![
11667 WebUIFragment::raw("<shared-card"),
11668 structural_fragment("streaming_root:shared-card"),
11669 WebUIFragment::raw(">"),
11670 WebUIFragment::component("shared-card"),
11671 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11672 ],
11673 contains_boundary: false,
11674 },
11675 ),
11676 (
11677 "detail-page".to_string(),
11678 FragmentList {
11679 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11680 contains_boundary: false,
11681 },
11682 ),
11683 (
11684 "shared-card".to_string(),
11685 FragmentList {
11686 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11687 contains_boundary: false,
11688 },
11689 ),
11690 ]);
11691 let mut protocol = WebUIProtocol::new(fragments);
11692 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11693 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11694 let component = protocol.components.entry(tag.to_string()).or_default();
11695 component.css = format!(".{tag}{{display:block}}");
11696 component.template_json = r#"{"h":""}"#.to_string();
11697 }
11698 protocol
11699 .components
11700 .get_mut("app-shell")
11701 .expect("app shell component")
11702 .uses_shadow_dom = true;
11703 protocol.populate_style_closures(&["index.html"]);
11704
11705 let mut writer = TestWriter::new();
11706 handle(
11707 &protocol,
11708 &test_json!({}),
11709 &RenderOptions::new("index.html", "/"),
11710 &mut writer,
11711 )
11712 .unwrap();
11713 let html = writer.get_content();
11714
11715 for resource in ["app-shell", "shared-card", "dashboard-page"] {
11716 assert_eq!(
11717 html.matches(&format!(r#"data-webui-resource="{resource}""#))
11718 .count(),
11719 1,
11720 "{resource} must install once in the active ShadowRoot: {html}"
11721 );
11722 }
11723 assert!(
11724 !html.contains(r#"data-webui-resource="detail-page""#),
11725 "inactive route CSS must not enter the ShadowRoot: {html}"
11726 );
11727 let shadow_start = html
11728 .find("<template shadowrootmode=\"open\">")
11729 .expect("Shadow template");
11730 let dashboard_style = html
11731 .find(r#"data-webui-resource="dashboard-page""#)
11732 .expect("dashboard style");
11733 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11734 let shadow_end = html.find("</template>").expect("Shadow template close");
11735 assert!(
11736 shadow_start < dashboard_style
11737 && dashboard_style < dashboard_host
11738 && dashboard_host < shadow_end,
11739 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11740 );
11741
11742 let protocol = Protocol::new(protocol);
11743 let mut streamed = FlushTestWriter::default();
11744 WebUIHandler::new()
11745 .render_streaming(
11746 &protocol,
11747 &test_json!({}),
11748 &RenderOptions::new("index.html", "/"),
11749 &mut streamed,
11750 )
11751 .unwrap();
11752 for resource in ["app-shell", "shared-card"] {
11753 assert_eq!(
11754 streamed
11755 .output
11756 .matches(&format!(r#"data-webui-resource="{resource}""#))
11757 .count(),
11758 1,
11759 "streaming must preserve tree-local delivery for {resource}: {}",
11760 streamed.output
11761 );
11762 }
11763 assert!(
11764 !streamed
11765 .output
11766 .contains(r#"data-webui-resource="dashboard-page""#),
11767 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11768 streamed.output
11769 );
11770 assert!(
11771 streamed
11772 .output
11773 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11774 && streamed
11775 .output
11776 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11777 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11778 streamed.output
11779 );
11780 assert!(
11781 !streamed
11782 .output
11783 .contains(r#"data-webui-resource="detail-page""#),
11784 "streaming must omit inactive route CSS: {}",
11785 streamed.output
11786 );
11787 }
11788
11789 #[test]
11790 fn document_styles_follow_doctype_before_headless_document_content() {
11791 for entry_fragments in [
11792 vec![
11793 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11794 structural_fragment("body_start"),
11795 WebUIFragment::raw("<my-card>"),
11796 WebUIFragment::component("my-card"),
11797 WebUIFragment::raw("</my-card></body></html>"),
11798 ],
11799 vec![
11800 WebUIFragment::raw("<!doctype html><html><my-card>"),
11801 WebUIFragment::component("my-card"),
11802 WebUIFragment::raw("</my-card></html>"),
11803 ],
11804 ] {
11805 let mut protocol = WebUIProtocol::new(HashMap::from([
11806 (
11807 "index.html".to_string(),
11808 FragmentList {
11809 fragments: entry_fragments,
11810 contains_boundary: false,
11811 },
11812 ),
11813 (
11814 "my-card".to_string(),
11815 FragmentList {
11816 fragments: vec![WebUIFragment::raw("<p>card</p>")],
11817 contains_boundary: false,
11818 },
11819 ),
11820 ]));
11821 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11822 let component = protocol
11823 .components
11824 .entry("my-card".to_string())
11825 .or_default();
11826 component.css = ".card{color:red}".to_string();
11827 component.uses_shadow_dom = false;
11828 protocol.populate_style_closures(&["index.html"]);
11829
11830 let mut writer = TestWriter::new();
11831 handle(
11832 &protocol,
11833 &test_json!({}),
11834 &RenderOptions::new("index.html", "/"),
11835 &mut writer,
11836 )
11837 .expect("render");
11838 let html = writer.get_content();
11839
11840 assert!(
11841 html.to_ascii_lowercase().starts_with("<!doctype html>"),
11842 "the doctype must remain the first document token: {html}"
11843 );
11844 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11845 let document = html.find("<html").expect("document root");
11846 let component = html.find("<my-card>").expect("component host");
11847 assert!(
11848 style < document && style < component,
11849 "headless document styles must precede document content: {html}"
11850 );
11851 }
11852 }
11853
11854 #[test]
11855 fn authored_shadow_component_can_render_as_entry_fragment() {
11856 let mut protocol = WebUIProtocol::new(HashMap::from([(
11857 "my-card".to_string(),
11858 FragmentList {
11859 fragments: vec![
11860 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11861 structural_fragment("shadow_styles:my-card"),
11862 WebUIFragment::raw("<p>card</p></template>"),
11863 ],
11864 contains_boundary: false,
11865 },
11866 )]));
11867 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11868 let component = protocol
11869 .components
11870 .entry("my-card".to_string())
11871 .or_default();
11872 component.css = ".card{color:red}".to_string();
11873 component.uses_shadow_dom = true;
11874 protocol.populate_style_closures(&["my-card"]);
11875
11876 let mut writer = TestWriter::new();
11877 handle(
11878 &protocol,
11879 &test_json!({}),
11880 &RenderOptions::new("my-card", "/"),
11881 &mut writer,
11882 )
11883 .expect("render");
11884 let html = writer.get_content();
11885
11886 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11887 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11888 assert!(html.ends_with("<p>card</p></template>"));
11889 }
11890
11891 #[test]
11892 fn component_style_closures_escape_mixed_case_style_end_tags() {
11893 for strategy in [
11894 webui_protocol::CssStrategy::Style,
11895 webui_protocol::CssStrategy::Module,
11896 ] {
11897 let mut protocol = WebUIProtocol::new(HashMap::from([
11898 (
11899 "index.html".to_string(),
11900 FragmentList {
11901 fragments: vec![
11902 WebUIFragment::raw("<html><head>"),
11903 structural_fragment("head_end"),
11904 WebUIFragment::raw("</head><body><safe-card>"),
11905 WebUIFragment::component("safe-card"),
11906 WebUIFragment::raw("</safe-card></body></html>"),
11907 ],
11908 contains_boundary: false,
11909 },
11910 ),
11911 (
11912 "safe-card".to_string(),
11913 FragmentList {
11914 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11915 contains_boundary: false,
11916 },
11917 ),
11918 ]));
11919 protocol.set_css_strategy(strategy);
11920 protocol.components.insert(
11921 "safe-card".to_string(),
11922 webui_protocol::ComponentData {
11923 css: ".safe{content:'</StYlE>'}".to_string(),
11924 ..Default::default()
11925 },
11926 );
11927 protocol.populate_style_closures(&["index.html"]);
11928 let mut writer = TestWriter::new();
11929
11930 handle(
11931 &protocol,
11932 &test_json!({}),
11933 &RenderOptions::new("index.html", "/"),
11934 &mut writer,
11935 )
11936 .unwrap();
11937
11938 let html = writer.get_content();
11939 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11940 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11941 }
11942 }
11943
11944 #[test]
11945 fn shadow_closure_supports_link_and_module_ssr_paths() {
11946 for strategy in [
11947 webui_protocol::CssStrategy::Link,
11948 webui_protocol::CssStrategy::Module,
11949 ] {
11950 let mut protocol = WebUIProtocol::new(HashMap::from([
11951 (
11952 "index.html".to_string(),
11953 FragmentList {
11954 fragments: vec![
11955 WebUIFragment::raw("<html><head>".to_string()),
11956 structural_fragment("head_end"),
11957 WebUIFragment::raw("</head><body><my-card>".to_string()),
11958 WebUIFragment::component("my-card"),
11959 WebUIFragment::raw("</my-card></body></html>".to_string()),
11960 ],
11961 contains_boundary: false,
11962 },
11963 ),
11964 (
11965 "my-card".to_string(),
11966 FragmentList {
11967 fragments: vec![
11968 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11969 structural_fragment("shadow_styles:my-card"),
11970 WebUIFragment::raw("<p>card</p></template>".to_string()),
11971 ],
11972 contains_boundary: false,
11973 },
11974 ),
11975 ]));
11976 protocol.set_css_strategy(strategy);
11977 let component = protocol
11978 .components
11979 .entry("my-card".to_string())
11980 .or_default();
11981 component.css = ".card{color:red}".to_string();
11982 component.css_href = "/my-card.css".to_string();
11983 component.uses_shadow_dom = true;
11984 protocol.populate_style_closures(&["index.html"]);
11985
11986 let mut writer = TestWriter::new();
11987 handle(
11988 &protocol,
11989 &test_json!({}),
11990 &RenderOptions::new("index.html", "/"),
11991 &mut writer,
11992 )
11993 .unwrap();
11994 let html = writer.get_content();
11995 match strategy {
11996 webui_protocol::CssStrategy::Link => {
11997 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11998 assert!(html.contains(&format!(
11999 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
12000 )));
12001 }
12002 webui_protocol::CssStrategy::Module => {
12003 assert!(html.contains(
12004 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
12005 ));
12006 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
12007 }
12008 webui_protocol::CssStrategy::Style => unreachable!(),
12009 }
12010 }
12011 }
12012
12013 #[test]
12014 fn test_module_preloads_emit_in_head_in_compiler_order() {
12015 let mut fragments = HashMap::new();
12019 fragments.insert(
12020 "index.html".to_string(),
12021 FragmentList {
12022 fragments: vec![
12023 WebUIFragment::raw(
12024 r#"<html><head><script type="module" async src="/index.js"></script>"#
12025 .to_string(),
12026 ),
12027 structural_fragment("head_end"),
12028 WebUIFragment::raw("</head><body>".to_string()),
12029 structural_fragment("body_end"),
12030 WebUIFragment::raw("</body></html>".to_string()),
12031 ],
12032 contains_boundary: false,
12033 },
12034 );
12035
12036 let mut protocol = WebUIProtocol::new(fragments);
12037 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
12038
12039 let state = test_json!({});
12040 let mut writer = TestWriter::new();
12041 handle(
12042 &protocol,
12043 &state,
12044 &RenderOptions::new("index.html", "/"),
12045 &mut writer,
12046 )
12047 .unwrap();
12048
12049 let html = writer.get_content();
12050 let head_end = html.find("</head>").expect("</head> missing");
12051 let head = &html[..head_end];
12052 assert!(
12053 head.contains(
12054 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
12055 ),
12056 "hints must appear in <head> in the compiler's order: {html}"
12057 );
12058 }
12059
12060 #[test]
12061 fn test_no_module_preloads_emits_nothing() {
12062 let mut fragments = HashMap::new();
12063 fragments.insert(
12064 "index.html".to_string(),
12065 FragmentList {
12066 fragments: vec![
12067 WebUIFragment::raw("<html><head>".to_string()),
12068 structural_fragment("head_end"),
12069 WebUIFragment::raw("</head><body>".to_string()),
12070 structural_fragment("body_end"),
12071 WebUIFragment::raw("</body></html>".to_string()),
12072 ],
12073 contains_boundary: false,
12074 },
12075 );
12076 let protocol = WebUIProtocol::new(fragments);
12077
12078 let state = test_json!({});
12079 let mut writer = TestWriter::new();
12080 handle(
12081 &protocol,
12082 &state,
12083 &RenderOptions::new("index.html", "/"),
12084 &mut writer,
12085 )
12086 .unwrap();
12087
12088 assert!(
12089 !writer.get_content().contains("modulepreload"),
12090 "a build without hints must be byte-identical to before"
12091 );
12092 }
12093
12094 #[test]
12095 fn test_link_strategy_head_links_follow_document_order() {
12096 let mut fragments = HashMap::new();
12101 fragments.insert(
12102 "index.html".to_string(),
12103 FragmentList {
12104 fragments: vec![
12105 WebUIFragment::raw("<html><head>".to_string()),
12106 structural_fragment("head_end"),
12107 WebUIFragment::raw("</head><body><z-widget>".to_string()),
12108 WebUIFragment::component("z-widget"),
12109 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
12110 WebUIFragment::component("a-widget"),
12111 WebUIFragment::raw("</a-widget>".to_string()),
12112 structural_fragment("body_end"),
12113 WebUIFragment::raw("</body></html>".to_string()),
12114 ],
12115 contains_boundary: false,
12116 },
12117 );
12118 fragments.insert(
12119 "z-widget".to_string(),
12120 FragmentList {
12121 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
12122 contains_boundary: false,
12123 },
12124 );
12125 fragments.insert(
12126 "a-widget".to_string(),
12127 FragmentList {
12128 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
12129 contains_boundary: false,
12130 },
12131 );
12132
12133 let mut protocol = WebUIProtocol::new(fragments);
12134 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12135
12136 let z = protocol
12137 .components
12138 .entry("z-widget".to_string())
12139 .or_default();
12140 z.css_href = "z-widget.css".to_string();
12141 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
12142
12143 let a = protocol
12144 .components
12145 .entry("a-widget".to_string())
12146 .or_default();
12147 a.css_href = "a-widget.css".to_string();
12148 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
12149 protocol.populate_style_closures(&["index.html"]);
12150
12151 let state = test_json!({});
12152 let mut writer = TestWriter::new();
12153
12154 handle(
12155 &protocol,
12156 &state,
12157 &RenderOptions::new("index.html", "/"),
12158 &mut writer,
12159 )
12160 .unwrap();
12161
12162 let html = writer.get_content();
12163 let head_end = html.find("</head>").expect("</head> missing");
12164 let head_section = &html[..head_end];
12165
12166 let z_pos = head_section
12167 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
12168 .expect("z-widget stylesheet link missing from <head>");
12169 let a_pos = head_section
12170 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
12171 .expect("a-widget stylesheet link missing from <head>");
12172
12173 assert!(
12174 z_pos < a_pos,
12175 "CSS <link> tags must follow document order (z-widget before \
12176 a-widget), not alphabetical order: {html}"
12177 );
12178 }
12179
12180 #[test]
12181 fn test_css_module_emitted_in_component_light_dom() {
12182 let mut fragments = HashMap::new();
12186 fragments.insert(
12187 "index.html".to_string(),
12188 FragmentList {
12189 fragments: vec![
12190 WebUIFragment::raw("<html><head>".to_string()),
12191 structural_fragment("head_end"),
12192 WebUIFragment::raw("</head><body><my-card>".to_string()),
12193 WebUIFragment::component("my-card"),
12194 WebUIFragment::raw("</my-card>".to_string()),
12195 WebUIFragment::raw("</body></html>".to_string()),
12196 ],
12197 contains_boundary: false,
12198 },
12199 );
12200 fragments.insert(
12201 "my-card".to_string(),
12202 FragmentList {
12203 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
12204 contains_boundary: false,
12205 },
12206 );
12207
12208 let mut protocol = WebUIProtocol::new(fragments);
12209 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12210 protocol
12211 .components
12212 .entry("my-card".to_string())
12213 .or_default()
12214 .css = "p{color:red}".to_string();
12215 protocol.populate_style_closures(&["index.html"]);
12216 let state = test_json!({});
12217 let mut writer = TestWriter::new();
12218
12219 handle(
12220 &protocol,
12221 &state,
12222 &RenderOptions::new("index.html", "/"),
12223 &mut writer,
12224 )
12225 .unwrap();
12226
12227 let html = writer.get_content();
12228
12229 let tag_open = html.find("<my-card>").expect("<my-card> missing");
12231 let css_pos = html
12232 .find(r#"<script type="importmap""#)
12233 .expect("CSS module importmap missing");
12234 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
12235 assert!(
12236 css_pos > tag_open && css_pos < tag_close,
12237 "CSS module should be inside component light DOM: {html}"
12238 );
12239
12240 let head_end = html.find("</head>").expect("</head> missing");
12242 assert!(
12243 css_pos > head_end,
12244 "CSS module should not be in <head>: {html}"
12245 );
12246 }
12247
12248 #[test]
12249 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
12250 let mut protocol = WebUIProtocol::default();
12251 protocol.fragments = HashMap::from([
12252 (
12253 "index.html".to_string(),
12254 FragmentList {
12255 fragments: vec![
12256 WebUIFragment::raw("<legacy-card>"),
12257 WebUIFragment::component("legacy-card"),
12258 WebUIFragment::raw("</legacy-card>"),
12259 ],
12260 contains_boundary: false,
12261 },
12262 ),
12263 (
12264 "legacy-card".to_string(),
12265 FragmentList {
12266 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
12267 contains_boundary: false,
12268 },
12269 ),
12270 ]);
12271 protocol.components.insert(
12272 "legacy-card".to_string(),
12273 webui_protocol::ComponentData {
12274 css: ".legacy{color:red}".to_string(),
12275 ..Default::default()
12276 },
12277 );
12278 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12279 let mut writer = TestWriter::new();
12280
12281 let error = handle(
12282 &protocol,
12283 &test_json!({}),
12284 &RenderOptions::new("index.html", "/"),
12285 &mut writer,
12286 )
12287 .expect_err("styled protocols require closure metadata");
12288 assert!(error
12289 .to_string()
12290 .contains("component style closure metadata is required"));
12291 }
12292
12293 #[test]
12294 fn test_css_module_emitted_for_route_components() {
12295 let template = r#"<h1>Dashboard</h1>"#;
12297
12298 let mut fragments = HashMap::new();
12299 fragments.insert(
12300 "index.html".to_string(),
12301 FragmentList {
12302 fragments: vec![
12303 WebUIFragment::raw("<html><head>".to_string()),
12304 structural_fragment("head_end"),
12305 WebUIFragment::raw("</head><body>".to_string()),
12306 WebUIFragment::route("/", "dash-page"),
12307 structural_fragment("body_end"),
12308 WebUIFragment::raw("</body></html>".to_string()),
12309 ],
12310 contains_boundary: false,
12311 },
12312 );
12313 fragments.insert(
12314 "dash-page".to_string(),
12315 FragmentList {
12316 fragments: vec![WebUIFragment::raw(template.to_string())],
12317 contains_boundary: false,
12318 },
12319 );
12320
12321 let mut protocol = WebUIProtocol::new(fragments);
12322 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12323 let comp = protocol
12324 .components
12325 .entry("dash-page".to_string())
12326 .or_default();
12327 comp.css = "h1{font-size:2rem}".to_string();
12328 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12329 protocol.populate_style_closures(&["index.html"]);
12330 let state = test_json!({});
12331 let mut writer = TestWriter::new();
12332
12333 handle(
12334 &protocol,
12335 &state,
12336 &RenderOptions::new("index.html", "/"),
12337 &mut writer,
12338 )
12339 .unwrap();
12340
12341 let html = writer.get_content();
12342
12343 assert!(
12344 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
12345 "Route component should have CSS module importmap with data: URI: {html}"
12346 );
12347 assert!(
12348 html.contains("<h1>Dashboard</h1>"),
12349 "Route component should render content: {html}"
12350 );
12351 let fallback = html
12352 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
12353 .expect("route Module fallback");
12354 let host_open = html.find("<dash-page>").expect("route host");
12355 let content = html.find("<h1>Dashboard</h1>").expect("route content");
12356 assert!(
12357 fallback < host_open && host_open < content,
12358 "the Module fallback must precede the generated Light route host: {html}"
12359 );
12360 let importmap = html
12366 .find(r#"<script type="importmap""#)
12367 .expect("route CSS module importmap");
12368 assert!(
12369 host_open < importmap,
12370 "the CSS module importmap must live inside the route host, not as a route child: {html}"
12371 );
12372 }
12373
12374 #[test]
12375 fn test_head_css_link_skipped_for_components_without_css() {
12376 let mut fragments = HashMap::new();
12379 fragments.insert(
12380 "index.html".to_string(),
12381 FragmentList {
12382 fragments: vec![
12383 WebUIFragment::raw("<html><head>".to_string()),
12384 structural_fragment("head_end"),
12385 WebUIFragment::raw("</head><body>".to_string()),
12386 WebUIFragment::component("has-css"),
12387 WebUIFragment::component("no-css"),
12388 structural_fragment("body_end"),
12389 WebUIFragment::raw("</body></html>".to_string()),
12390 ],
12391 contains_boundary: false,
12392 },
12393 );
12394 fragments.insert(
12395 "has-css".to_string(),
12396 FragmentList {
12397 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
12398 contains_boundary: false,
12399 },
12400 );
12401 fragments.insert(
12402 "no-css".to_string(),
12403 FragmentList {
12404 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
12405 contains_boundary: false,
12406 },
12407 );
12408
12409 let mut protocol = WebUIProtocol::new(fragments);
12410 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12411
12412 protocol
12414 .components
12415 .entry("has-css".to_string())
12416 .or_default()
12417 .css_href = "has-css.css".to_string();
12418 protocol
12419 .components
12420 .entry("no-css".to_string())
12421 .or_default()
12422 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
12423 protocol.populate_style_closures(&["index.html"]);
12424
12425 let state = test_json!({});
12426 let mut writer = TestWriter::new();
12427
12428 let handler = WebUIHandler::with_plugin(|| {
12429 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12430 });
12431 handler
12432 .handle(
12433 &protocol,
12434 &state,
12435 &RenderOptions::new("index.html", "/"),
12436 &mut writer,
12437 )
12438 .unwrap();
12439
12440 let html = writer.get_content();
12441 assert!(
12442 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12443 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12444 );
12445 assert!(
12446 !html.contains("no-css.css"),
12447 "Component without CSS must NOT get a <link> in <head>: {html}"
12448 );
12449 }
12450
12451 #[test]
12452 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12453 let mut fragments = HashMap::new();
12461 fragments.insert(
12462 "index.html".to_string(),
12463 FragmentList {
12464 fragments: vec![
12465 WebUIFragment::raw("<html><head>".to_string()),
12466 structural_fragment("head_end"),
12467 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12468 WebUIFragment::component("app-shell"),
12469 WebUIFragment::raw("</app-shell>".to_string()),
12470 structural_fragment("body_end"),
12471 WebUIFragment::raw("</body></html>".to_string()),
12472 ],
12473 contains_boundary: false,
12474 },
12475 );
12476 fragments.insert(
12478 "app-shell".to_string(),
12479 FragmentList {
12480 fragments: vec![
12481 WebUIFragment::raw("<div>Shell</div>".to_string()),
12482 WebUIFragment::component("cart-panel"),
12483 ],
12484 contains_boundary: false,
12485 },
12486 );
12487 fragments.insert(
12489 "cart-panel".to_string(),
12490 FragmentList {
12491 fragments: vec![
12492 WebUIFragment::raw("<aside>".to_string()),
12493 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12494 WebUIFragment::raw("</aside>".to_string()),
12495 ],
12496 contains_boundary: false,
12497 },
12498 );
12499 fragments.insert(
12501 "cart-items".to_string(),
12502 FragmentList {
12503 fragments: vec![WebUIFragment::component("product-card")],
12504 contains_boundary: false,
12505 },
12506 );
12507 fragments.insert(
12508 "product-card".to_string(),
12509 FragmentList {
12510 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12511 contains_boundary: false,
12512 },
12513 );
12514
12515 let mut protocol = WebUIProtocol::new(fragments);
12516 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12517 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12518 for name in ["app-shell", "cart-panel", "product-card"] {
12519 let comp = protocol.components.entry(name.to_string()).or_default();
12520 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12521 comp.css = format!(".{name}{{display:block}}");
12522 if name == "cart-panel" {
12523 comp.hydration_mode = StateProjectionMode::Keys as i32;
12524 comp.hydration_keys = vec!["hasItems".to_string()];
12525 }
12526 if name == "product-card" {
12527 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12528 }
12529 }
12530 protocol.populate_style_closures(&["index.html"]);
12531
12532 let state = test_json!({ "hasItems": false });
12534 let mut writer = TestWriter::new();
12535
12536 let handler = WebUIHandler::with_plugin(|| {
12537 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12538 });
12539 handler
12540 .handle(
12541 &protocol,
12542 &state,
12543 &RenderOptions::new("index.html", "/"),
12544 &mut writer,
12545 )
12546 .unwrap();
12547
12548 let html = writer.get_content();
12549
12550 assert!(
12551 html.contains(r#"<script type="application/json" id="webui-data">"#),
12552 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12553 );
12554 assert!(
12555 html.contains(r#""state":{"hasItems":false}"#),
12556 "SSR state should live in the JSON data block: {html}"
12557 );
12558 assert!(
12559 html.contains(r#""inventory":"#),
12560 "SSR inventory should live in the JSON data block: {html}"
12561 );
12562 assert!(
12563 !html.contains("window.__webui={\""),
12564 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12565 );
12566 assert!(
12567 !html.contains(r#"document.getElementById("webui-data")"#),
12568 "SSR must not parse webui-data; client packages own that lazy load: {html}"
12569 );
12570 assert!(
12571 !html.contains("window.__webui=w;"),
12572 "executable bootstrap must not replace existing window.__webui registrations: {html}"
12573 );
12574 assert!(
12575 !html.contains("w.templateFns={\""),
12576 "template function emission must not replace existing templateFns registrations: {html}"
12577 );
12578 assert!(
12579 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12580 "template functions should merge into the flat templateFns registry: {html}"
12581 );
12582
12583 assert!(
12586 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12587 "product-card template should be emitted even when unrendered: {html}"
12588 );
12589
12590 assert!(
12593 html.contains(r#""product-card":"data:text/css,"#),
12594 "reachable product-card CSS module importmap should be emitted: {html}"
12595 );
12596
12597 assert!(
12599 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12600 "rendered app-shell template should be emitted: {html}"
12601 );
12602 assert!(
12603 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12604 "rendered cart-panel template should be emitted: {html}"
12605 );
12606 }
12607
12608 #[test]
12609 fn component_asset_manifest_falls_back_to_body_start() {
12610 let fragments = HashMap::from([(
12611 "index.html".to_string(),
12612 FragmentList {
12613 fragments: vec![
12614 WebUIFragment::raw("<body>".to_string()),
12615 structural_fragment("body_start"),
12616 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12617 ],
12618 contains_boundary: false,
12619 },
12620 )]);
12621 let mut protocol = WebUIProtocol::new(fragments);
12622 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12623 root: "lazy-panel".to_string(),
12624 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12625 }];
12626 let mut writer = TestWriter::new();
12627
12628 handle(
12629 &protocol,
12630 &Value::Null,
12631 &RenderOptions::new("index.html", "/"),
12632 &mut writer,
12633 )
12634 .unwrap();
12635
12636 assert_eq!(
12637 writer.get_content(),
12638 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12639 );
12640 }
12641
12642 #[test]
12643 fn component_asset_styles_emit_once_when_body_precedes_head() {
12644 let fragments = HashMap::from([(
12645 "index.html".to_string(),
12646 FragmentList {
12647 fragments: vec![
12648 WebUIFragment::raw("<body>".to_string()),
12649 structural_fragment("body_start"),
12650 WebUIFragment::raw("</body><head>".to_string()),
12651 structural_fragment("head_end"),
12652 WebUIFragment::raw("</head>".to_string()),
12653 ],
12654 contains_boundary: false,
12655 },
12656 )]);
12657 let mut protocol = WebUIProtocol::new(fragments);
12658 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12659 root: "lazy-panel".to_string(),
12660 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12661 }];
12662 let mut writer = TestWriter::new();
12663
12664 handle(
12665 &protocol,
12666 &Value::Null,
12667 &RenderOptions::new("index.html", "/"),
12668 &mut writer,
12669 )
12670 .unwrap();
12671
12672 assert_eq!(
12673 writer
12674 .get_content()
12675 .matches(r#"id="webui-component-assets""#)
12676 .count(),
12677 1
12678 );
12679 }
12680
12681 #[test]
12682 fn light_component_asset_styles_apply_at_head_end() {
12683 let fragments = HashMap::from([(
12684 "index.html".to_string(),
12685 FragmentList {
12686 fragments: vec![
12687 WebUIFragment::raw("<html><head>".to_string()),
12688 structural_fragment("head_end"),
12689 WebUIFragment::raw("</head><body></body></html>".to_string()),
12690 ],
12691 contains_boundary: false,
12692 },
12693 )]);
12694 let mut protocol = WebUIProtocol::new(fragments);
12695 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12696 for root in ["lazy-panel", "secondary-panel"] {
12697 protocol.components.insert(
12698 root.to_string(),
12699 webui_protocol::ComponentData {
12700 uses_shadow_dom: false,
12701 ..Default::default()
12702 },
12703 );
12704 }
12705 protocol.component_asset_style_preloads = vec![
12706 ComponentAssetStylePreload {
12707 root: "lazy-panel".to_string(),
12708 style_hrefs: vec![
12709 "/assets/lazy-panel.css".to_string(),
12710 "/assets/shared.css".to_string(),
12711 ],
12712 },
12713 ComponentAssetStylePreload {
12714 root: "secondary-panel".to_string(),
12715 style_hrefs: vec![
12716 "/assets/secondary-panel.css".to_string(),
12717 "/assets/shared.css".to_string(),
12718 ],
12719 },
12720 ];
12721 let mut writer = TestWriter::new();
12722
12723 handle(
12724 &protocol,
12725 &Value::Null,
12726 &RenderOptions::new("index.html", "/"),
12727 &mut writer,
12728 )
12729 .unwrap();
12730
12731 let html = writer.get_content();
12732 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12733 assert_eq!(html.matches("/assets/shared.css").count(), 1);
12734 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12735 let links_end = html
12736 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12737 .expect("asset stylesheet must be emitted");
12738 let head_end = html.find("</head>").expect("head must close");
12739 assert!(links_end < head_end);
12740 }
12741
12742 #[test]
12752 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12753 let template = r#"<h1>Dashboard</h1>"#;
12755
12756 let mut fragments = HashMap::new();
12757 fragments.insert(
12758 "index.html".to_string(),
12759 FragmentList {
12760 fragments: vec![
12761 WebUIFragment::raw("<html><head>".to_string()),
12762 structural_fragment("head_end"),
12763 WebUIFragment::raw("</head><body>".to_string()),
12764 WebUIFragment::route("/", "dash-page"),
12765 structural_fragment("body_end"),
12766 WebUIFragment::raw("</body></html>".to_string()),
12767 ],
12768 contains_boundary: false,
12769 },
12770 );
12771 fragments.insert(
12772 "dash-page".to_string(),
12773 FragmentList {
12774 fragments: vec![WebUIFragment::raw(template.to_string())],
12775 contains_boundary: false,
12776 },
12777 );
12778
12779 let mut protocol = WebUIProtocol::new(fragments);
12780 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12781 let comp = protocol
12782 .components
12783 .entry("dash-page".to_string())
12784 .or_default();
12785 comp.css = "h1{font-size:2rem}".to_string();
12786 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12787 protocol.populate_style_closures(&["index.html"]);
12788 let state = test_json!({});
12789 let mut writer = TestWriter::new();
12790
12791 handle(
12792 &protocol,
12793 &state,
12794 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12795 &mut writer,
12796 )
12797 .unwrap();
12798
12799 let html = writer.get_content();
12800
12801 assert!(
12802 html.contains(
12803 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12804 ),
12805 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12806 );
12807 }
12808
12809 #[test]
12810 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12811 let mut fragments = HashMap::new();
12816 fragments.insert(
12817 "index.html".to_string(),
12818 FragmentList {
12819 fragments: vec![
12820 WebUIFragment::raw("<html><head>".to_string()),
12821 structural_fragment("head_end"),
12822 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12823 WebUIFragment::component("app-shell"),
12824 WebUIFragment::raw("</app-shell>".to_string()),
12825 structural_fragment("body_end"),
12826 WebUIFragment::raw("</body></html>".to_string()),
12827 ],
12828 contains_boundary: false,
12829 },
12830 );
12831 fragments.insert(
12832 "app-shell".to_string(),
12833 FragmentList {
12834 fragments: vec![WebUIFragment::if_cond(
12835 ConditionExpr::identifier("hasItems"),
12836 "cart-items",
12837 )],
12838 contains_boundary: false,
12839 },
12840 );
12841 fragments.insert(
12842 "cart-items".to_string(),
12843 FragmentList {
12844 fragments: vec![WebUIFragment::component("product-card")],
12845 contains_boundary: false,
12846 },
12847 );
12848 fragments.insert(
12849 "product-card".to_string(),
12850 FragmentList {
12851 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12852 contains_boundary: false,
12853 },
12854 );
12855
12856 let mut protocol = WebUIProtocol::new(fragments);
12857 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12858 for name in ["app-shell", "product-card"] {
12859 let comp = protocol.components.entry(name.to_string()).or_default();
12860 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12861 comp.css = format!(".{name}{{display:block}}");
12862 }
12863 protocol.populate_style_closures(&["index.html"]);
12864
12865 let state = test_json!({ "hasItems": false });
12868 let mut writer = TestWriter::new();
12869
12870 let handler = WebUIHandler::with_plugin(|| {
12871 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12872 });
12873 handler
12874 .handle(
12875 &protocol,
12876 &state,
12877 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12878 &mut writer,
12879 )
12880 .unwrap();
12881
12882 let html = writer.get_content();
12883
12884 assert!(
12885 html.contains(
12886 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12887 ),
12888 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12889 );
12890 }
12891
12892 #[test]
12893 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12894 let mut fragments = HashMap::new();
12895 fragments.insert(
12896 "index.html".to_string(),
12897 FragmentList {
12898 fragments: vec![
12899 WebUIFragment::raw("<html><body><style>".to_string()),
12900 WebUIFragment::signal("tokens.light", true),
12901 WebUIFragment::raw("</style>".to_string()),
12902 structural_fragment("body_end"),
12903 WebUIFragment::raw("</body></html>".to_string()),
12904 ],
12905 contains_boundary: false,
12906 },
12907 );
12908 fragments.insert(
12909 "app-shell".to_string(),
12910 FragmentList {
12911 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12912 contains_boundary: false,
12913 },
12914 );
12915 let index_fragments = fragments
12916 .get_mut("index.html")
12917 .expect("index fixture should exist");
12918 index_fragments
12919 .fragments
12920 .insert(1, WebUIFragment::component("app-shell"));
12921 let mut protocol = WebUIProtocol::new(fragments);
12922 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12923 protocol.components.insert(
12928 "app-shell".to_string(),
12929 webui_protocol::ComponentData {
12930 hydration_mode: StateProjectionMode::Keys as i32,
12931 hydration_keys: vec!["name".to_string()],
12932 ..Default::default()
12933 },
12934 );
12935 let state = test_json!({
12936 "name": "Alice",
12937 "tokens": {
12938 "light": "--color-brand: red;"
12939 }
12940 });
12941 let mut writer = TestWriter::new();
12942 let handler = WebUIHandler::with_plugin(|| {
12943 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12944 });
12945 handler.handle(
12946 &protocol,
12947 &state,
12948 &RenderOptions::new("index.html", "/"),
12949 &mut writer,
12950 )?;
12951 let output = writer.get_content();
12952
12953 assert!(output.contains("--color-brand: red;"));
12955 assert!(output.contains(r#""name":"Alice""#));
12957 assert!(!output.contains(r#""tokens""#));
12958 Ok(())
12959 }
12960
12961 #[test]
12962 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12963 let mut fragments = HashMap::new();
12964 fragments.insert(
12965 "index.html".to_string(),
12966 FragmentList {
12967 fragments: vec![
12968 WebUIFragment::raw("<html><body>"),
12969 structural_fragment("body_end"),
12970 WebUIFragment::raw("</body></html>"),
12971 ],
12972 contains_boundary: false,
12973 },
12974 );
12975 let protocol = WebUIProtocol::new(fragments);
12976 let state = test_json!({
12977 "client": "visible",
12978 "serverOnly": "also preserved",
12979 });
12980 let handler = WebUIHandler::with_plugin(|| {
12981 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12982 });
12983 let mut writer = TestWriter::new();
12984 handler.handle(
12985 &protocol,
12986 &state,
12987 &RenderOptions::new("index.html", "/"),
12988 &mut writer,
12989 )?;
12990 let output = writer.get_content();
12991 assert!(output.contains(r#""client":"visible""#));
12992 assert!(output.contains(r#""serverOnly":"also preserved""#));
12993 Ok(())
12994 }
12995
12996 #[test]
12997 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12998 let mut fragments = HashMap::new();
12999 fragments.insert(
13000 "index.html".to_string(),
13001 FragmentList {
13002 fragments: vec![
13003 WebUIFragment::raw("<html><body>"),
13004 WebUIFragment::component("app-shell"),
13005 structural_fragment("body_end"),
13006 WebUIFragment::raw("</body></html>"),
13007 ],
13008 contains_boundary: false,
13009 },
13010 );
13011 fragments.insert(
13012 "app-shell".to_string(),
13013 FragmentList {
13014 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13015 contains_boundary: false,
13016 },
13017 );
13018 let mut protocol = WebUIProtocol::new(fragments);
13019 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13020 protocol.components.insert(
13021 "app-shell".to_string(),
13022 webui_protocol::ComponentData {
13023 hydration_mode: StateProjectionMode::All as i32,
13024 ..Default::default()
13025 },
13026 );
13027 let state = test_json!({
13028 "known": "value",
13029 "possiblyInherited": "must not be dropped",
13030 });
13031 let handler = WebUIHandler::with_plugin(|| {
13032 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13033 });
13034 let mut writer = TestWriter::new();
13035 handler.handle(
13036 &protocol,
13037 &state,
13038 &RenderOptions::new("index.html", "/"),
13039 &mut writer,
13040 )?;
13041 let output = writer.get_content();
13042 assert!(output.contains(r#""known":"value""#));
13043 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
13044 Ok(())
13045 }
13046
13047 #[test]
13048 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
13049 let mut fragments = HashMap::new();
13050 fragments.insert(
13051 "index.html".to_string(),
13052 FragmentList {
13053 fragments: vec![
13054 WebUIFragment::raw("<html><body>"),
13055 WebUIFragment::component("app-shell"),
13056 structural_fragment("body_end"),
13057 WebUIFragment::raw("</body></html>"),
13058 ],
13059 contains_boundary: false,
13060 },
13061 );
13062 fragments.insert(
13063 "app-shell".to_string(),
13064 FragmentList {
13065 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13066 contains_boundary: false,
13067 },
13068 );
13069 let mut protocol = WebUIProtocol::new(fragments);
13070 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13071 let state = test_json!({
13072 "known": "value",
13073 "serverOnly": "must not be dropped",
13074 });
13075 let handler = WebUIHandler::with_plugin(|| {
13076 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13077 });
13078 let mut writer = TestWriter::new();
13079 handler.handle(
13080 &protocol,
13081 &state,
13082 &RenderOptions::new("index.html", "/"),
13083 &mut writer,
13084 )?;
13085 let output = writer.get_content();
13086 assert!(output.contains(r#""known":"value""#));
13087 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
13088 Ok(())
13089 }
13090
13091 #[test]
13092 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
13093 let mut fragments = HashMap::new();
13094 fragments.insert(
13095 "index.html".to_string(),
13096 FragmentList {
13097 fragments: vec![
13098 WebUIFragment::raw("<html><body>"),
13099 WebUIFragment::component("app-shell"),
13100 structural_fragment("body_end"),
13101 WebUIFragment::raw("</body></html>"),
13102 ],
13103 contains_boundary: false,
13104 },
13105 );
13106 fragments.insert(
13107 "app-shell".to_string(),
13108 FragmentList {
13109 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13110 contains_boundary: false,
13111 },
13112 );
13113 let mut protocol = WebUIProtocol::new(fragments);
13114 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13115 protocol.components.insert(
13116 "app-shell".to_string(),
13117 webui_protocol::ComponentData {
13118 hydration_mode: i32::MAX,
13119 ..Default::default()
13120 },
13121 );
13122 let state = test_json!({
13123 "known": "value",
13124 "serverOnly": "must not be dropped",
13125 });
13126 let handler = WebUIHandler::with_plugin(|| {
13127 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13128 });
13129 let mut writer = TestWriter::new();
13130 handler.handle(
13131 &protocol,
13132 &state,
13133 &RenderOptions::new("index.html", "/"),
13134 &mut writer,
13135 )?;
13136 let output = writer.get_content();
13137 assert!(output.contains(r#""known":"value""#));
13138 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
13139 Ok(())
13140 }
13141
13142 #[test]
13143 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
13144 let mut protocol = WebUIProtocol::new(HashMap::new());
13145 protocol.components.insert(
13146 "app-shell".to_string(),
13147 webui_protocol::ComponentData {
13148 navigation_keys: vec!["selected".to_string()],
13149 ..Default::default()
13150 },
13151 );
13152
13153 match collect_navigation_state(&protocol, ["app-shell"]) {
13154 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
13155 StateSelection::Full
13156 | StateSelection::KeyIds(_)
13157 | StateSelection::FullExceptKeyIds(_) => {
13158 panic!("legacy navigation keys should remain owned and projected")
13159 }
13160 }
13161 }
13162
13163 #[test]
13164 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
13165 let mut protocol = WebUIProtocol::new(HashMap::new());
13166 protocol.components.insert(
13167 "app-shell".to_string(),
13168 webui_protocol::ComponentData::default(),
13169 );
13170
13171 assert!(matches!(
13172 collect_navigation_state(&protocol, ["app-shell"]),
13173 StateSelection::Full
13174 ));
13175 }
13176
13177 #[test]
13178 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
13179 let mut fragments = HashMap::new();
13180 fragments.insert(
13181 "index.html".to_string(),
13182 FragmentList {
13183 fragments: vec![
13184 WebUIFragment::raw("<html><body>".to_string()),
13185 structural_fragment("body_end"),
13186 WebUIFragment::raw("</body></html>".to_string()),
13187 ],
13188 contains_boundary: false,
13189 },
13190 );
13191 let mut protocol = WebUIProtocol::new(fragments);
13192 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13193 let state = test_json!({
13194 "title": "Legacy state",
13195 "serverOnly": "preserved",
13196 });
13197 let handler = WebUIHandler::with_plugin(|| {
13198 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13199 });
13200
13201 let mut writer = TestWriter::new();
13202 handler.handle(
13203 &protocol,
13204 &state,
13205 &RenderOptions::new("index.html", "/"),
13206 &mut writer,
13207 )?;
13208 assert!(writer.get_content().contains(r#""state":{}"#));
13209 assert!(!writer.get_content().contains("Legacy state"));
13210 assert!(!writer.get_content().contains("preserved"));
13211 Ok(())
13212 }
13213
13214 #[test]
13215 fn scriptless_component_state_is_navigation_only() -> Result<()> {
13216 let mut fragments = HashMap::new();
13217 fragments.insert(
13218 "index.html".to_string(),
13219 FragmentList {
13220 fragments: vec![
13221 WebUIFragment::raw("<html><body>"),
13222 WebUIFragment::component("items-page"),
13223 structural_fragment("body_end"),
13224 WebUIFragment::raw("</body></html>"),
13225 ],
13226 contains_boundary: false,
13227 },
13228 );
13229 fragments.insert(
13230 "items-page".to_string(),
13231 FragmentList {
13232 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
13233 contains_boundary: false,
13234 },
13235 );
13236 let mut protocol = WebUIProtocol::new(fragments);
13237 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13238 protocol.components.insert(
13239 "items-page".to_string(),
13240 webui_protocol::ComponentData {
13241 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
13242 navigation_mode: Some(StateProjectionMode::Keys as i32),
13243 navigation_keys: vec!["items".into()],
13244 ..Default::default()
13245 },
13246 );
13247 let state = test_json!({
13248 "items": ["STATE_SENTINEL"],
13249 "serverOnly": "SECRET_SENTINEL",
13250 });
13251 let handler = WebUIHandler::with_plugin(|| {
13252 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13253 });
13254 let mut writer = TestWriter::new();
13255
13256 handler.handle(
13257 &protocol,
13258 &state,
13259 &RenderOptions::new("index.html", "/"),
13260 &mut writer,
13261 )?;
13262
13263 let output = writer.get_content();
13264 assert!(output.contains(r#""state":{}"#));
13265 assert!(!output.contains("STATE_SENTINEL"));
13266 assert!(!output.contains("SECRET_SENTINEL"));
13267 Ok(())
13268 }
13269
13270 #[test]
13271 fn write_selected_state_projects_and_escapes() {
13272 let state = test_json!({
13275 "drop": "secret",
13276 "keep": "</script><b>"
13277 });
13278 let keys = ["keep"];
13279 let selection = StateSelection::Keys(keys.to_vec());
13280 let mut sink = TestWriter::new();
13281 let mut scratch = Vec::new();
13282 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13283 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
13284 }
13285
13286 #[test]
13287 fn write_selected_state_non_object_projection_emits_empty_object() {
13288 let state = test_json!("scalar state has nothing hydratable");
13289 let keys: [&str; 0] = [];
13290 let selection = StateSelection::Keys(keys.to_vec());
13291 let mut sink = TestWriter::new();
13292 let mut scratch = Vec::new();
13293 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13294 assert_eq!(sink.get_content(), "{}");
13295 }
13296
13297 #[test]
13298 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
13299 let state = test_json!({
13300 "keptA": 1,
13301 "keptB": 2,
13302 "serverOnlyA": 3,
13303 "serverOnlyB": 4,
13304 });
13305 let keys = ["keptA", "keptA", "keptB", "missing"];
13306 let selection = StateSelection::Keys(keys.to_vec());
13307 let mut sink = TestWriter::new();
13308 let mut scratch = Vec::new();
13309 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13310 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13311 }
13312
13313 #[test]
13314 fn write_selected_state_map_first_matches_schema_first_output() {
13315 let state = test_json!({
13316 "keptA": 1,
13317 "keptB": 2,
13318 });
13319 let keys = ["keptA", "keptB", "missingA", "missingB"];
13320 let selection = StateSelection::Keys(keys.to_vec());
13321 let mut sink = TestWriter::new();
13322 let mut scratch = Vec::new();
13323 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13324 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13325 }
13326
13327 #[test]
13328 fn write_selected_state_full_preserves_and_escapes_state() {
13329 let state = test_json!({
13330 "serverOnly": "</script><b>",
13331 "value": 42,
13332 });
13333 let mut sink = TestWriter::new();
13334 let mut scratch = Vec::new();
13335 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13336 assert_eq!(
13337 sink.get_content(),
13338 r#"{"serverOnly":"<\/script><b>","value":42}"#
13339 );
13340 }
13341
13342 #[test]
13343 fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
13344 let cases = [
13345 ("", ""),
13346 ("</", "<\\/"),
13347 ("</a</b", "<\\/a<\\/b"),
13348 ("<<//", "<<\\//"),
13349 ("a<b>c<", "a<b>c<"),
13350 ("x</", "x<\\/"),
13351 ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
13352 ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
13353 ];
13354 for (json, expected) in cases {
13355 let mut sink = TestWriter::new();
13356 write_script_safe_json_str(&mut sink, json)?;
13357 assert_eq!(sink.get_content(), expected, "input {json:?}");
13358 }
13359 Ok(())
13360 }
13361
13362 #[test]
13363 fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
13364 let mut json = String::with_capacity(512);
13367 for gap in 0..130 {
13368 json.clear();
13369 json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
13370 json.push_str(&"x".repeat(gap));
13371 json.push_str("</p><br/>");
13372 json.push_str(&"y".repeat(gap % 37));
13373 json.push_str("</");
13374 let mut sink = TestWriter::new();
13375 write_script_safe_json_str(&mut sink, &json)?;
13376 assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
13377 }
13378 Ok(())
13379 }
13380
13381 #[test]
13382 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
13383 let preloads = vec![ComponentAssetStylePreload {
13384 root: "lazy-panel".to_string(),
13385 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
13386 }];
13387
13388 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
13389
13390 assert_eq!(
13391 manifest,
13392 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
13393 );
13394 }
13395
13396 #[test]
13397 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
13398 let mut sink = TestWriter::new();
13404 let mut scratch: Vec<u8> = Vec::new();
13405 assert_eq!(scratch.capacity(), 0);
13407
13408 let mut large = serde_json::Map::new();
13410 for index in 0..64 {
13411 large.insert(
13412 format!("component_{index:04}"),
13413 Value::String("x".repeat(48)),
13414 );
13415 }
13416 let large = Value::Object(large);
13417 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
13418 let high_water = scratch.capacity();
13419 assert!(high_water > 0);
13420
13421 for seq in 0..8 {
13424 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13425 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13426 assert_eq!(
13427 scratch.capacity(),
13428 high_water,
13429 "scratch reallocated on checkpoint {seq}"
13430 );
13431 }
13432 }
13433
13434 #[test]
13435 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13436 let mut fragments = HashMap::new();
13437 fragments.insert(
13438 "index.html".to_string(),
13439 FragmentList {
13440 fragments: vec![
13441 WebUIFragment::raw("<html><body>"),
13442 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13443 path: "/".to_string(),
13444 fragment_id: "home-page".to_string(),
13445 exact: true,
13446 ..Default::default()
13447 }),
13448 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13449 path: "/admin".to_string(),
13450 fragment_id: "admin-page".to_string(),
13451 exact: true,
13452 ..Default::default()
13453 }),
13454 structural_fragment("body_end"),
13455 WebUIFragment::raw("</body></html>"),
13456 ],
13457
13458 contains_boundary: false,
13459 },
13460 );
13461 fragments.insert(
13462 "home-page".to_string(),
13463 FragmentList {
13464 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13465 contains_boundary: false,
13466 },
13467 );
13468 fragments.insert(
13469 "admin-page".to_string(),
13470 FragmentList {
13471 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13472 contains_boundary: false,
13473 },
13474 );
13475
13476 let mut protocol = WebUIProtocol::new(fragments);
13477 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13478 protocol.components.insert(
13479 "home-page".to_string(),
13480 webui_protocol::ComponentData {
13481 template_json: "{}".to_string(),
13482 hydration_mode: StateProjectionMode::Keys as i32,
13483 hydration_keys: vec!["homeTitle".to_string()],
13484 ..Default::default()
13485 },
13486 );
13487 protocol.components.insert(
13488 "admin-page".to_string(),
13489 webui_protocol::ComponentData {
13490 template_json: "{}".to_string(),
13491 hydration_mode: StateProjectionMode::Keys as i32,
13492 hydration_keys: vec!["adminToken".to_string()],
13493 ..Default::default()
13494 },
13495 );
13496 let state = test_json!({
13497 "homeTitle": "Welcome",
13498 "adminToken": "TOP_SECRET_SENTINEL",
13499 });
13500 let handler = WebUIHandler::with_plugin(|| {
13501 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13502 });
13503 let mut writer = TestWriter::new();
13504 handler.handle(
13505 &protocol,
13506 &state,
13507 &RenderOptions::new("index.html", "/"),
13508 &mut writer,
13509 )?;
13510 let output = writer.get_content();
13511 assert!(output.contains(r#""homeTitle":"Welcome""#));
13512 assert!(!output.contains("TOP_SECRET_SENTINEL"));
13513 assert!(!output.contains(r#""adminToken""#));
13514 Ok(())
13515 }
13516
13517 #[test]
13518 fn test_component_attr_name_aria() {
13519 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13521 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13522 assert_eq!(
13523 component_attr_name("aria-activedescendant"),
13524 "ariaActiveDescendant"
13525 );
13526 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13527 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13528 }
13529
13530 #[test]
13531 fn test_component_attr_name_html_global() {
13532 assert_eq!(component_attr_name("readonly"), "readOnly");
13533 assert_eq!(component_attr_name("tabindex"), "tabIndex");
13534 assert_eq!(component_attr_name("accesskey"), "accessKey");
13535 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13536 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13537 assert_eq!(component_attr_name("inputmode"), "inputMode");
13538 assert_eq!(component_attr_name("maxlength"), "maxLength");
13539 assert_eq!(component_attr_name("minlength"), "minLength");
13540 assert_eq!(component_attr_name("novalidate"), "noValidate");
13541 assert_eq!(component_attr_name("formaction"), "formAction");
13542 assert_eq!(component_attr_name("ismap"), "isMap");
13543 assert_eq!(component_attr_name("usemap"), "useMap");
13544 }
13545
13546 #[test]
13547 fn test_component_attr_name_strips_colon() {
13548 assert_eq!(component_attr_name(":readonly"), "readOnly");
13549 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13550 assert_eq!(component_attr_name(":data-title"), "dataTitle");
13551 }
13552
13553 #[test]
13554 fn test_component_attr_name_regular() {
13555 assert_eq!(component_attr_name("data-title"), "dataTitle");
13556 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13557 assert_eq!(component_attr_name("simple"), "simple");
13558 }
13559
13560 fn make_query_route_protocol() -> WebUIProtocol {
13563 use webui_protocol::WebUiFragmentRoute;
13564
13565 let mut fragments = HashMap::new();
13566
13567 fragments.insert(
13568 "index.html".to_string(),
13569 FragmentList {
13570 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13571 path: "/".into(),
13572 fragment_id: "app-shell".into(),
13573 exact: false,
13574 children: vec![
13575 WebUiFragmentRoute {
13576 path: "compose".into(),
13577 fragment_id: "compose-page".into(),
13578 exact: true,
13579 allowed_query: "action,to,subject".into(),
13580 keep_alive: false,
13581 ..Default::default()
13582 },
13583 WebUiFragmentRoute {
13584 path: "settings".into(),
13585 fragment_id: "settings-page".into(),
13586 exact: true,
13587 keep_alive: false,
13588 ..Default::default()
13589 },
13590 ],
13591 keep_alive: false,
13592 ..Default::default()
13593 })],
13594
13595 contains_boundary: false,
13596 },
13597 );
13598
13599 fragments.insert(
13600 "app-shell".to_string(),
13601 FragmentList {
13602 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13603 contains_boundary: false,
13604 },
13605 );
13606 fragments.insert(
13607 "compose-page".to_string(),
13608 FragmentList {
13609 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13610 contains_boundary: false,
13611 },
13612 );
13613 fragments.insert(
13614 "settings-page".to_string(),
13615 FragmentList {
13616 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13617 contains_boundary: false,
13618 },
13619 );
13620
13621 WebUIProtocol::new(fragments)
13622 }
13623
13624 #[test]
13625 fn test_matched_route_omits_query_attr_from_dom() {
13626 let protocol = make_query_route_protocol();
13627 let state = test_json!({});
13628 let handler = WebUIHandler::new();
13629 let mut writer = TestWriter::new();
13630
13631 handler
13632 .handle(
13633 &protocol,
13634 &state,
13635 &RenderOptions::new("index.html", "/compose"),
13636 &mut writer,
13637 )
13638 .expect("render failed");
13639
13640 let html = writer.get_content();
13641 assert!(
13643 !html.contains(r#"query="action,to,subject""#),
13644 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13645 );
13646 }
13647
13648 #[test]
13649 fn test_nonmatched_route_omits_query_attr_from_dom() {
13650 let protocol = make_query_route_protocol();
13651 let state = test_json!({});
13652 let handler = WebUIHandler::new();
13653 let mut writer = TestWriter::new();
13654
13655 handler
13656 .handle(
13657 &protocol,
13658 &state,
13659 &RenderOptions::new("index.html", "/settings"),
13660 &mut writer,
13661 )
13662 .expect("render failed");
13663
13664 let html = writer.get_content();
13665 assert!(
13667 !html.contains(r#"query="#),
13668 "hidden route should not have query attr: {html}"
13669 );
13670 }
13671
13672 #[test]
13673 fn test_route_without_query_has_no_query_attr() {
13674 let protocol = make_query_route_protocol();
13675 let state = test_json!({});
13676 let handler = WebUIHandler::new();
13677 let mut writer = TestWriter::new();
13678
13679 handler
13680 .handle(
13681 &protocol,
13682 &state,
13683 &RenderOptions::new("index.html", "/settings"),
13684 &mut writer,
13685 )
13686 .expect("render failed");
13687
13688 let html = writer.get_content();
13689 let settings_idx = html
13691 .find(r#"component="settings-page""#)
13692 .expect("settings route should exist");
13693 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13694 assert!(
13695 !settings_tag.contains("query="),
13696 "route without allowed_query should not emit query attr: {settings_tag}"
13697 );
13698 }
13699
13700 fn build_head_body_protocol() -> WebUIProtocol {
13705 let mut fragments = HashMap::new();
13706 fragments.insert(
13707 "index.html".to_string(),
13708 FragmentList {
13709 fragments: vec![
13710 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13711 structural_fragment("head_end"),
13712 WebUIFragment::raw("</head><body>hello".to_string()),
13713 structural_fragment("body_end"),
13714 WebUIFragment::raw("</body></html>".to_string()),
13715 ],
13716 contains_boundary: false,
13717 },
13718 );
13719 WebUIProtocol::new(fragments)
13720 }
13721
13722 #[test]
13723 fn head_inject_emits_at_head_end_boundary() {
13724 let protocol = build_head_body_protocol();
13725 let state = test_json!({});
13726 let mut writer = TestWriter::new();
13727 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13728 handle(&protocol, &state, &opts, &mut writer).unwrap();
13729 let html = writer.get_content();
13730 let inject_idx = html
13732 .find("<link rel=preload>")
13733 .expect("inject HTML missing");
13734 let head_close_idx = html.find("</head>").expect("</head> missing");
13735 assert!(
13736 inject_idx < head_close_idx,
13737 "head_inject must appear before </head>: {html}"
13738 );
13739 assert_eq!(html.matches("<link rel=preload>").count(), 1);
13741 }
13742
13743 #[test]
13744 fn component_render_policy_css_emits_once_in_head_with_nonce() {
13745 let mut protocol = build_head_body_protocol();
13746 protocol.component_render_css = concat!(
13747 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13748 "contain-intrinsic-block-size:auto 18rem;}"
13749 )
13750 .to_string();
13751 let state = test_json!({});
13752 let mut writer = TestWriter::new();
13753 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13754 handle(&protocol, &state, &opts, &mut writer).unwrap();
13755 let html = writer.get_content();
13756
13757 let style = concat!(
13758 r#"<style data-webui-render-policy nonce="test-nonce">"#,
13759 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13760 "contain-intrinsic-block-size:auto 18rem;}</style>"
13761 );
13762 let style_index = html.find(style).expect("render policy style missing");
13763 let head_close = html.find("</head>").expect("</head> missing");
13764 assert!(style_index < head_close);
13765 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13766 }
13767
13768 #[test]
13769 fn body_inject_emits_at_body_end_boundary() {
13770 let protocol = build_head_body_protocol();
13771 let state = test_json!({});
13772 let mut writer = TestWriter::new();
13773 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13774 handle(&protocol, &state, &opts, &mut writer).unwrap();
13775 let html = writer.get_content();
13776 let inject_idx = html
13777 .find("<script>lr</script>")
13778 .expect("inject HTML missing");
13779 let body_close_idx = html.find("</body>").expect("</body> missing");
13780 assert!(
13781 inject_idx < body_close_idx,
13782 "body_inject must appear before </body>: {html}"
13783 );
13784 assert_eq!(html.matches("<script>lr</script>").count(), 1);
13785 }
13786
13787 fn build_all_boundaries_protocol() -> WebUIProtocol {
13792 let mut fragments = HashMap::new();
13793 fragments.insert(
13794 "index.html".to_string(),
13795 FragmentList {
13796 fragments: vec![
13797 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13798 structural_fragment("head_end"),
13799 WebUIFragment::raw("</head><body>".to_string()),
13800 structural_fragment("body_start"),
13801 WebUIFragment::raw("hello".to_string()),
13802 structural_fragment("body_end"),
13803 WebUIFragment::raw("</body></html>".to_string()),
13804 ],
13805 contains_boundary: false,
13806 },
13807 );
13808 WebUIProtocol::new(fragments)
13809 }
13810
13811 fn state_inject_options<'a>() -> RenderOptions<'a> {
13812 RenderOptions::new("index.html", "/")
13813 }
13814
13815 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13816 let mut writer = TestWriter::new();
13817 handle(protocol, state, options, &mut writer).unwrap();
13818 writer.get_content().to_string()
13819 }
13820
13821 #[test]
13822 fn state_inject_emits_at_every_structural_boundary() {
13823 let protocol = build_all_boundaries_protocol();
13824 let state = test_json!({
13825 "$webui": {
13826 "headEnd": "<meta name=he>",
13827 "bodyStart": "<span id=bs></span>",
13828 "bodyEnd": "<script>be</script>",
13829 }
13830 });
13831 let html = render_with(&protocol, &state, &state_inject_options());
13832
13833 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13834 let head_close = html.find("</head>").expect("</head> missing");
13835 let body_open = html.find("<body>").expect("<body> missing");
13836 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13837 let hello = html.find("hello").expect("body content missing");
13838 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13839 let body_close = html.find("</body>").expect("</body> missing");
13840
13841 assert!(
13842 head_end < head_close,
13843 "headEnd must precede </head>: {html}"
13844 );
13845 assert!(
13846 body_open < body_start && body_start < hello,
13847 "bodyStart must sit immediately after <body>: {html}"
13848 );
13849 assert!(
13850 hello < body_end && body_end < body_close,
13851 "bodyEnd must precede </body>: {html}"
13852 );
13853
13854 for needle in [
13855 "<meta name=he>",
13856 "<span id=bs></span>",
13857 "<script>be</script>",
13858 ] {
13859 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13860 }
13861 }
13862
13863 #[test]
13864 fn state_inject_follows_render_options_inject() {
13865 let protocol = build_all_boundaries_protocol();
13866 let state = test_json!({
13867 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13868 });
13869 let options = RenderOptions::new("index.html", "/")
13870 .with_head_inject("<!--opt-he-->")
13871 .with_body_inject("<!--opt-be-->");
13872 let html = render_with(&protocol, &state, &options);
13873
13874 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13875 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13876 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13877 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13878
13879 assert!(
13880 opt_he < state_he,
13881 "RenderOptions head_inject must precede the state-supplied value: {html}"
13882 );
13883 assert!(
13884 opt_be < state_be,
13885 "RenderOptions body_inject must precede the state-supplied value: {html}"
13886 );
13887 }
13888
13889 #[test]
13890 fn malformed_state_inject_values_are_inert() {
13891 let protocol = build_all_boundaries_protocol();
13892 for state in [
13895 test_json!({}),
13896 test_json!({ "$webui": "not-an-object" }),
13897 test_json!({ "$webui": [] }),
13898 test_json!({ "$webui": null }),
13899 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13900 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13901 ] {
13902 let html = render_with(&protocol, &state, &state_inject_options());
13903 assert!(
13904 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13905 "malformed reserved state must render the document unchanged: {html}"
13906 );
13907 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13908 assert!(!html.contains("42"), "non-string member leaked: {html}");
13909 }
13910 }
13911
13912 #[test]
13913 fn state_inject_never_reaches_the_hydration_payload() {
13914 let protocol = build_all_boundaries_protocol();
13915 let state = test_json!({
13916 "visible": "keep",
13917 "$webui": { "bodyEnd": "<script>secret</script>" }
13918 });
13919 let handler = WebUIHandler::with_plugin(|| {
13920 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13921 });
13922 let runtime = Protocol::new(protocol.clone());
13923 let mut writer = TestWriter::new();
13924 handler
13925 .render(&runtime, &state, &state_inject_options(), &mut writer)
13926 .unwrap();
13927 let html = writer.get_content();
13928
13929 let data_start = html
13930 .find(r#"<script type="application/json" id="webui-data""#)
13931 .expect("hydration block missing");
13932 let data_end = html[data_start..]
13933 .find("</script>")
13934 .map(|offset| data_start + offset)
13935 .expect("hydration block never closes");
13936 let payload = &html[data_start..data_end];
13937 assert!(
13938 !payload.contains("$webui"),
13939 "reserved key must be stripped from the hydration payload: {payload}"
13940 );
13941 assert!(
13942 payload.contains("visible"),
13943 "ordinary state must survive the filter: {payload}"
13944 );
13945 let inject = html
13948 .find("<script>secret</script>")
13949 .expect("inject missing");
13950 let body_close = html.find("</body>").expect("</body> missing");
13951 assert!(
13952 data_end < inject && inject < body_close,
13953 "state bodyEnd must follow the hydration block and precede </body>: {html}"
13954 );
13955 }
13956
13957 #[test]
13958 fn projected_hydration_strips_reserved_state_inject_key() {
13959 let mut fragments = HashMap::new();
13960 fragments.insert(
13961 "index.html".to_string(),
13962 FragmentList {
13963 fragments: vec![
13964 WebUIFragment::raw("<html><body>"),
13965 WebUIFragment::component("app-shell"),
13966 structural_fragment("body_end"),
13967 WebUIFragment::raw("</body></html>"),
13968 ],
13969 contains_boundary: false,
13970 },
13971 );
13972 fragments.insert(
13973 "app-shell".to_string(),
13974 FragmentList {
13975 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13976 contains_boundary: false,
13977 },
13978 );
13979 let mut document = WebUIProtocol::new(fragments);
13980 document.initial_state_strategy = InitialStateStrategy::Components as i32;
13981 document.components.insert(
13982 "app-shell".to_string(),
13983 webui_protocol::ComponentData {
13984 hydration_mode: StateProjectionMode::Keys as i32,
13985 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13986 ..Default::default()
13987 },
13988 );
13989 let state = test_json!({
13990 "$webui": { "bodyEnd": "<script>secret</script>" },
13991 "serverOnly": "drop",
13992 "visible": "keep",
13993 });
13994 let handler = WebUIHandler::with_plugin(|| {
13995 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13996 });
13997 let mut writer = TestWriter::new();
13998
13999 handler
14000 .render(
14001 &Protocol::new(document),
14002 &state,
14003 &RenderOptions::new("index.html", "/"),
14004 &mut writer,
14005 )
14006 .unwrap();
14007 let html = writer.get_content();
14008 let data_start = html
14009 .find(r#"<script type="application/json" id="webui-data""#)
14010 .expect("hydration block missing");
14011 let data_end = html[data_start..]
14012 .find("</script>")
14013 .map(|offset| data_start + offset)
14014 .expect("hydration block never closes");
14015 let payload = &html[data_start..data_end];
14016
14017 assert!(
14018 !payload.contains(STATE_INJECT_KEY),
14019 "reserved key leaked: {payload}"
14020 );
14021 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
14022 assert!(!payload.contains("serverOnly"), "{payload}");
14023 assert!(html.contains("<script>secret</script>"), "{html}");
14024 }
14025
14026 #[test]
14027 fn write_selected_state_strips_reserved_key_from_full_state() {
14028 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
14029 let mut sink = TestWriter::new();
14030 let mut scratch = Vec::new();
14031 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
14032 let json = sink.get_content();
14033 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
14034 assert!(
14035 json.contains("\"a\":1") && json.contains("\"z\":2"),
14036 "{json}"
14037 );
14038 }
14039
14040 #[test]
14041 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
14042 let mut protocol = WebUIProtocol::new(HashMap::new());
14045 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
14046 protocol.fragments.insert(
14047 "keep-card".to_string(),
14048 webui_protocol::FragmentList::default(),
14049 );
14050 protocol.components.insert(
14051 "keep-card".to_string(),
14052 webui_protocol::ComponentData {
14053 hydration_mode: StateProjectionMode::Keys as i32,
14054 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
14055 ..Default::default()
14056 },
14057 );
14058 let protocol = Protocol::new(protocol);
14059 let index = protocol.component_reachability();
14060 let component = protocol.component_index()["keep-card"];
14061 let mut ids = Vec::new();
14062 assert!(!collect_hydration_key_ids_into(
14063 protocol.protocol(),
14064 index,
14065 [component],
14066 &mut ids
14067 ));
14068
14069 let state = test_json!({
14070 "$webui": { "bodyEnd": "<b>x</b>" },
14071 "keep": 1,
14072 });
14073 let mut sink = TestWriter::new();
14074 let mut scratch = Vec::new();
14075
14076 write_selected_state(
14077 &mut sink,
14078 &mut scratch,
14079 &state,
14080 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
14081 )
14082 .unwrap();
14083
14084 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
14085 }
14086
14087 #[test]
14088 fn write_selected_state_full_is_unchanged_without_reserved_key() {
14089 let state = test_json!({ "a": 1, "z": 2 });
14090 let mut sink = TestWriter::new();
14091 let mut scratch = Vec::new();
14092 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
14093 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
14094 }
14095
14096 #[test]
14097 fn body_start_hook_dedupes_on_malformed_protocol() {
14098 let mut fragments = HashMap::new();
14099 fragments.insert(
14100 "index.html".to_string(),
14101 FragmentList {
14102 fragments: vec![
14103 WebUIFragment::raw("<html><head></head><body>".to_string()),
14104 structural_fragment("body_start"),
14105 structural_fragment("body_start"),
14106 WebUIFragment::raw("</body></html>".to_string()),
14107 ],
14108 contains_boundary: false,
14109 },
14110 );
14111 let protocol = WebUIProtocol::new(fragments);
14112 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
14113 let html = render_with(&protocol, &state, &state_inject_options());
14114 assert_eq!(
14115 html.matches("<i>once</i>").count(),
14116 1,
14117 "duplicate body_start signals must not duplicate the inject: {html}"
14118 );
14119 }
14120
14121 #[test]
14122 fn injects_are_no_op_when_unset() {
14123 let protocol = build_head_body_protocol();
14124 let state = test_json!({});
14125 let mut writer = TestWriter::new();
14126 handle(
14127 &protocol,
14128 &state,
14129 &RenderOptions::new("index.html", "/"),
14130 &mut writer,
14131 )
14132 .unwrap();
14133 let html = writer.get_content();
14134 assert!(!html.contains("<link rel=preload>"));
14135 assert!(!html.contains("<script>lr</script>"));
14136 }
14137
14138 #[test]
14139 fn empty_inject_string_treated_as_unset() {
14140 let protocol = build_head_body_protocol();
14141 let state = test_json!({});
14142 let mut writer = TestWriter::new();
14143 let opts = RenderOptions::new("index.html", "/")
14144 .with_head_inject("")
14145 .with_body_inject("");
14146 handle(&protocol, &state, &opts, &mut writer).unwrap();
14147 let html = writer.get_content();
14150 assert!(html.contains("</head>"));
14151 assert!(html.contains("</body>"));
14152 }
14153
14154 #[test]
14155 fn inject_html_is_passed_through_verbatim() {
14156 let protocol = build_head_body_protocol();
14160 let state = test_json!({});
14161 let mut writer = TestWriter::new();
14162 let opts =
14163 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
14164 handle(&protocol, &state, &opts, &mut writer).unwrap();
14165 assert!(writer.get_content().contains("<script>var x=1;</script>"));
14166 }
14167
14168 #[test]
14175 fn injects_robust_against_marker_literals_in_content() {
14176 let mut fragments = HashMap::new();
14177 fragments.insert(
14181 "index.html".to_string(),
14182 FragmentList {
14183 fragments: vec![
14184 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
14185 structural_fragment("head_end"),
14186 WebUIFragment::raw(
14187 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
14188 ),
14189 structural_fragment("body_end"),
14190 WebUIFragment::raw("</body></html>".to_string()),
14191 ],
14192 contains_boundary: false,
14193 },
14194 );
14195 let protocol = WebUIProtocol::new(fragments);
14196 let state = test_json!({});
14197 let mut writer = TestWriter::new();
14198 let opts = RenderOptions::new("index.html", "/")
14199 .with_head_inject("<HEAD-INJ>")
14200 .with_body_inject("<BODY-INJ>");
14201 handle(&protocol, &state, &opts, &mut writer).unwrap();
14202 let html = writer.get_content();
14203 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
14206 let head_close_idx = html.find("</head>").expect("</head> missing");
14207 assert!(head_inj_idx < head_close_idx);
14208 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
14212 let body_close_idx = html.rfind("</body>").expect("</body> missing");
14214 assert!(
14215 body_inj_idx < body_close_idx,
14216 "body_inject must precede the structural </body>: {html}"
14217 );
14218 assert!(html.contains("<!-- </body> </head> -->"));
14220 }
14221
14222 #[test]
14226 fn both_injects_fire_at_correct_boundaries() {
14227 let protocol = build_head_body_protocol();
14228 let state = test_json!({});
14229 let mut writer = TestWriter::new();
14230 let opts = RenderOptions::new("index.html", "/")
14231 .with_head_inject("<META-HEAD>")
14232 .with_body_inject("<SCRIPT-BODY>");
14233 handle(&protocol, &state, &opts, &mut writer).unwrap();
14234 let html = writer.get_content();
14235 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
14236 let head_close = html.find("</head>").expect("</head> missing");
14237 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
14238 let body_close = html.find("</body>").expect("</body> missing");
14239 assert!(head_idx < head_close, "head_inject before </head>");
14240 assert!(head_close < body_idx, "body_inject after </head>");
14241 assert!(body_idx < body_close, "body_inject before </body>");
14242 assert_eq!(html.matches("<META-HEAD>").count(), 1);
14243 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
14244 }
14245
14246 #[test]
14252 fn injects_dedupe_against_duplicate_signals() {
14253 let mut fragments = HashMap::new();
14254 fragments.insert(
14255 "index.html".to_string(),
14256 FragmentList {
14257 fragments: vec![
14258 WebUIFragment::raw("<html><head>".to_string()),
14259 structural_fragment("head_end"),
14260 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
14263 structural_fragment("body_end"),
14264 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
14266 ],
14267 contains_boundary: false,
14268 },
14269 );
14270 let protocol = WebUIProtocol::new(fragments);
14271 let state = test_json!({});
14272 let mut writer = TestWriter::new();
14273 let opts = RenderOptions::new("index.html", "/")
14274 .with_head_inject("<HINJ>")
14275 .with_body_inject("<BINJ>");
14276 handle(&protocol, &state, &opts, &mut writer).unwrap();
14277 let html = writer.get_content();
14278 assert_eq!(
14279 html.matches("<HINJ>").count(),
14280 1,
14281 "head_inject must emit exactly once even with duplicate head_end signals"
14282 );
14283 assert_eq!(
14284 html.matches("<BINJ>").count(),
14285 1,
14286 "body_inject must emit exactly once even with duplicate body_end signals"
14287 );
14288 }
14289
14290 #[test]
14294 fn injects_no_op_when_no_head_or_body_signals() {
14295 let mut fragments = HashMap::new();
14296 fragments.insert(
14297 "index.html".to_string(),
14298 FragmentList {
14299 fragments: vec![WebUIFragment::raw(
14300 "<my-component>hi</my-component>".to_string(),
14301 )],
14302 contains_boundary: false,
14303 },
14304 );
14305 let protocol = WebUIProtocol::new(fragments);
14306 let state = test_json!({});
14307 let mut writer = TestWriter::new();
14308 let opts = RenderOptions::new("index.html", "/")
14309 .with_head_inject("<HINJ>")
14310 .with_body_inject("<BINJ>");
14311 handle(&protocol, &state, &opts, &mut writer).unwrap();
14312 let html = writer.get_content();
14313 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
14314 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
14315 assert!(html.contains("<my-component>"));
14316 }
14317
14318 #[test]
14324 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
14325 let protocol = std::sync::Arc::new(build_head_body_protocol());
14326 let state = std::sync::Arc::new(test_json!({}));
14327 let handler = std::sync::Arc::new(WebUIHandler::new());
14328
14329 const N_THREADS: usize = 16;
14330 let mut handles = Vec::with_capacity(N_THREADS);
14331 for tid in 0..N_THREADS {
14332 let h = std::sync::Arc::clone(&handler);
14333 let p = std::sync::Arc::clone(&protocol);
14334 let s = std::sync::Arc::clone(&state);
14335 handles.push(std::thread::spawn(move || {
14336 let head = format!("<HEAD-T{tid}>");
14337 let body = format!("<BODY-T{tid}>");
14338 let mut writer = TestWriter::new();
14339 let opts = RenderOptions::new("index.html", "/")
14340 .with_head_inject(&head)
14341 .with_body_inject(&body);
14342 h.handle(&p, &s, &opts, &mut writer).unwrap();
14343 let html = writer.get_content();
14344 assert_eq!(html.matches(&head).count(), 1);
14346 assert_eq!(html.matches(&body).count(), 1);
14347 for other in 0..N_THREADS {
14349 if other == tid {
14350 continue;
14351 }
14352 let other_head = format!("<HEAD-T{other}>");
14353 let other_body = format!("<BODY-T{other}>");
14354 assert!(
14355 !html.contains(&other_head),
14356 "tid {tid} saw {other}'s head_inject"
14357 );
14358 assert!(
14359 !html.contains(&other_body),
14360 "tid {tid} saw {other}'s body_inject"
14361 );
14362 }
14363 }));
14364 }
14365 for h in handles {
14366 h.join().expect("worker panicked");
14367 }
14368 }
14369
14370 #[test]
14375 fn large_inject_roundtrips_without_truncation() {
14376 let protocol = build_head_body_protocol();
14377 let state = test_json!({});
14378 let mut writer = TestWriter::new();
14379 let big = "x".repeat(1024 * 1024);
14380 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
14381 handle(&protocol, &state, &opts, &mut writer).unwrap();
14382 let html = writer.get_content();
14383 assert!(
14384 html.contains(&big),
14385 "large head_inject must be present verbatim ({} bytes)",
14386 big.len()
14387 );
14388 assert_eq!(html.matches(&big).count(), 1);
14390 }
14391
14392 #[test]
14397 fn empty_nonce_treated_as_unset() {
14398 let protocol = build_head_body_protocol();
14399 let state = test_json!({});
14400 let mut writer = TestWriter::new();
14401 let opts = RenderOptions::new("index.html", "/").with_nonce("");
14402 handle(&protocol, &state, &opts, &mut writer).unwrap();
14403 assert!(
14404 !writer.get_content().contains("webui-nonce"),
14405 "empty nonce must not emit <meta name=\"webui-nonce\">"
14406 );
14407 }
14408
14409 #[test]
14427 fn empty_field_bypass_is_normalised_at_handler_init() {
14428 let protocol = build_head_body_protocol();
14429 let state = test_json!({});
14430
14431 let opts_with_empty_nonce = RenderOptions {
14433 nonce: Some(""),
14434 ..RenderOptions::new("index.html", "/")
14435 };
14436 let mut writer = TestWriter::new();
14437 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14438 let html = writer.get_content();
14439 assert!(
14440 !html.contains("webui-nonce"),
14441 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14442 );
14443 assert!(
14444 !html.contains("nonce=\"\""),
14445 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14446 );
14447
14448 let opts_with_empty_injects = RenderOptions {
14450 head_inject: Some(""),
14451 body_inject: Some(""),
14452 ..RenderOptions::new("index.html", "/")
14453 };
14454 let mut writer = TestWriter::new();
14455 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14456 }
14460
14461 #[test]
14494 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14495 let mut fragments = HashMap::new();
14496 fragments.insert(
14497 "index.html".to_string(),
14498 FragmentList {
14499 fragments: vec![
14500 WebUIFragment::raw("["),
14501 WebUIFragment::for_loop("item", "outer", "outer_body"),
14502 WebUIFragment::raw("]"),
14503 ],
14504 contains_boundary: false,
14505 },
14506 );
14507 fragments.insert(
14508 "outer_body".to_string(),
14509 FragmentList {
14510 fragments: vec![
14511 WebUIFragment::raw("(O="),
14512 WebUIFragment::signal("item.tag", false),
14513 WebUIFragment::for_loop("item", "inner", "inner_body"),
14514 WebUIFragment::raw(",O="),
14515 WebUIFragment::signal("item.tag", false),
14516 WebUIFragment::raw(")"),
14517 ],
14518 contains_boundary: false,
14519 },
14520 );
14521 fragments.insert(
14522 "inner_body".to_string(),
14523 FragmentList {
14524 fragments: vec![
14525 WebUIFragment::raw("[I="),
14526 WebUIFragment::signal("item.tag", false),
14527 WebUIFragment::raw("]"),
14528 ],
14529 contains_boundary: false,
14530 },
14531 );
14532 let protocol = WebUIProtocol::new(fragments);
14533 let state = test_json!({
14534 "outer": [{"tag": "A"}, {"tag": "B"}],
14535 "inner": [{"tag": "X"}, {"tag": "Y"}],
14536 });
14537 let mut writer = TestWriter::new();
14538 handle(
14539 &protocol,
14540 &state,
14541 &RenderOptions::new("index.html", "/"),
14542 &mut writer,
14543 )
14544 .unwrap();
14545 assert_eq!(
14557 writer.get_content(),
14558 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14559 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14560 );
14561 }
14562 #[derive(Default)]
14563 struct FlushTestWriter {
14564 output: String,
14565 flushes: Vec<usize>,
14566 fail_flush: bool,
14567 fail_flush_at: Option<usize>,
14568 flush_attempts: usize,
14569 ended: bool,
14570 }
14571
14572 impl ResponseWriter for FlushTestWriter {
14573 fn write(&mut self, content: &str) -> Result<()> {
14574 self.output.push_str(content);
14575 Ok(())
14576 }
14577
14578 fn end(&mut self) -> Result<()> {
14579 self.ended = true;
14580 Ok(())
14581 }
14582 }
14583
14584 impl FlushWriter for FlushTestWriter {
14585 fn flush(&mut self) -> Result<()> {
14586 let attempt = self.flush_attempts;
14587 self.flush_attempts += 1;
14588 if self.fail_flush || self.fail_flush_at == Some(attempt) {
14589 return Err(HandlerError::ClientDisconnected);
14590 }
14591 self.flushes.push(self.output.len());
14592 Ok(())
14593 }
14594 }
14595
14596 fn streaming_protocol(with_boundaries: bool) -> Protocol {
14597 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14598 }
14599
14600 fn streaming_protocol_with_state_strategy(
14601 with_boundaries: bool,
14602 state_strategy: InitialStateStrategy,
14603 ) -> Protocol {
14604 let mut fragments = HashMap::new();
14605 let mut entry = vec![
14606 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14607 structural_fragment("head_start"),
14608 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14609 structural_fragment("head_end"),
14610 WebUIFragment::raw("</HEAD><body>"),
14611 structural_fragment("body_start"),
14612 ];
14613 if with_boundaries {
14614 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14615 }
14616 entry.extend([
14617 WebUIFragment::raw("<my-counter"),
14618 structural_fragment("streaming_root:my-counter"),
14619 WebUIFragment::raw(">"),
14620 WebUIFragment::component("my-counter"),
14621 WebUIFragment::raw("</my-counter>"),
14622 ]);
14623 if with_boundaries {
14624 entry.push(WebUIFragment::boundary_end(0));
14625 }
14626 entry.extend([
14627 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14628 structural_fragment("body_end"),
14629 WebUIFragment::raw("</body></html>"),
14630 ]);
14631 fragments.insert(
14632 "index.html".to_string(),
14633 FragmentList {
14634 fragments: entry,
14635 contains_boundary: with_boundaries,
14636 },
14637 );
14638 fragments.insert(
14639 "my-counter".to_string(),
14640 FragmentList {
14641 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14642 contains_boundary: false,
14643 },
14644 );
14645
14646 let mut document = WebUIProtocol::new(fragments);
14647 document.initial_state_strategy = state_strategy as i32;
14648 document.components.insert(
14649 "my-counter".to_string(),
14650 webui_protocol::ComponentData {
14651 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14652 template_functions: "[function(){return true}]".to_string(),
14653 hydration_mode: StateProjectionMode::Keys as i32,
14654 hydration_keys: vec!["count".to_string()],
14655 ..Default::default()
14656 },
14657 );
14658 Protocol::new(document)
14659 }
14660
14661 #[test]
14662 fn handler_error_stays_small() {
14663 assert!(
14670 std::mem::size_of::<HandlerError>()
14671 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14672 "HandlerError grew to {} bytes",
14673 std::mem::size_of::<HandlerError>()
14674 );
14675 }
14676
14677 #[test]
14678 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14679 let protocol = streaming_protocol(true);
14680 let handler = WebUIHandler::with_plugin(|| {
14681 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14682 });
14683 let state = test_json!({ "count": 1, "serverOnly": "secret" });
14684 let mut writer = FlushTestWriter::default();
14685
14686 handler
14687 .render_streaming(
14688 &protocol,
14689 &state,
14690 &RenderOptions::new("index.html", "/"),
14691 &mut writer,
14692 )
14693 .unwrap();
14694
14695 assert!(writer.ended);
14696 assert_eq!(
14697 writer.flushes.len(),
14698 3,
14699 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14700 );
14701 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14702 assert!(boundary_flush.contains("<!--wb:0-->"));
14703 assert!(boundary_flush.contains("<!--/wb:0-->"));
14704 assert!(
14705 boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14706 "boundary flush: {boundary_flush}"
14707 );
14708 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14709 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14710 assert!(!boundary_flush.contains("slow tail"));
14711 assert!(!writer.output.contains("id=\"webui-data\""));
14712 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14715 assert!(terminal_flush.contains("slow tail"));
14716 assert!(writer.output.contains("[1,4,0,{}]"));
14717 assert!(!writer.output.contains("[1,0,1,"));
14718 assert!(!writer.output.contains("[2,4,0,{}]"));
14719
14720 let marker = writer
14721 .output
14722 .find(STREAMING_MARKER)
14723 .expect("streaming marker");
14724 let attributed_head = writer
14725 .output
14726 .find("<HEAD data-shell=\"main\">")
14727 .expect("attributed mixed-case head");
14728 let authored_script = writer
14729 .output
14730 .find("src=\"/index.js\"")
14731 .expect("entry script");
14732 assert!(attributed_head < marker && marker < authored_script);
14733 }
14734
14735 #[test]
14736 fn streaming_terminal_tail_never_resends_full_state() {
14737 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14738 let handler = WebUIHandler::with_plugin(|| {
14739 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14740 });
14741 let mut writer = FlushTestWriter::default();
14742
14743 handler
14744 .render_streaming(
14745 &protocol,
14746 &test_json!({ "count": 1, "serverOnly": "secret" }),
14747 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14748 &mut writer,
14749 )
14750 .unwrap();
14751
14752 assert_eq!(
14753 writer.output.matches("serverOnly").count(),
14754 1,
14755 "full state belongs only to the interactive boundary"
14756 );
14757 assert!(writer.output.contains("[1,4,0,{}]"));
14758 }
14759
14760 #[test]
14761 fn streaming_terminal_record_emits_before_body_close() {
14762 let fragments = HashMap::from([(
14763 "index.html".to_string(),
14764 FragmentList {
14765 fragments: vec![
14766 WebUIFragment::raw("<html><head>"),
14767 structural_fragment("head_start"),
14768 structural_fragment("head_end"),
14769 WebUIFragment::raw("</head><body>"),
14770 structural_fragment("body_start"),
14771 WebUIFragment::raw("<main>static</main>"),
14772 structural_fragment("body_end"),
14773 WebUIFragment::raw("</body></html>"),
14774 ],
14775 contains_boundary: false,
14776 },
14777 )]);
14778 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14779 let mut writer = FlushTestWriter::default();
14780
14781 WebUIHandler::new()
14782 .render_streaming(
14783 &protocol,
14784 &test_json!({}),
14785 &RenderOptions::new("index.html", "/"),
14786 &mut writer,
14787 )
14788 .unwrap();
14789
14790 let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14791 let body_close = writer.output.find("</body>").expect("body close");
14792 assert!(
14793 terminal < body_close,
14794 "terminal boundary record must stay inside <body>: {}",
14795 writer.output
14796 );
14797 }
14798
14799 #[test]
14803 fn state_inject_placement_matches_between_render_modes() {
14804 let entry = vec![
14805 WebUIFragment::raw("<html><head>"),
14806 structural_fragment("head_start"),
14807 structural_fragment("head_end"),
14808 WebUIFragment::raw("</head><body>"),
14809 structural_fragment("body_start"),
14810 WebUIFragment::raw("<main>static</main>"),
14811 structural_fragment("body_end"),
14812 WebUIFragment::raw("</body></html>"),
14813 ];
14814 let fragments = HashMap::from([(
14815 "index.html".to_string(),
14816 FragmentList {
14817 fragments: entry,
14818 contains_boundary: false,
14819 },
14820 )]);
14821 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14822 let state = test_json!({
14823 "$webui": {
14824 "headEnd": "<meta name=he>",
14825 "bodyStart": "<span id=bs></span>",
14826 "bodyEnd": "<script>be</script>",
14827 }
14828 });
14829 let options = RenderOptions::new("index.html", "/");
14830
14831 let mut ordinary = TestWriter::new();
14832 WebUIHandler::new()
14833 .render(&protocol, &state, &options, &mut ordinary)
14834 .unwrap();
14835 let ordinary_html = ordinary.get_content().to_string();
14836
14837 let mut streamed = FlushTestWriter::default();
14838 WebUIHandler::new()
14839 .render_streaming(&protocol, &state, &options, &mut streamed)
14840 .unwrap();
14841 let streamed_html = &streamed.output;
14842
14843 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14844 let head_end = html.find("<meta name=he>").expect("headEnd missing");
14845 let head_close = html.find("</head>").expect("</head> missing");
14846 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14847 let main = html.find("<main>static</main>").expect("content missing");
14848 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14849 let body_close = html.find("</body>").expect("</body> missing");
14850 assert!(head_end < head_close, "headEnd misplaced: {html}");
14851 assert!(body_start < main, "bodyStart misplaced: {html}");
14852 assert!(
14853 main < body_end && body_end < body_close,
14854 "bodyEnd misplaced: {html}"
14855 );
14856 }
14857
14858 assert!(
14861 streamed_html.contains(",4,0,{}]"),
14862 "streaming must still end in one empty terminal record: {streamed_html}"
14863 );
14864 }
14865
14866 #[test]
14867 fn streaming_state_inject_emits_and_strips_reserved_key() {
14868 let entry = vec![
14869 WebUIFragment::raw("<html><head>"),
14870 structural_fragment("head_start"),
14871 structural_fragment("head_end"),
14872 WebUIFragment::raw("</head><body>"),
14873 structural_fragment("body_start"),
14874 structural_fragment("body_end"),
14875 WebUIFragment::raw("</body></html>"),
14876 ];
14877 let fragments = HashMap::from([(
14878 "index.html".to_string(),
14879 FragmentList {
14880 fragments: entry,
14881 contains_boundary: false,
14882 },
14883 )]);
14884 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14885 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14886
14887 let mut writer = FlushTestWriter::default();
14888 WebUIHandler::new()
14889 .render_streaming(
14890 &protocol,
14891 &state,
14892 &RenderOptions::new("index.html", "/"),
14893 &mut writer,
14894 )
14895 .unwrap();
14896 assert!(writer.output.contains("<script>be</script>"));
14897 assert!(!writer.output.contains("$webui"));
14898 }
14899
14900 #[test]
14901 fn static_streaming_document_uses_one_empty_terminal_record() {
14902 let fragments = HashMap::from([(
14903 "index.html".to_string(),
14904 FragmentList {
14905 fragments: vec![
14906 WebUIFragment::raw("<html><head>"),
14907 structural_fragment("head_start"),
14908 structural_fragment("head_end"),
14909 WebUIFragment::raw("</head><body>"),
14910 structural_fragment("body_start"),
14911 WebUIFragment::raw("<main>static</main>"),
14912 structural_fragment("body_end"),
14913 WebUIFragment::raw("</body></html>"),
14914 ],
14915 contains_boundary: false,
14916 },
14917 )]);
14918 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14919 let mut writer = FlushTestWriter::default();
14920
14921 WebUIHandler::new()
14922 .render_streaming(
14923 &protocol,
14924 &test_json!({ "serverOnly": "secret" }),
14925 &RenderOptions::new("index.html", "/"),
14926 &mut writer,
14927 )
14928 .unwrap();
14929
14930 assert_eq!(writer.flushes.len(), 1);
14931 assert!(writer.output.contains(STREAMING_MARKER));
14932 assert!(writer.output.contains("[0,4,0,{}]"));
14933 assert!(!writer.output.contains("serverOnly"));
14934 assert!(!writer.output.contains("id=\"webui-data\""));
14935 assert!(!writer.output.contains("<!--wb:"));
14936 }
14937
14938 #[test]
14939 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14940 let protocol = streaming_protocol(true);
14941 let handler = WebUIHandler::with_plugin(|| {
14942 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14943 });
14944 let mut writer = FlushTestWriter::default();
14945 handler
14946 .render_streaming(
14947 &protocol,
14948 &test_json!({ "count": 1 }),
14949 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14950 &mut writer,
14951 )
14952 .unwrap();
14953
14954 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14955 let envelope = writer.output[boundary_end..]
14956 .find("data-webui-boundary nonce=\"test-nonce-123\"")
14957 .map(|index| index + boundary_end)
14958 .expect("nonce-bearing envelope");
14959 let functions = writer
14960 .output
14961 .find("templateFns")
14962 .expect("function side channel");
14963 let sentinel = writer
14964 .output
14965 .find("<webui-hydrate>")
14966 .expect("hydration sentinel");
14967 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14968 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14969 }
14970
14971 #[test]
14972 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14973 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14974 let mut fragments = vec![structural_fragment("head_start")];
14975 fragments.extend(boundaries);
14976 let fragments = HashMap::from([(
14977 "index.html".to_string(),
14978 FragmentList {
14979 fragments,
14980 contains_boundary: true,
14981 },
14982 )]);
14983 Protocol::new(WebUIProtocol::new(fragments))
14984 }
14985
14986 let handler = WebUIHandler::new();
14987 let mut writer = FlushTestWriter::default();
14988 let result = handler.render_streaming(
14989 &protocol(vec![WebUIFragment::boundary_end(0)]),
14990 &test_json!({}),
14991 &RenderOptions::new("index.html", "/"),
14992 &mut writer,
14993 );
14994 match result {
14995 Err(HandlerError::StreamingBoundary(err)) => {
14996 assert_eq!(err.signal, "boundary");
14997 assert!(err.reason.contains("no active occurrence"));
14998 }
14999 other => panic!("expected ordered-boundary error, got {other:?}"),
15000 }
15001
15002 let mut writer = FlushTestWriter::default();
15003 let result = handler.render_streaming(
15004 &protocol(vec![
15005 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15006 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15007 ]),
15008 &test_json!({}),
15009 &RenderOptions::new("index.html", "/"),
15010 &mut writer,
15011 );
15012 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
15013 }
15014
15015 #[test]
15016 fn streaming_render_propagates_boundary_flush_disconnect() {
15017 let protocol = streaming_protocol(true);
15018 let handler = WebUIHandler::new();
15019 let mut writer = FlushTestWriter {
15020 fail_flush: true,
15021 ..FlushTestWriter::default()
15022 };
15023 let result = handler.render_streaming(
15024 &protocol,
15025 &test_json!({}),
15026 &RenderOptions::new("index.html", "/"),
15027 &mut writer,
15028 );
15029 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
15030 assert!(!writer.ended);
15031 }
15032
15033 #[test]
15034 fn streaming_render_requires_structural_head_start_before_writing() {
15035 let fragments = HashMap::from([(
15036 "index.html".to_string(),
15037 FragmentList {
15038 fragments: vec![
15039 WebUIFragment::raw(
15040 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
15041 ),
15042 structural_fragment("head_end"),
15043 WebUIFragment::raw("</HEAD><body>"),
15044 structural_fragment("body_start"),
15045 structural_fragment("body_end"),
15046 ],
15047 contains_boundary: false,
15048 },
15049 )]);
15050 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15051 let mut writer = FlushTestWriter::default();
15052
15053 let result = WebUIHandler::new().render_streaming(
15054 &protocol,
15055 &test_json!({}),
15056 &RenderOptions::new("index.html", "/"),
15057 &mut writer,
15058 );
15059
15060 assert!(matches!(
15061 result,
15062 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
15063 ));
15064 assert_eq!(
15065 writer.output,
15066 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
15067 );
15068 assert!(writer.flushes.is_empty());
15069 assert!(!writer.ended);
15070 }
15071
15072 #[test]
15073 fn streaming_render_rejects_duplicate_head_start() {
15074 let fragments = HashMap::from([(
15075 "index.html".to_string(),
15076 FragmentList {
15077 fragments: vec![
15078 WebUIFragment::raw("<html><head>"),
15079 structural_fragment("head_start"),
15080 structural_fragment("head_start"),
15081 structural_fragment("head_end"),
15082 WebUIFragment::raw("</head><body>"),
15083 structural_fragment("body_start"),
15084 structural_fragment("body_end"),
15085 ],
15086 contains_boundary: false,
15087 },
15088 )]);
15089 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15090 let mut writer = FlushTestWriter::default();
15091
15092 let result = WebUIHandler::new().render_streaming(
15093 &protocol,
15094 &test_json!({}),
15095 &RenderOptions::new("index.html", "/"),
15096 &mut writer,
15097 );
15098
15099 assert!(matches!(
15100 result,
15101 Err(HandlerError::DuplicateStreamingHeadStart)
15102 ));
15103 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
15104 assert!(writer.flushes.is_empty());
15105 }
15106
15107 #[test]
15108 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
15109 let handler = WebUIHandler::with_plugin(|| {
15110 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15111 });
15112 let state = test_json!({
15113 "count": 1,
15114 "head_start": "must not render",
15115 "boundary_start:0": "must not render",
15116 "boundary_end:0": "must not render",
15117 });
15118 let mut with_boundaries = TestWriter::new();
15119 let mut without_boundaries = TestWriter::new();
15120 handler
15121 .render(
15122 &streaming_protocol(true),
15123 &state,
15124 &RenderOptions::new("index.html", "/"),
15125 &mut with_boundaries,
15126 )
15127 .unwrap();
15128 handler
15129 .render(
15130 &streaming_protocol(false),
15131 &state,
15132 &RenderOptions::new("index.html", "/"),
15133 &mut without_boundaries,
15134 )
15135 .unwrap();
15136 assert_eq!(
15137 with_boundaries.get_content(),
15138 without_boundaries.get_content()
15139 );
15140 }
15141
15142 #[test]
15143 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
15144 for source in [
15145 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
15146 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
15147 ] {
15148 let mut parser = HtmlParser::new();
15149 parser
15150 .parse("index.html", source)
15151 .expect("parse head fixture");
15152 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15153 let mut writer = TestWriter::new();
15154 WebUIHandler::new()
15155 .render(
15156 &protocol,
15157 &test_json!({ "theme": "light" }),
15158 &RenderOptions::new("index.html", "/"),
15159 &mut writer,
15160 )
15161 .unwrap();
15162
15163 assert_eq!(
15164 writer.get_content(),
15165 "<html><head><title>T</title></head><body>x</body></html>"
15166 );
15167 }
15168 }
15169
15170 #[test]
15171 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
15172 let source =
15173 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
15174 let mut parser = HtmlParser::new();
15175 parser.parse("index.html", source).expect("parse fixture");
15176 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15177
15178 let mut ordinary = TestWriter::new();
15179 WebUIHandler::new()
15180 .render(
15181 &protocol,
15182 &test_json!({}),
15183 &RenderOptions::new("index.html", "/"),
15184 &mut ordinary,
15185 )
15186 .unwrap();
15187 assert_eq!(ordinary.get_content(), source);
15188
15189 let mut streaming = FlushTestWriter::default();
15190 WebUIHandler::new()
15191 .render_streaming(
15192 &protocol,
15193 &test_json!({}),
15194 &RenderOptions::new("index.html", "/"),
15195 &mut streaming,
15196 )
15197 .unwrap();
15198 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
15199 assert!(streaming.output.contains("</HEAD><BODY>x"));
15200 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
15201 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
15202 let title = streaming.output.find("<title>").expect("title");
15203 assert!(opening < marker && marker < title);
15204 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
15205 }
15206
15207 #[test]
15210 fn unnamespaced_signal_is_ordinary_content() {
15211 let fragments = HashMap::from([(
15212 "index.html".to_string(),
15213 FragmentList {
15214 fragments: vec![
15215 WebUIFragment::raw("<html><head>"),
15216 structural_fragment("head_end"),
15217 WebUIFragment::raw("</head><body>"),
15218 WebUIFragment::signal("body_end".to_string(), false),
15219 structural_fragment("body_end"),
15220 WebUIFragment::raw("</body></html>"),
15221 ],
15222 contains_boundary: false,
15223 },
15224 )]);
15225 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15226 let mut writer = TestWriter::new();
15227 WebUIHandler::new()
15228 .render(
15229 &protocol,
15230 &test_json!({ "body_end": "content" }),
15231 &RenderOptions::new("index.html", "/"),
15232 &mut writer,
15233 )
15234 .expect("current protocol must render");
15235 assert!(writer.get_content().contains("content"));
15236 }
15237
15238 #[test]
15239 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
15240 let source = concat!(
15241 "<html><head></head><body>",
15242 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
15243 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
15244 "</body></html>",
15245 );
15246 let state = test_json!({
15247 "head_start": "hs",
15248 "head_end": "he",
15249 "body_start": "bs",
15250 "body_end": "be",
15251 "boundary_start:0": "b0s",
15252 "boundary_end:0": "b0e",
15253 "streaming_root:forged": "root",
15254 });
15255 let mut parser = HtmlParser::new();
15256 parser.parse("index.html", source).expect("parse fixture");
15257 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
15258 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
15259
15260 let mut ordinary = TestWriter::new();
15261 WebUIHandler::new()
15262 .render(
15263 &protocol,
15264 &state,
15265 &RenderOptions::new("index.html", "/"),
15266 &mut ordinary,
15267 )
15268 .unwrap();
15269 assert_eq!(ordinary.get_content(), expected);
15270
15271 let mut streaming = FlushTestWriter::default();
15272 WebUIHandler::new()
15273 .render_streaming(
15274 &protocol,
15275 &state,
15276 &RenderOptions::new("index.html", "/"),
15277 &mut streaming,
15278 )
15279 .unwrap();
15280 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
15281 assert!(!streaming.output.contains("<!--wb:"));
15282 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
15283 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
15284 }
15285
15286 #[test]
15287 fn streaming_rejects_structural_signals_after_terminal() {
15288 let fragments = HashMap::from([(
15289 "index.html".to_string(),
15290 FragmentList {
15291 fragments: vec![
15292 WebUIFragment::raw("<html><head>"),
15293 structural_fragment("head_start"),
15294 structural_fragment("head_end"),
15295 WebUIFragment::raw("</head><body>"),
15296 structural_fragment("body_start"),
15297 structural_fragment("body_end"),
15298 WebUIFragment::raw("</body>"),
15299 structural_fragment("boundary_start:0"),
15300 WebUIFragment::raw("</html>"),
15301 ],
15302 contains_boundary: false,
15303 },
15304 )]);
15305 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15306 let mut streaming = FlushTestWriter::default();
15307 let result = WebUIHandler::new().render_streaming(
15308 &protocol,
15309 &test_json!({}),
15310 &RenderOptions::new("index.html", "/"),
15311 &mut streaming,
15312 );
15313
15314 match result {
15315 Err(HandlerError::StreamingBoundary(error)) => {
15316 assert_eq!(error.signal, "boundary_start:0");
15317 assert!(
15318 error
15319 .reason
15320 .contains("structural signal arrived after body_end"),
15321 "reason: {}",
15322 error.reason
15323 );
15324 }
15325 other => panic!("expected post-terminal rejection, got {other:?}"),
15326 }
15327 assert_eq!(
15328 streaming.output,
15329 concat!(
15330 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
15331 "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
15332 "<webui-hydrate></webui-hydrate></body>"
15333 )
15334 );
15335 assert!(!streaming.output.contains("<!--wb:0-->"));
15336
15337 let mut ordinary = TestWriter::new();
15338 WebUIHandler::new()
15339 .render(
15340 &protocol,
15341 &test_json!({}),
15342 &RenderOptions::new("index.html", "/"),
15343 &mut ordinary,
15344 )
15345 .unwrap();
15346 assert_eq!(
15347 ordinary.get_content(),
15348 "<html><head></head><body></body></html>"
15349 );
15350 }
15351
15352 fn disjoint_streaming_protocol_ext(
15353 hosts: &[&str],
15354 emit_root_signal: bool,
15355 include_styles: bool,
15356 ) -> Protocol {
15357 let mut fragments = HashMap::new();
15358 let mut entry = vec![
15359 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
15360 structural_fragment("head_start"),
15361 structural_fragment("head_end"),
15362 WebUIFragment::raw("</head><body>"),
15363 structural_fragment("body_start"),
15364 ];
15365 for (sequence, host) in hosts.iter().enumerate() {
15366 entry.push(WebUIFragment::boundary(
15367 sequence as u32,
15368 "index.html",
15369 format!("boundary-{sequence}"),
15370 None,
15371 ));
15372 entry.push(WebUIFragment::raw(format!("<{host}")));
15373 if emit_root_signal {
15374 entry.push(structural_fragment(format!("streaming_root:{host}")));
15375 }
15376 entry.push(WebUIFragment::raw(">"));
15377 entry.push(WebUIFragment::component(*host));
15378 entry.push(WebUIFragment::raw(format!("</{host}>")));
15379 entry.push(WebUIFragment::boundary_end(sequence as u32));
15380 }
15381 entry.push(structural_fragment("body_end"));
15382 entry.push(WebUIFragment::raw("</body></html>"));
15383 fragments.insert(
15384 "index.html".to_string(),
15385 FragmentList {
15386 fragments: entry,
15387 contains_boundary: true,
15388 },
15389 );
15390 fragments.insert(
15391 "comp-a".to_string(),
15392 FragmentList {
15393 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15394 contains_boundary: false,
15395 },
15396 );
15397 fragments.insert(
15398 "comp-b".to_string(),
15399 FragmentList {
15400 fragments: vec![WebUIFragment::raw("<b>B</b>")],
15401 contains_boundary: false,
15402 },
15403 );
15404
15405 let mut document = WebUIProtocol::new(fragments);
15406 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15407 document.components.insert(
15408 "comp-a".to_string(),
15409 webui_protocol::ComponentData {
15410 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
15411 hydration_mode: StateProjectionMode::Keys as i32,
15412 hydration_keys: vec!["a_count".to_string()],
15413 ..Default::default()
15414 },
15415 );
15416 document.components.insert(
15417 "comp-b".to_string(),
15418 webui_protocol::ComponentData {
15419 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
15420 hydration_mode: StateProjectionMode::Keys as i32,
15421 hydration_keys: vec!["b_count".to_string()],
15422 ..Default::default()
15423 },
15424 );
15425 if include_styles {
15426 document.set_css_strategy(webui_protocol::CssStrategy::Style);
15427 document
15428 .components
15429 .get_mut("comp-a")
15430 .expect("comp-a component")
15431 .css = ".comp-a{color:red}".to_string();
15432 document
15433 .components
15434 .get_mut("comp-b")
15435 .expect("comp-b component")
15436 .css = ".comp-b{color:blue}".to_string();
15437 document.populate_style_closures(&["index.html"]);
15438 }
15439 Protocol::new(document)
15440 }
15441
15442 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15443 disjoint_streaming_protocol_ext(hosts, true, false)
15444 }
15445
15446 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15447 disjoint_streaming_protocol_ext(hosts, true, true)
15448 }
15449
15450 fn streaming_root_validation_protocol(
15451 mut host_fragments: Vec<WebUIFragment>,
15452 tail: Vec<WebUIFragment>,
15453 ) -> Protocol {
15454 let mut entry = vec![
15455 WebUIFragment::raw("<html><head>"),
15456 structural_fragment("head_start"),
15457 structural_fragment("head_end"),
15458 WebUIFragment::raw("</head><body>"),
15459 structural_fragment("body_start"),
15460 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15461 ];
15462 entry.append(&mut host_fragments);
15463 entry.extend(tail);
15464 let fragments = HashMap::from([
15465 (
15466 "index.html".to_string(),
15467 FragmentList {
15468 fragments: entry,
15469 contains_boundary: true,
15470 },
15471 ),
15472 (
15473 "comp-a".to_string(),
15474 FragmentList {
15475 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15476 contains_boundary: false,
15477 },
15478 ),
15479 (
15480 "comp-b".to_string(),
15481 FragmentList {
15482 fragments: vec![WebUIFragment::raw("<b>B</b>")],
15483 contains_boundary: false,
15484 },
15485 ),
15486 ]);
15487 Protocol::new(WebUIProtocol::new(fragments))
15488 }
15489
15490 fn completed_streaming_tail() -> Vec<WebUIFragment> {
15491 vec![
15492 WebUIFragment::raw("</comp-a>"),
15493 WebUIFragment::boundary_end(0),
15494 structural_fragment("body_end"),
15495 WebUIFragment::raw("</body></html>"),
15496 ]
15497 }
15498
15499 fn assert_streaming_root_error(
15500 protocol: &Protocol,
15501 expected_signal: &str,
15502 expected_reason: &str,
15503 ) {
15504 let mut writer = FlushTestWriter::default();
15505 let result = WebUIHandler::new().render_streaming(
15506 protocol,
15507 &test_json!({}),
15508 &RenderOptions::new("index.html", "/"),
15509 &mut writer,
15510 );
15511 match result {
15512 Err(HandlerError::StreamingBoundary(error)) => {
15513 assert_eq!(error.signal, expected_signal);
15514 assert!(
15515 error.reason.contains(expected_reason),
15516 "reason: {}",
15517 error.reason
15518 );
15519 }
15520 other => panic!("expected streaming-root rejection, got {other:?}"),
15521 }
15522 }
15523
15524 #[test]
15525 fn streaming_root_signal_injects_data_ws_inside_boundary() {
15526 let protocol = disjoint_streaming_protocol(&["comp-a"]);
15527 let handler = WebUIHandler::with_plugin(|| {
15528 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15529 });
15530 let mut writer = FlushTestWriter::default();
15531 handler
15532 .render_streaming(
15533 &protocol,
15534 &test_json!({ "a_count": 1 }),
15535 &RenderOptions::new("index.html", "/"),
15536 &mut writer,
15537 )
15538 .unwrap();
15539 assert!(
15542 writer.output.contains("<comp-a data-ws>"),
15543 "streamed host must carry data-ws: {}",
15544 writer.output
15545 );
15546 }
15547
15548 #[test]
15549 fn streaming_root_signal_preserves_ordinary_output_bytes() {
15550 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15553 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15554 let state = test_json!({ "a_count": 1, "b_count": 2 });
15555 let plugin = || {
15556 WebUIHandler::with_plugin(
15557 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15558 )
15559 };
15560 let mut with_writer = TestWriter::new();
15561 let mut without_writer = TestWriter::new();
15562 plugin()
15563 .render(
15564 &with_signal,
15565 &state,
15566 &RenderOptions::new("index.html", "/"),
15567 &mut with_writer,
15568 )
15569 .unwrap();
15570 plugin()
15571 .render(
15572 &without_signal,
15573 &state,
15574 &RenderOptions::new("index.html", "/"),
15575 &mut without_writer,
15576 )
15577 .unwrap();
15578 let dom_prefix = |content: &str| -> String {
15583 content
15584 .split_once(r#"<script type="application/json" id="webui-data""#)
15585 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15586 };
15587 assert_eq!(
15588 dom_prefix(&with_writer.get_content()),
15589 dom_prefix(&without_writer.get_content())
15590 );
15591 assert!(!with_writer.get_content().contains("data-ws"));
15592 assert!(with_writer.get_content().contains("<comp-a>"));
15593 }
15594
15595 #[test]
15596 fn streaming_component_without_root_signal_is_rejected() {
15597 let protocol = streaming_root_validation_protocol(
15598 vec![
15599 WebUIFragment::raw("<comp-a>"),
15600 WebUIFragment::component("comp-a"),
15601 ],
15602 completed_streaming_tail(),
15603 );
15604
15605 assert_streaming_root_error(
15606 &protocol,
15607 "comp-a",
15608 "has no compiler-owned streaming_root signal",
15609 );
15610 }
15611
15612 #[test]
15613 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15614 let duplicate = streaming_root_validation_protocol(
15615 vec![
15616 WebUIFragment::raw("<comp-a"),
15617 structural_fragment("streaming_root:comp-a"),
15618 structural_fragment("streaming_root:comp-a"),
15619 WebUIFragment::raw(">"),
15620 WebUIFragment::component("comp-a"),
15621 ],
15622 completed_streaming_tail(),
15623 );
15624 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15625
15626 let after_tag_close = streaming_root_validation_protocol(
15627 vec![
15628 WebUIFragment::raw("<comp-a>"),
15629 structural_fragment("streaming_root:comp-a"),
15630 WebUIFragment::component("comp-a"),
15631 ],
15632 completed_streaming_tail(),
15633 );
15634 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15635
15636 let detached_from_opening = streaming_root_validation_protocol(
15637 vec![
15638 WebUIFragment::raw("not an opening tag"),
15639 structural_fragment("streaming_root:comp-a"),
15640 WebUIFragment::raw(">"),
15641 WebUIFragment::component("comp-a"),
15642 ],
15643 completed_streaming_tail(),
15644 );
15645 assert_streaming_root_error(
15646 &detached_from_opening,
15647 "comp-a",
15648 "unclosed component opening-tag close",
15649 );
15650 }
15651
15652 #[test]
15653 fn mismatched_streaming_root_signal_is_rejected() {
15654 let protocol = streaming_root_validation_protocol(
15655 vec![
15656 WebUIFragment::raw("<comp-a"),
15657 structural_fragment("streaming_root:comp-b"),
15658 WebUIFragment::raw(">"),
15659 WebUIFragment::component("comp-a"),
15660 ],
15661 completed_streaming_tail(),
15662 );
15663
15664 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15665 }
15666
15667 #[test]
15668 fn pending_streaming_root_is_rejected_at_structural_ends() {
15669 let protocol = streaming_root_validation_protocol(
15670 vec![
15671 WebUIFragment::raw("<comp-a"),
15672 structural_fragment("streaming_root:comp-a"),
15673 WebUIFragment::raw(">"),
15674 ],
15675 vec![structural_fragment("boundary_end:0")],
15676 );
15677 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15678 }
15679
15680 #[test]
15681 fn streaming_root_outside_boundary_is_rejected() {
15682 let fragments = HashMap::from([
15683 (
15684 "index.html".to_string(),
15685 FragmentList {
15686 fragments: vec![
15687 WebUIFragment::raw("<html><head>"),
15688 structural_fragment("head_start"),
15689 structural_fragment("head_end"),
15690 WebUIFragment::raw("</head><body>"),
15691 structural_fragment("body_start"),
15692 WebUIFragment::raw("<comp-a"),
15693 structural_fragment("streaming_root:comp-a"),
15694 WebUIFragment::raw(">"),
15695 WebUIFragment::component("comp-a"),
15696 WebUIFragment::raw("</comp-a>"),
15697 structural_fragment("body_end"),
15698 WebUIFragment::raw("</body></html>"),
15699 ],
15700 contains_boundary: false,
15701 },
15702 ),
15703 (
15704 "comp-a".to_string(),
15705 FragmentList {
15706 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15707 contains_boundary: false,
15708 },
15709 ),
15710 ]);
15711 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15712 let mut writer = FlushTestWriter::default();
15713 let result = WebUIHandler::new().render_streaming(
15714 &protocol,
15715 &test_json!({}),
15716 &RenderOptions::new("index.html", "/"),
15717 &mut writer,
15718 );
15719 match result {
15720 Err(HandlerError::StreamingBoundary(err)) => {
15721 assert_eq!(err.signal, "comp-a");
15722 assert!(
15723 err.reason
15724 .contains("no boundary or generated component span"),
15725 "reason: {}",
15726 err.reason
15727 );
15728 }
15729 other => panic!("expected outside-boundary rejection, got {other:?}"),
15730 }
15731 }
15732
15733 fn parser_route_protocol(with_boundary: bool) -> Protocol {
15734 let mut parser = HtmlParser::with_options(DomStrategy::Light);
15735 parser
15736 .component_registry_mut()
15737 .register_component(ComponentRegistration::new(
15738 "route-page",
15739 "<p>route content</p>",
15740 None,
15741 true,
15742 ))
15743 .expect("register route component");
15744 let route = r#"<route path="/" component="route-page" exact />"#;
15745 let html = if with_boundary {
15746 format!(
15747 "<html><head></head><body>\
15748 <boundary name=\"route\">{route}</boundary>\
15749 </body></html>"
15750 )
15751 } else {
15752 format!("<html><head></head><body>{route}</body></html>")
15753 };
15754 parser
15755 .parse("index.html", &html)
15756 .expect("parse route streaming fixture");
15757 let mut document = WebUIProtocol::new(parser.into_fragment_records());
15758 document.components.insert(
15759 "route-page".to_string(),
15760 webui_protocol::ComponentData::default(),
15761 );
15762 Protocol::new(document)
15763 }
15764
15765 #[test]
15766 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15767 let protocol = parser_route_protocol(true);
15768 let mut writer = FlushTestWriter::default();
15769 WebUIHandler::new()
15770 .render_streaming(
15771 &protocol,
15772 &test_json!({}),
15773 &RenderOptions::new("index.html", "/"),
15774 &mut writer,
15775 )
15776 .unwrap();
15777
15778 assert!(
15779 writer
15780 .output
15781 .contains("<route-page data-ws><p>route content</p></route-page>"),
15782 "matched route host must be deferred before upgrade: {}",
15783 writer.output
15784 );
15785 }
15786
15787 #[test]
15788 fn streaming_parser_route_host_outside_boundary_is_rejected() {
15789 let protocol = parser_route_protocol(false);
15790 let mut writer = FlushTestWriter::default();
15791 let result = WebUIHandler::new().render_streaming(
15792 &protocol,
15793 &test_json!({}),
15794 &RenderOptions::new("index.html", "/"),
15795 &mut writer,
15796 );
15797
15798 match result {
15799 Err(HandlerError::StreamingBoundary(error)) => {
15800 assert_eq!(error.signal, "route-page");
15801 assert!(
15802 error
15803 .reason
15804 .contains("no boundary or generated component span"),
15805 "reason: {}",
15806 error.reason
15807 );
15808 }
15809 other => panic!("expected route boundary rejection, got {other:?}"),
15810 }
15811 assert!(
15812 !writer.output.contains("<route-page>"),
15813 "an unmarked custom-element opening must never be completed"
15814 );
15815 }
15816
15817 #[test]
15818 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15819 let entry = vec![
15820 WebUIFragment::raw("<html><head>"),
15821 structural_fragment("head_start"),
15822 structural_fragment("head_end"),
15823 WebUIFragment::raw("</head><body>"),
15824 structural_fragment("body_start"),
15825 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15826 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15827 path: "/account".into(),
15828 fragment_id: "account-shell".into(),
15829 exact: false,
15830 ..Default::default()
15831 }),
15832 WebUIFragment::boundary_end(0),
15833 structural_fragment("body_end"),
15834 WebUIFragment::raw("</body></html>"),
15835 ];
15836 let fragments = HashMap::from([
15837 (
15838 "index.html".to_string(),
15839 FragmentList {
15840 fragments: entry,
15841 contains_boundary: true,
15842 },
15843 ),
15844 (
15845 "account-shell".to_string(),
15846 FragmentList {
15847 fragments: vec![WebUIFragment::route_from(
15848 webui_protocol::WebUiFragmentRoute {
15849 path: "./details".into(),
15850 fragment_id: "details-page".into(),
15851 exact: true,
15852 pending_component: "details-loading".into(),
15853 error_component: "details-error".into(),
15854 ..Default::default()
15855 },
15856 )],
15857 contains_boundary: false,
15858 },
15859 ),
15860 (
15861 "details-page".to_string(),
15862 FragmentList {
15863 fragments: vec![WebUIFragment::raw("<p>details</p>")],
15864 contains_boundary: false,
15865 },
15866 ),
15867 (
15868 "details-loading".to_string(),
15869 FragmentList {
15870 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15871 contains_boundary: false,
15872 },
15873 ),
15874 (
15875 "details-error".to_string(),
15876 FragmentList {
15877 fragments: vec![WebUIFragment::raw("<p>error</p>")],
15878 contains_boundary: false,
15879 },
15880 ),
15881 ]);
15882 let mut document = WebUIProtocol::new(fragments);
15883 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15884 for name in [
15885 "account-shell",
15886 "details-page",
15887 "details-loading",
15888 "details-error",
15889 ] {
15890 document.components.insert(
15891 name.to_string(),
15892 webui_protocol::ComponentData {
15893 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15894 ..Default::default()
15895 },
15896 );
15897 }
15898 let protocol = Protocol::new(document);
15899 let handler = WebUIHandler::with_plugin(|| {
15900 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15901 });
15902 let mut writer = FlushTestWriter::default();
15903
15904 handler
15905 .render_streaming(
15906 &protocol,
15907 &test_json!({}),
15908 &RenderOptions::new("index.html", "/account/details"),
15909 &mut writer,
15910 )
15911 .unwrap();
15912
15913 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15914 assert!(
15915 first.contains(r#""details-loading":"#),
15916 "relative-route pending metadata missing: {first}"
15917 );
15918 assert!(
15919 first.contains(r#""details-error":"#),
15920 "relative-route error metadata missing: {first}"
15921 );
15922 }
15923
15924 #[test]
15925 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15926 let entry = vec![
15927 WebUIFragment::raw("<html><head>"),
15928 structural_fragment("head_start"),
15929 structural_fragment("head_end"),
15930 WebUIFragment::raw("</head><body>"),
15931 structural_fragment("body_start"),
15932 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15933 WebUIFragment::raw("<static-shell"),
15934 structural_fragment("streaming_root:static-shell"),
15935 WebUIFragment::raw(">"),
15936 WebUIFragment::component("static-shell"),
15937 WebUIFragment::raw("</static-shell>"),
15938 WebUIFragment::raw("<route-shell"),
15939 structural_fragment("streaming_root:route-shell"),
15940 WebUIFragment::raw(">"),
15941 WebUIFragment::component("route-shell"),
15942 WebUIFragment::raw("</route-shell>"),
15943 WebUIFragment::boundary_end(0),
15944 structural_fragment("body_end"),
15945 WebUIFragment::raw("</body></html>"),
15946 ];
15947 let fragments = HashMap::from([
15948 (
15949 "index.html".to_string(),
15950 FragmentList {
15951 fragments: entry,
15952 contains_boundary: true,
15953 },
15954 ),
15955 (
15956 "static-shell".to_string(),
15957 FragmentList {
15958 fragments: vec![WebUIFragment::if_cond(
15959 ConditionExpr::identifier("show_hidden"),
15960 "static-hidden-if",
15961 )],
15962 contains_boundary: false,
15963 },
15964 ),
15965 (
15966 "static-hidden-if".to_string(),
15967 FragmentList {
15968 fragments: vec![WebUIFragment::component("static-hidden")],
15969 contains_boundary: false,
15970 },
15971 ),
15972 (
15973 "static-hidden".to_string(),
15974 FragmentList {
15975 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15976 contains_boundary: false,
15977 },
15978 ),
15979 (
15980 "route-shell".to_string(),
15981 FragmentList {
15982 fragments: vec![WebUIFragment::route_from(
15983 webui_protocol::WebUiFragmentRoute {
15984 path: "/account".into(),
15985 fragment_id: "route-page".into(),
15986 exact: true,
15987 ..Default::default()
15988 },
15989 )],
15990 contains_boundary: false,
15991 },
15992 ),
15993 (
15994 "route-page".to_string(),
15995 FragmentList {
15996 fragments: vec![WebUIFragment::raw("<p>route</p>")],
15997 contains_boundary: false,
15998 },
15999 ),
16000 ]);
16001 let mut document = WebUIProtocol::new(fragments);
16002 document.set_css_strategy(webui_protocol::CssStrategy::Link);
16003 document.initial_state_strategy = InitialStateStrategy::Components as i32;
16004 for (name, keys) in [
16005 ("static-shell", vec!["show_hidden", "static_count"]),
16006 ("static-hidden", vec!["hidden_count"]),
16007 ("route-shell", vec!["route_shell_count"]),
16008 ("route-page", vec!["route_count"]),
16009 ] {
16010 document.components.insert(
16011 name.to_string(),
16012 webui_protocol::ComponentData {
16013 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
16014 hydration_mode: StateProjectionMode::Keys as i32,
16015 hydration_keys: keys.into_iter().map(str::to_string).collect(),
16016 css_href: format!("/{name}.css"),
16017 ..Default::default()
16018 },
16019 );
16020 }
16021 document.populate_style_closures(&["index.html"]);
16022 let protocol = Protocol::new(document);
16023 assert_eq!(
16024 protocol
16025 .component_reachability()
16026 .is_route_dependent(protocol.component_index()["route-shell"]),
16027 Some(true),
16028 "fixture must exercise request-aware checkpoint reachability"
16029 );
16030 let handler = WebUIHandler::with_plugin(|| {
16031 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16032 });
16033 let mut writer = FlushTestWriter::default();
16034
16035 handler
16036 .render_streaming(
16037 &protocol,
16038 &test_json!({
16039 "show_hidden": false,
16040 "static_count": 1,
16041 "hidden_count": 2,
16042 "route_shell_count": 3,
16043 "route_count": 4,
16044 "unrelated": "private",
16045 }),
16046 &RenderOptions::new("index.html", "/account"),
16047 &mut writer,
16048 )
16049 .unwrap();
16050
16051 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
16052 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
16053 assert!(
16054 checkpoint.contains(&format!(r#""{template}":"#)),
16055 "{template} metadata missing: {checkpoint}"
16056 );
16057 }
16058 for state in [
16059 r#""show_hidden":false"#,
16060 r#""static_count":1"#,
16061 r#""hidden_count":2"#,
16062 r#""route_shell_count":3"#,
16063 r#""route_count":4"#,
16064 ] {
16065 assert!(
16066 checkpoint.contains(state),
16067 "{state} state missing: {checkpoint}"
16068 );
16069 }
16070 assert!(
16071 !checkpoint.contains("unrelated"),
16072 "checkpoint leaked unrelated state: {checkpoint}"
16073 );
16074 assert!(
16075 checkpoint.contains(
16076 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
16077 ),
16078 "checkpoint metadata lost source order: {checkpoint}"
16079 );
16080 assert!(
16081 checkpoint.contains(r#""componentStyles":"#)
16082 && checkpoint.contains(r#""strategy":"link""#)
16083 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
16084 "checkpoint is missing tree-local style metadata: {checkpoint}"
16085 );
16086 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
16087 assert_eq!(
16088 writer
16089 .output
16090 .matches(&format!(r#"data-webui-resource="{resource}""#))
16091 .count(),
16092 1,
16093 "Document delivery state must persist across streamed checkpoints"
16094 );
16095 }
16096 }
16097
16098 #[test]
16099 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
16100 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
16105 let handler = WebUIHandler::with_plugin(|| {
16106 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16107 });
16108 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
16109 let mut writer = FlushTestWriter::default();
16110 handler
16111 .render_streaming(
16112 &protocol,
16113 &state,
16114 &RenderOptions::new("index.html", "/"),
16115 &mut writer,
16116 )
16117 .unwrap();
16118
16119 assert_eq!(
16120 writer.flushes.len(),
16121 5,
16122 "bootstrap, three boundary commits, and one terminal"
16123 );
16124 let segment = |index: usize| -> &str {
16125 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16126 };
16127 let b0 = segment(0);
16128 let b1 = segment(1);
16129 let b2 = segment(2);
16130
16131 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
16133 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
16134 assert!(
16135 b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
16136 "b0: {b0}"
16137 );
16138 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
16139 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
16140 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
16141
16142 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
16144 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
16145 assert!(
16146 b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
16147 "b1: {b1}"
16148 );
16149 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
16150 assert!(
16151 !b1.contains(r#""templates":{"comp-a"#),
16152 "b1 re-sent comp-a: {b1}"
16153 );
16154 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
16155
16156 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
16158 assert!(
16159 b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
16160 "b2: {b2}"
16161 );
16162 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
16163 assert!(
16164 !b2.contains(r#""templates""#),
16165 "b2 re-sent a template: {b2}"
16166 );
16167
16168 assert!(!writer.output.contains("serverOnly"));
16170 }
16171
16172 #[test]
16173 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
16174 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
16175 let handler = WebUIHandler::with_plugin(|| {
16176 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16177 });
16178 let mut writer = FlushTestWriter::default();
16179 handler
16180 .render_streaming(
16181 &protocol,
16182 &test_json!({ "a_count": 1, "b_count": 2 }),
16183 &RenderOptions::new("index.html", "/"),
16184 &mut writer,
16185 )
16186 .unwrap();
16187
16188 let segment = |index: usize| -> &str {
16189 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16190 };
16191 let checkpoint_styles = |checkpoint: &str| -> Value {
16192 let script = checkpoint
16193 .rfind(r#"<script type="application/json" data-webui-boundary"#)
16194 .unwrap();
16195 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
16196 let payload_end =
16197 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
16198 let record: Value =
16199 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
16200 record[3]["componentStyles"].clone()
16201 };
16202
16203 let first = checkpoint_styles(segment(0));
16204 assert!(first["resources"]["comp-a"].is_object());
16205 assert!(first["resources"]["comp-b"].is_object());
16206 assert!(first["closures"]["index.html"].is_array());
16207 assert!(first["closures"]["comp-a"].is_array());
16208
16209 let second = checkpoint_styles(segment(1));
16210 assert_eq!(second["resources"], test_json!({}));
16211 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
16212
16213 let repeated = checkpoint_styles(segment(2));
16214 assert_eq!(repeated["resources"], test_json!({}));
16215 assert_eq!(repeated["closures"], test_json!({}));
16216 for resource in ["comp-a", "comp-b"] {
16217 assert_eq!(
16218 [&first, &second, &repeated]
16219 .iter()
16220 .filter(|styles| {
16221 styles["resources"]
16222 .as_object()
16223 .is_some_and(|resources| resources.contains_key(resource))
16224 })
16225 .count(),
16226 1,
16227 "{resource} style metadata was serialized more than once"
16228 );
16229 }
16230 }
16231
16232 #[test]
16233 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
16234 let entry = vec![
16235 WebUIFragment::raw("<html><head>"),
16236 structural_fragment("head_start"),
16237 structural_fragment("head_end"),
16238 WebUIFragment::raw("</head><body>"),
16239 structural_fragment("body_start"),
16240 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
16241 WebUIFragment::raw("<comp-a"),
16242 structural_fragment("streaming_root:comp-a"),
16243 WebUIFragment::raw(">"),
16244 WebUIFragment::component("comp-a"),
16245 WebUIFragment::raw("</comp-a>"),
16246 WebUIFragment::boundary_end(0),
16247 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
16248 WebUIFragment::raw("<comp-hidden"),
16249 structural_fragment("streaming_root:comp-hidden"),
16250 WebUIFragment::raw(">"),
16251 WebUIFragment::component("comp-hidden"),
16252 WebUIFragment::raw("</comp-hidden>"),
16253 WebUIFragment::boundary_end(1),
16254 structural_fragment("body_end"),
16255 WebUIFragment::raw("</body></html>"),
16256 ];
16257 let fragments = HashMap::from([
16258 (
16259 "index.html".to_string(),
16260 FragmentList {
16261 fragments: entry,
16262 contains_boundary: true,
16263 },
16264 ),
16265 (
16266 "comp-a".to_string(),
16267 FragmentList {
16268 fragments: vec![WebUIFragment::if_cond(
16269 ConditionExpr::identifier("show_hidden"),
16270 "hidden-if",
16271 )],
16272 contains_boundary: false,
16273 },
16274 ),
16275 (
16276 "hidden-if".to_string(),
16277 FragmentList {
16278 fragments: vec![WebUIFragment::component("comp-hidden")],
16279 contains_boundary: false,
16280 },
16281 ),
16282 (
16283 "comp-hidden".to_string(),
16284 FragmentList {
16285 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
16286 contains_boundary: false,
16287 },
16288 ),
16289 ]);
16290 let mut document = WebUIProtocol::new(fragments);
16291 document.set_css_strategy(webui_protocol::CssStrategy::Module);
16292 document.initial_state_strategy = InitialStateStrategy::Components as i32;
16293 document.components.insert(
16294 "comp-a".to_string(),
16295 webui_protocol::ComponentData {
16296 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
16297 hydration_mode: StateProjectionMode::Keys as i32,
16298 hydration_keys: vec!["show_hidden".to_string()],
16299 ..Default::default()
16300 },
16301 );
16302 document.components.insert(
16303 "comp-hidden".to_string(),
16304 webui_protocol::ComponentData {
16305 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
16306 css: ".hidden{display:block}".to_string(),
16307 hydration_mode: StateProjectionMode::Keys as i32,
16308 hydration_keys: vec!["hidden_count".to_string()],
16309 ..Default::default()
16310 },
16311 );
16312 document.populate_style_closures(&["index.html"]);
16313 let protocol = Protocol::new(document);
16314 let handler = WebUIHandler::with_plugin(|| {
16315 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16316 });
16317 let mut writer = FlushTestWriter::default();
16318
16319 handler
16320 .render_streaming(
16321 &protocol,
16322 &test_json!({
16323 "show_hidden": false,
16324 "hidden_count": 7,
16325 "unrelated": "private",
16326 }),
16327 &RenderOptions::new("index.html", "/"),
16328 &mut writer,
16329 )
16330 .unwrap();
16331
16332 let boundary = |index: usize| -> &str {
16333 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16334 };
16335 let first = boundary(0);
16336 let second = boundary(1);
16337
16338 assert!(first.contains(r#""comp-a":"#), "first: {first}");
16339 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
16340 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
16341 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
16342 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
16343 assert!(
16344 first.contains(r#"<script type="importmap""#),
16345 "first: {first}"
16346 );
16347 assert!(
16348 !first.contains("unrelated"),
16349 "first leaked unrelated state: {first}"
16350 );
16351
16352 assert!(
16353 !second.contains(r#""templates""#),
16354 "second re-sent metadata: {second}"
16355 );
16356 assert!(
16357 !second.contains(r#"<script type="importmap""#),
16358 "second re-sent CSS: {second}"
16359 );
16360 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
16361 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
16362 assert_eq!(
16363 writer
16364 .output
16365 .matches(r#"<script type="importmap">"#)
16366 .count(),
16367 1,
16368 "import-map output: {}",
16369 writer.output
16370 );
16371 }
16372
16373 #[test]
16374 fn streaming_flush_count_is_boundaries_plus_one() {
16375 for &count in &[1usize, 3, 10, 100] {
16376 let hosts: Vec<&str> = (0..count)
16377 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
16378 .collect();
16379 let protocol = disjoint_streaming_protocol(&hosts);
16380 let handler = WebUIHandler::with_plugin(|| {
16381 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16382 });
16383 let mut writer = FlushTestWriter::default();
16384 handler
16385 .render_streaming(
16386 &protocol,
16387 &test_json!({ "a_count": 1, "b_count": 2 }),
16388 &RenderOptions::new("index.html", "/"),
16389 &mut writer,
16390 )
16391 .unwrap();
16392 assert_eq!(
16393 writer.flushes.len(),
16394 count + 2,
16395 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
16396 );
16397 }
16398 }
16399
16400 #[test]
16401 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
16402 let mut fragments = HashMap::new();
16403 fragments.insert("comp-a".to_string(), FragmentList::default());
16404 fragments.insert("comp-b".to_string(), FragmentList::default());
16405 let mut protocol = WebUIProtocol::new(fragments);
16406 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
16407 protocol.components.insert(
16408 "comp-a".to_string(),
16409 webui_protocol::ComponentData {
16410 hydration_mode: StateProjectionMode::Keys as i32,
16411 hydration_keys: vec!["a".to_string(), "shared".to_string()],
16412 ..Default::default()
16413 },
16414 );
16415 protocol.components.insert(
16416 "comp-b".to_string(),
16417 webui_protocol::ComponentData {
16418 hydration_mode: StateProjectionMode::Keys as i32,
16419 hydration_keys: vec!["b".to_string(), "shared".to_string()],
16420 ..Default::default()
16421 },
16422 );
16423
16424 let protocol = Protocol::new(protocol);
16425 let component_index = protocol.component_index();
16426 let reachability = protocol.component_reachability();
16427 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16428 assert!(!collect_hydration_key_ids_into(
16429 protocol.protocol(),
16430 reachability,
16431 [component_index["comp-a"]],
16432 &mut scratch,
16433 ));
16434 let keys = scratch
16435 .iter()
16436 .filter_map(|id| reachability.hydration_key(*id))
16437 .collect::<Vec<_>>();
16438 assert_eq!(keys, ["a", "shared"]);
16439 let pointer = scratch.as_ptr();
16440 let capacity = scratch.capacity();
16441
16442 assert!(!collect_hydration_key_ids_into(
16443 protocol.protocol(),
16444 reachability,
16445 [component_index["comp-b"]],
16446 &mut scratch,
16447 ));
16448 let keys = scratch
16449 .iter()
16450 .filter_map(|id| reachability.hydration_key(*id))
16451 .collect::<Vec<_>>();
16452 assert_eq!(keys, ["b", "shared"]);
16453 assert_eq!(scratch.as_ptr(), pointer);
16454 assert_eq!(scratch.capacity(), capacity);
16455 }
16456}