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 render_capacity::RenderCapacityHints;
21#[doc(hidden)]
22pub use response_writer::{
23 append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
24 append_boolean_attribute_to_string,
25};
26pub use route_handler::Protocol;
27
28pub use html_encode::encode_safe;
38
39use plugin::BootstrapExtensionContext;
40use plugin::HandlerPlugin;
41use plugin::WebUiTemplatePayload;
42use route_handler::ComponentReachabilityIndex;
43use route_matcher::CompiledRouteIndex;
44use serde::ser::SerializeMap;
45use serde::Serialize;
46use serde_json::Value;
47use std::borrow::Cow;
48use std::cell::{Cell, OnceCell};
49use std::collections::{HashMap, HashSet};
50use std::fmt::{self, Write as _};
51use std::hash::{BuildHasherDefault, Hasher};
52use std::sync::{Arc, LazyLock};
53use streaming::{
54 consume_streaming_component_root, ensure_no_pending_streaming_root,
55 prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
56 validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
57 StreamingRenderState,
58};
59pub use streaming::{
60 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
61 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
62 MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
63};
64use thiserror::Error;
65use webui_expressions::{evaluate_with_resolver, ExpressionError};
66use webui_protocol::{
67 web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
68 InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
69};
70use webui_state::find_value_by_dotted_path_ref;
71
72#[derive(Debug, Error)]
74pub enum HandlerError {
75 #[error("Rendering error: {0}")]
76 Rendering(String),
77
78 #[error("invalid state JSON: {0}")]
84 InvalidState(String),
85
86 #[error("Rendering invariant error: {0}")]
87 Invariant(String),
88
89 #[error("Missing fragment: {0}")]
90 MissingFragment(String),
91
92 #[error("Missing data field: {0}")]
93 MissingData(String),
94
95 #[error("Type error: {0}")]
96 TypeError(String),
97
98 #[error("Protocol error: {0}")]
99 Protocol(#[from] webui_protocol::ProtocolError),
100
101 #[error("Evaluation error: {0}")]
102 Evaluation(String),
103
104 #[error("I/O error: {0}")]
105 Io(#[from] std::io::Error),
106
107 #[error("Writer error: {0}")]
108 Writer(String),
109
110 #[error("Plugin data error: {0}")]
111 PluginData(String),
112
113 #[error("client disconnected")]
120 ClientDisconnected,
121
122 #[error("streaming flush timed out")]
129 StreamTimeout,
130
131 #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
138 StreamingBoundary(Box<StreamingBoundaryError>),
139
140 #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
142 MissingStreamingBodyEnd,
143
144 #[error(
146 "streaming protocol is missing the required `head_start` signal before `{before}`; \
147 rebuild the protocol with streaming-boundary parser support"
148 )]
149 MissingStreamingHeadStart {
150 before: &'static str,
152 },
153
154 #[error("streaming protocol emitted duplicate `head_start` signals")]
156 DuplicateStreamingHeadStart,
157}
158
159#[derive(Debug)]
166pub struct StreamingBoundaryError {
167 pub signal: String,
169 pub reason: String,
171}
172
173pub type Result<T> = std::result::Result<T, HandlerError>;
174
175#[cold]
176#[inline(never)]
177fn invalid_fragment_range_error(
178 range: &std::ops::Range<usize>,
179 fragment_count: usize,
180) -> HandlerError {
181 HandlerError::Invariant(format!(
182 "fragment range {}..{} exceeds fragment count {fragment_count}",
183 range.start, range.end
184 ))
185}
186
187#[cold]
188#[inline(never)]
189fn route_style_plan_missing_error() -> HandlerError {
190 HandlerError::Invariant(
191 "matched route style targets were computed without a route chain".to_string(),
192 )
193}
194
195#[cold]
196#[inline(never)]
197fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
198 HandlerError::Invariant(format!(
199 "matched route style target count {targets} does not match route count {routes}"
200 ))
201}
202
203pub trait ResponseWriter {
205 fn write(&mut self, content: &str) -> Result<()>;
207
208 #[doc(hidden)]
210 fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
211 self.write(" ")?;
212 self.write(name)?;
213 self.write("=\"")?;
214 self.write(value)?;
215 self.write("\"")
216 }
217
218 #[doc(hidden)]
220 fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
221 self.write(" ")?;
222 self.write(name)
223 }
224
225 fn end(&mut self) -> Result<()>;
227
228 #[doc(hidden)]
236 fn stream_flush(&mut self) -> Result<()> {
237 Ok(())
238 }
239
240 #[doc(hidden)]
241 fn stream_begin_component(&mut self) -> Result<()> {
242 Err(HandlerError::Invariant(
243 "component opening buffering requires a streaming sink".to_string(),
244 ))
245 }
246
247 #[doc(hidden)]
248 fn stream_mark_component_root(&mut self) -> Result<()> {
249 Err(HandlerError::Invariant(
250 "component root buffering requires a streaming sink".to_string(),
251 ))
252 }
253
254 #[doc(hidden)]
255 fn stream_commit_component(
256 &mut self,
257 span_id: Option<u32>,
258 enclosing_span_id: Option<u32>,
259 deferred: bool,
260 ) -> Result<()> {
261 let _ = (span_id, enclosing_span_id, deferred);
262 Err(HandlerError::Invariant(
263 "component opening commit requires a streaming sink".to_string(),
264 ))
265 }
266}
267
268struct StateWithoutSelectedKeys<'a> {
274 value: &'a Value,
275 keys: KeyView<'a>,
276}
277
278impl Serialize for StateWithoutSelectedKeys<'_> {
279 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
280 where
281 S: serde::Serializer,
282 {
283 let Value::Object(map) = self.value else {
284 return serializer.serialize_map(Some(0))?.end();
285 };
286 let mut out = serializer.serialize_map(None)?;
287 for (key, value) in map {
288 if key != STATE_INJECT_KEY && !self.keys.contains(key) {
289 out.serialize_entry(key, value)?;
290 }
291 }
292 out.end()
293 }
294}
295
296pub trait FlushWriter: ResponseWriter {
301 fn flush(&mut self) -> Result<()>;
303}
304
305pub struct RenderOptions<'a> {
310 pub entry_id: &'a str,
312 pub request_path: &'a str,
314 pub nonce: Option<&'a str>,
318 pub head_inject: Option<&'a str>,
326 pub body_inject: Option<&'a str>,
331}
332
333pub const STATE_INJECT_KEY: &str = "$webui";
349
350#[derive(Clone, Copy, Default)]
357pub(crate) struct StateInject<'state> {
358 pub(crate) head_end: Option<&'state str>,
359 pub(crate) body_start: Option<&'state str>,
360 pub(crate) body_end: Option<&'state str>,
361}
362
363impl<'state> StateInject<'state> {
364 pub(crate) fn resolve(state: &'state Value) -> Self {
371 let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
372 return Self::default();
373 };
374 let field = |name: &str| {
375 map.get(name)
376 .and_then(Value::as_str)
377 .filter(|html| !html.is_empty())
378 };
379 Self {
380 head_end: field("headEnd"),
381 body_start: field("bodyStart"),
382 body_end: field("bodyEnd"),
383 }
384 }
385}
386
387impl<'a> RenderOptions<'a> {
388 #[must_use]
390 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
391 Self {
392 entry_id,
393 request_path,
394 nonce: None,
395 head_inject: None,
396 body_inject: None,
397 }
398 }
399
400 #[must_use]
404 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
405 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
406 self
407 }
408
409 #[must_use]
424 pub fn with_head_inject(mut self, html: &'a str) -> Self {
425 self.head_inject = if html.is_empty() { None } else { Some(html) };
426 self
427 }
428
429 #[must_use]
439 pub fn with_body_inject(mut self, html: &'a str) -> Self {
440 self.body_inject = if html.is_empty() { None } else { Some(html) };
441 self
442 }
443}
444
445pub struct WebUIHandler {
450 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
451}
452
453#[derive(Clone, Copy, Eq, PartialEq)]
454enum StyleClosureInstall {
455 Static,
456 Routed,
457}
458
459pub(crate) struct ShadowStyleRoot {
460 component_index: u32,
461 static_closure_emitted: bool,
462 routed_resources: Vec<u32>,
463}
464
465struct LoopBinding<'protocol, 'state> {
466 name: &'protocol str,
467 value: &'state Value,
468}
469
470#[derive(Clone, Copy)]
471struct VisibleLoopScope {
472 start: usize,
473 end: usize,
474}
475
476impl VisibleLoopScope {
477 const EMPTY: Self = Self { start: 0, end: 0 };
478}
479
480pub(crate) type ScopeMap = HashMap<String, Value, BuildHasherDefault<ScopeKeyHasher>>;
482
483#[derive(Clone, Copy, Default)]
490pub(crate) struct ScopeKeyHasher(u64);
491
492impl ScopeKeyHasher {
493 const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
494
495 #[inline]
496 fn add_word(&mut self, word: u64) {
497 self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(Self::SEED);
498 }
499}
500
501impl Hasher for ScopeKeyHasher {
502 #[inline]
503 fn write(&mut self, bytes: &[u8]) {
504 let (words, tail) = bytes.as_chunks::<8>();
505 for word in words {
506 self.add_word(u64::from_le_bytes(*word));
507 }
508 if !tail.is_empty() {
509 let mut word = [0u8; 8];
510 word[..tail.len()].copy_from_slice(tail);
511 self.add_word(u64::from_le_bytes(word));
512 }
513 }
514
515 #[inline]
516 fn write_u8(&mut self, byte: u8) {
517 self.add_word(u64::from(byte));
518 }
519
520 #[inline]
521 fn finish(&self) -> u64 {
522 self.0
523 }
524}
525
526#[derive(Clone, Copy)]
527struct LocalValueSources<'ctx, 'protocol, 'state> {
528 owned: &'ctx ScopeMap,
529 borrowed: &'ctx BorrowedScope<'protocol, 'state>,
530}
531
532pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
534
535#[derive(Default)]
536struct BorrowedScope<'protocol, 'state> {
537 inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
538 inline_len: usize,
539 overflow: Vec<(&'protocol str, &'state Value)>,
540}
541
542impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
543 fn get(&self, name: &str) -> Option<&'state Value> {
544 if self.inline_len == 0 {
545 return None;
546 }
547 if let Some((entry_name, value)) = self.inline[0].as_ref() {
548 if *entry_name == name {
549 return Some(*value);
550 }
551 }
552 for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
553 if *entry_name == name {
554 return Some(*value);
555 }
556 }
557 self.overflow
558 .iter()
559 .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
560 }
561
562 fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
563 for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
564 if *entry_name == name {
565 return Some(std::mem::replace(current, value));
566 }
567 }
568 for (entry_name, current) in &mut self.overflow {
569 if *entry_name == name {
570 return Some(std::mem::replace(current, value));
571 }
572 }
573 if self.inline_len < INLINE_SCOPE_SLOTS {
574 self.inline[self.inline_len] = Some((name, value));
575 self.inline_len += 1;
576 } else {
577 self.overflow.push((name, value));
578 }
579 None
580 }
581
582 fn remove(&mut self, name: &str) -> Option<&'state Value> {
583 if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
584 entry
585 .as_ref()
586 .is_some_and(|(entry_name, _)| *entry_name == name)
587 }) {
588 let removed = self.inline[index].take().map(|(_, value)| value);
589 if let Some(entry) = self.overflow.pop() {
590 self.inline[index] = Some(entry);
591 } else {
592 self.inline_len -= 1;
593 self.inline[index] = self.inline[self.inline_len].take();
594 }
595 return removed;
596 }
597 let index = self
598 .overflow
599 .iter()
600 .position(|(entry_name, _)| *entry_name == name)?;
601 Some(self.overflow.swap_remove(index).1)
602 }
603
604 fn clear(&mut self) {
605 for entry in &mut self.inline[..self.inline_len] {
606 *entry = None;
607 }
608 self.inline_len = 0;
609 self.overflow.clear();
610 }
611
612 fn clone_into_owned(&self, target: &mut ScopeMap) {
613 for (name, value) in self.inline[..self.inline_len].iter().flatten() {
614 target.insert((*name).to_owned(), (*value).clone());
615 }
616 for (name, value) in &self.overflow {
617 target.insert((*name).to_owned(), (*value).clone());
618 }
619 }
620}
621
622#[derive(Clone, Copy)]
628pub(crate) struct RenderFragmentList<'protocol> {
629 fragments: &'protocol [WebUIFragment],
630 metadata: &'protocol [RenderFragmentMetadata],
631 attr_names: &'protocol str,
632 contains_boundary: bool,
633 has_routes: bool,
636}
637
638impl<'protocol> RenderFragmentList<'protocol> {
639 fn target(self, index: usize) -> Option<usize> {
641 let target = self.metadata.get(index)?.target;
642 (target != NO_RENDER_SLOT).then_some(target as usize)
643 }
644
645 fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
647 let prepared = self.metadata.get(index)?;
648 if prepared.attr_start == NO_ATTR_NAME {
649 return None;
650 }
651 let start = prepared.attr_start as usize;
652 self.attr_names
653 .get(start..start + prepared.attr_len as usize)
654 }
655}
656
657const NO_RENDER_SLOT: u32 = u32::MAX;
659const NO_ATTR_NAME: u32 = u32::MAX;
661
662#[derive(Clone, Copy)]
667struct RenderFragmentMetadata {
668 target: u32,
672 attr_start: u32,
674 attr_len: u32,
675}
676
677pub(crate) struct RenderFragmentIndex {
684 ids: Box<[Arc<str>]>,
685 metadata: Box<[RenderFragmentMetadata]>,
686 ranges: Box<[u32]>,
688 attr_names: Box<str>,
690 route_presence: Box<[u64]>,
692}
693
694pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
699 cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
700 spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
704 index: &'protocol RenderFragmentIndex,
705 protocol: &'protocol WebUIProtocol,
706}
707
708impl RenderFragmentIndex {
709 pub(crate) fn new(
716 protocol: &WebUIProtocol,
717 ids: &[Arc<str>],
718 slots: &HashMap<Arc<str>, u32>,
719 ) -> Self {
720 let total_fragments = protocol
721 .fragments
722 .values()
723 .map(|list| list.fragments.len())
724 .sum();
725 let mut metadata = Vec::with_capacity(total_fragments);
726 let mut ranges = Vec::with_capacity(ids.len() + 1);
727 let mut attr_names = String::with_capacity(total_fragments * 8);
730 let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
731
732 for (slot, id) in ids.iter().enumerate() {
733 #[allow(clippy::cast_possible_truncation)]
735 ranges.push(metadata.len() as u32);
736 let Some(list) = protocol.fragments.get(id.as_ref()) else {
737 continue;
738 };
739 let mut has_routes = false;
740 for fragment in &list.fragments {
741 let inner = fragment.fragment.as_ref();
742 if matches!(inner, Some(Fragment::Route(_))) {
743 has_routes = true;
744 }
745 let target = fragment_target_id(fragment)
746 .and_then(|target| slots.get(target).copied())
747 .unwrap_or(NO_RENDER_SLOT);
748 let (attr_start, attr_len) = match inner {
749 Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
753 #[allow(clippy::cast_possible_truncation)]
754 let start = attr_names.len() as u32;
755 let before = attr_names.len();
756 push_component_attr_name(&mut attr_names, component_attr_source(attribute));
757 #[allow(clippy::cast_possible_truncation)]
758 let len = (attr_names.len() - before) as u32;
759 (start, len)
760 }
761 _ => (NO_ATTR_NAME, 0),
762 };
763 metadata.push(RenderFragmentMetadata {
764 target,
765 attr_start,
766 attr_len,
767 });
768 }
769 if has_routes {
770 route_presence[slot / 64] |= 1u64 << (slot % 64);
771 }
772 }
773 #[allow(clippy::cast_possible_truncation)]
774 ranges.push(metadata.len() as u32);
775
776 Self {
777 ids: ids.to_vec().into_boxed_slice(),
778 metadata: metadata.into_boxed_slice(),
779 ranges: ranges.into_boxed_slice(),
780 attr_names: attr_names.into_boxed_str(),
781 route_presence: route_presence.into_boxed_slice(),
782 }
783 }
784
785 pub(crate) fn index(&self, id: &str) -> Option<usize> {
787 self.ids
788 .binary_search_by(|candidate| candidate.as_ref().cmp(id))
789 .ok()
790 }
791
792 pub(crate) fn resolve<'protocol>(
793 &'protocol self,
794 protocol: &'protocol WebUIProtocol,
795 ) -> ResolvedRenderFragmentIndex<'protocol> {
796 ResolvedRenderFragmentIndex {
797 cache: std::array::from_fn(|_| Cell::new(None)),
798 spilled: OnceCell::new(),
799 index: self,
800 protocol,
801 }
802 }
803}
804
805impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
806 pub(crate) fn index(&self, id: &str) -> Option<usize> {
807 self.index.index(id)
808 }
809
810 fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
812 if index < INLINE_RENDER_FRAGMENT_LISTS {
813 return self.cache.get(index);
814 }
815 let spilled = self.spilled.get_or_init(|| {
816 let overflow = self
817 .index
818 .ids
819 .len()
820 .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
821 (0..overflow).map(|_| Cell::new(None)).collect()
822 });
823 spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
824 }
825
826 pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
827 let id = self.index.ids.get(index)?;
828 let fragment_list = match self.slot(index) {
829 Some(slot) => match slot.get() {
830 Some(cached) => cached,
831 None => {
832 let resolved = self.protocol.fragments.get(id.as_ref())?;
833 slot.set(Some(resolved));
834 resolved
835 }
836 },
837 None => self.protocol.fragments.get(id.as_ref())?,
838 };
839 let start = *self.index.ranges.get(index)? as usize;
840 let end = *self.index.ranges.get(index + 1)? as usize;
841 let metadata = self.index.metadata.get(start..end)?;
842 let has_routes = self
843 .index
844 .route_presence
845 .get(index / 64)
846 .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
847 Some(RenderFragmentList {
848 fragments: &fragment_list.fragments,
849 metadata,
850 attr_names: &self.index.attr_names,
851 contains_boundary: fragment_list.contains_boundary,
852 has_routes,
853 })
854 }
855
856 #[cfg(test)]
857 pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
858 self.index(id).and_then(|index| self.list(index))
859 }
860}
861
862fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
869 let takes_complex_branch = attribute.condition_tree.is_none()
870 && attribute.template.is_empty()
871 && !attribute.value.is_empty()
872 && !attribute.raw_value
873 && attribute.complex;
874 if takes_complex_branch {
875 attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
876 } else {
877 &attribute.name
878 }
879}
880
881fn descend_into<'protocol>(
887 children: &mut RouteChildren<'protocol>,
888 index: usize,
889) -> RouteChildren<'protocol> {
890 match children {
891 Cow::Borrowed(routes) => match routes.get(index) {
892 Some(route) => Cow::Borrowed(&route.children),
893 None => Cow::Borrowed(&[]),
894 },
895 Cow::Owned(routes) => match routes.get_mut(index) {
896 Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
897 None => Cow::Borrowed(&[]),
898 },
899 }
900}
901
902fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
904 match fragment.fragment.as_ref()? {
905 Fragment::Component(component) => Some(&component.fragment_id),
906 Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
907 Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
908 Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
909 Some(&attribute.template)
910 }
911 _ => None,
912 }
913}
914
915pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
917 pub(crate) protocol: &'protocol WebUIProtocol,
918 pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
922 pub(crate) component_asset_style_manifest: &'protocol str,
925 pub(crate) component_asset_style_links: &'protocol str,
927 pub(crate) state: &'state Value,
928 pub(crate) writer: &'output mut dyn ResponseWriter,
929 pub(crate) local_vars: ScopeMap,
930 local_borrowed_vars: BorrowedScope<'protocol, 'state>,
932 loop_vars: Vec<LoopBinding<'protocol, 'state>>,
934 visible_loop_scope: VisibleLoopScope,
937 pub(crate) component_attrs: ScopeMap,
939 component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
941 collecting_component_attrs: bool,
945 pub(crate) request_path: &'protocol str,
948 pub(crate) route_base: Cow<'protocol, str>,
953 pub(crate) rendered_components: HashSet<String>,
957 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
959 pub(crate) route_children: RouteChildren<'protocol>,
967 pub(crate) entry_id: &'protocol str,
970 pub(crate) nonce: Option<&'protocol str>,
973 pub(crate) component_index: &'protocol HashMap<String, u32>,
976 pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
978 pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
983 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
989 pub(crate) css_strategy: webui_protocol::CssStrategy,
991 pub(crate) head_inject: Option<&'protocol str>,
997 pub(crate) body_inject: Option<&'protocol str>,
1001 pub(crate) state_inject: StateInject<'state>,
1006 pub(crate) head_end_emitted: bool,
1013 pub(crate) body_start_emitted: bool,
1018 pub(crate) component_asset_styles_emitted: bool,
1021 pub(crate) body_end_emitted: bool,
1026 pub(crate) route_index: &'protocol CompiledRouteIndex,
1028 pub(crate) route_chain_index: usize,
1032 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
1035 pub(crate) route_document_style_targets: Vec<bool>,
1039 pub(crate) reachable_components: Option<Vec<String>>,
1045 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
1047 pub(crate) json_scratch: Vec<u8>,
1054 pub(crate) scope_pool: Vec<ScopeMap>,
1060 pub(crate) document_style_resources: HashSet<String>,
1063 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1066 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1068}
1069
1070pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1073
1074pub(crate) fn structural_signal_value(
1075 signal: &webui_protocol::WebUIFragmentSignal,
1076) -> Option<&str> {
1077 if !signal.raw {
1078 return None;
1079 }
1080 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1081}
1082
1083pub(crate) fn write_interaction_marker(
1084 tag: &str,
1085 context: &mut WebUIProcessContext<'_, '_, '_>,
1086) -> Result<()> {
1087 if matches!(
1088 context
1089 .protocol
1090 .components
1091 .get(tag)
1092 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1093 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1094 ) {
1095 context.writer.write(" data-webui-interaction")?;
1096 }
1097 Ok(())
1098}
1099
1100fn doctype_prefix_end(raw: &str) -> Option<usize> {
1103 const PREFIX: &[u8] = b"<!doctype";
1104
1105 let bytes = raw.as_bytes();
1106 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1107 while bytes
1108 .get(cursor)
1109 .is_some_and(|byte| byte.is_ascii_whitespace())
1110 {
1111 cursor += 1;
1112 }
1113
1114 let prefix_end = cursor.checked_add(PREFIX.len())?;
1115 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1116 return None;
1117 }
1118 let separator = *bytes.get(prefix_end)?;
1119 if separator != b'>' && !separator.is_ascii_whitespace() {
1120 return None;
1121 }
1122
1123 let mut quote = None;
1124 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1125 match quote {
1126 Some(active) if byte == active => quote = None,
1127 Some(_) => {}
1128 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1129 None if byte == b'>' => return Some(prefix_end + offset + 1),
1130 None => {}
1131 }
1132 }
1133 None
1134}
1135
1136const SCOPE_POOL_CAP: usize = 8;
1139const INLINE_SCOPE_SLOTS: usize = 4;
1141const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1152const INLINE_STYLE_UNITS: usize = 8;
1154const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1155
1156struct EmittedStyleUnits<'a> {
1162 inline: [&'a str; INLINE_STYLE_UNITS],
1163 inline_len: usize,
1164 overflow: HashSet<&'a str>,
1165}
1166
1167impl<'a> EmittedStyleUnits<'a> {
1168 fn new() -> Self {
1169 Self {
1170 inline: [""; INLINE_STYLE_UNITS],
1171 inline_len: 0,
1172 overflow: HashSet::new(),
1173 }
1174 }
1175
1176 fn insert(&mut self, name: &'a str) -> bool {
1178 if self.inline[..self.inline_len].contains(&name) {
1179 return false;
1180 }
1181 if self.inline_len < INLINE_STYLE_UNITS {
1182 self.inline[self.inline_len] = name;
1183 self.inline_len += 1;
1184 return true;
1185 }
1186 self.overflow.insert(name)
1187 }
1188}
1189
1190struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1191
1192impl Serialize for ComponentAssetStyleManifest<'_> {
1193 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1194 where
1195 S: serde::Serializer,
1196 {
1197 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1198 for entry in self.0 {
1199 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1200 }
1201 map.end()
1202 }
1203}
1204
1205fn serialize_component_asset_style_manifest(
1206 preloads: &[ComponentAssetStylePreload],
1207) -> std::result::Result<String, serde_json::Error> {
1208 if preloads.is_empty() {
1209 return Ok(String::new());
1210 }
1211 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1212 if json.contains("</") {
1213 Ok(json.replace("</", "<\\/"))
1214 } else {
1215 Ok(json)
1216 }
1217}
1218
1219fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1220 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1221 let mut seen = HashSet::with_capacity(href_count);
1222 let mut links = String::with_capacity(href_count.saturating_mul(48));
1223 for entry in preloads {
1224 for href in &entry.style_hrefs {
1225 if !seen.insert(href.as_str()) {
1226 continue;
1227 }
1228 links.push_str("<link rel=\"stylesheet\" href=\"");
1229 push_escaped_html_attribute(&mut links, href);
1230 links.push_str("\">");
1231 }
1232 }
1233 links
1234}
1235
1236fn push_escaped_html_attribute(output: &mut String, value: &str) {
1237 let mut start = 0;
1238 for (index, ch) in value.char_indices() {
1239 let escaped = match ch {
1240 '&' => Some("&"),
1241 '"' => Some("""),
1242 '<' => Some("<"),
1243 '>' => Some(">"),
1244 _ => None,
1245 };
1246 let Some(entity) = escaped else {
1247 continue;
1248 };
1249 if start < index {
1250 output.push_str(&value[start..index]);
1251 }
1252 output.push_str(entity);
1253 start = index + ch.len_utf8();
1254 }
1255 if start < value.len() {
1256 output.push_str(&value[start..]);
1257 }
1258}
1259
1260fn remove_owned_component_attr(attrs: &mut ScopeMap, name: &str) {
1265 if !attrs.is_empty() {
1266 attrs.remove(name);
1267 }
1268}
1269
1270fn take_scope_map(pool: &mut Vec<ScopeMap>) -> ScopeMap {
1273 pool.pop().unwrap_or_default()
1274}
1275
1276fn recycle_scope_map(pool: &mut Vec<ScopeMap>, mut map: ScopeMap) {
1280 if pool.len() < SCOPE_POOL_CAP {
1281 map.clear();
1282 pool.push(map);
1283 }
1284}
1285
1286fn take_borrowed_scope<'protocol, 'state>(
1287 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1288) -> BorrowedScope<'protocol, 'state> {
1289 pool.pop().unwrap_or_default()
1290}
1291
1292fn recycle_borrowed_scope<'protocol, 'state>(
1293 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1294 mut scope: BorrowedScope<'protocol, 'state>,
1295) {
1296 if pool.len() < SCOPE_POOL_CAP {
1297 scope.clear();
1298 pool.push(scope);
1299 }
1300}
1301
1302pub(crate) enum WebUiBootstrapState<'a> {
1303 Complete {
1304 value: &'a Value,
1305 selection: StateSelection<'a>,
1306 },
1307 Reference {
1308 record_sequence: usize,
1309 value: &'a Value,
1310 delta: Option<StateSelection<'a>>,
1311 },
1312}
1313
1314pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1315 pub(crate) declaration_id: Option<u32>,
1316 pub(crate) enclosing_span_instance_id: Option<u32>,
1317 pub(crate) state: WebUiBootstrapState<'a>,
1318 pub(crate) chain: &'a [Value],
1319 pub(crate) inventory: &'a str,
1320 pub(crate) nonce: Option<&'a str>,
1321 pub(crate) css_hrefs: &'a [&'a str],
1322 pub(crate) style_specs: &'a [&'a str],
1323 pub(crate) component_styles: &'a ComponentStyles,
1324 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1325}
1326
1327#[cfg(test)]
1337fn component_attr_name(name: &str) -> String {
1338 let mut buffer = String::new();
1339 push_component_attr_name(&mut buffer, name);
1340 buffer
1341}
1342
1343fn push_component_attr_name(buffer: &mut String, name: &str) {
1352 let stripped = name.strip_prefix(':').unwrap_or(name);
1353 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1358 buffer.push_str(property);
1359 return;
1360 }
1361 buffer.reserve(stripped.len());
1362 let mut capitalize_next = false;
1363 for ch in stripped.chars() {
1364 if ch == '-' {
1365 capitalize_next = true;
1366 } else if capitalize_next {
1367 buffer.extend(ch.to_uppercase());
1368 capitalize_next = false;
1369 } else {
1370 buffer.push(ch);
1371 }
1372 }
1373}
1374
1375#[cold]
1376#[inline(never)]
1377fn missing_component_attr_name_error() -> HandlerError {
1378 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1379}
1380
1381struct ScalarBuffer {
1389 buf: [u8; 48],
1390 len: usize,
1391}
1392
1393impl ScalarBuffer {
1394 #[inline]
1395 const fn new() -> Self {
1396 Self {
1397 buf: [0; 48],
1398 len: 0,
1399 }
1400 }
1401
1402 #[inline]
1403 fn as_str(&self) -> Option<&str> {
1404 std::str::from_utf8(&self.buf[..self.len]).ok()
1405 }
1406}
1407
1408impl fmt::Write for ScalarBuffer {
1409 #[inline]
1410 fn write_str(&mut self, s: &str) -> fmt::Result {
1411 let bytes = s.as_bytes();
1412 let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1413 let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1414 slot.copy_from_slice(bytes);
1415 self.len = end;
1416 Ok(())
1417 }
1418}
1419
1420fn format_plain_json_scalar<'buf>(
1427 buffer: &'buf mut ScalarBuffer,
1428 value: &Value,
1429) -> Option<&'buf str> {
1430 match value {
1431 Value::Bool(true) => Some("true"),
1432 Value::Bool(false) => Some("false"),
1433 Value::Null => Some("null"),
1434 Value::Number(number) => {
1435 write!(buffer, "{number}").ok()?;
1438 buffer.as_str()
1439 }
1440 _ => None,
1441 }
1442}
1443
1444fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1448 let mut buffer = ScalarBuffer::new();
1449 match format_plain_json_scalar(&mut buffer, value) {
1450 Some(rendered) => {
1451 writer.write(rendered)?;
1452 Ok(true)
1453 }
1454 None => Ok(false),
1455 }
1456}
1457
1458pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1460 if n == 0 {
1461 return writer.write("0");
1462 }
1463 let mut buf = [0u8; 20];
1465 let mut pos = buf.len();
1466 while n > 0 {
1467 pos -= 1;
1468 #[allow(clippy::cast_possible_truncation)]
1470 let digit = (n % 10) as u8;
1471 buf[pos] = b'0' + digit;
1472 n /= 10;
1473 }
1474 match std::str::from_utf8(&buf[pos..]) {
1476 Ok(s) => writer.write(s),
1477 Err(_) => writer.write("0"),
1478 }
1479}
1480
1481pub(crate) fn write_script_safe_json<T>(
1482 writer: &mut dyn ResponseWriter,
1483 scratch: &mut Vec<u8>,
1484 value: &T,
1485) -> Result<()>
1486where
1487 T: Serialize + ?Sized,
1488{
1489 scratch.clear();
1497 serde_json::to_writer(&mut *scratch, value)
1498 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1499 let json = std::str::from_utf8(scratch)
1500 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1501 write_script_safe_json_str(writer, json)
1502}
1503
1504static END_TAG_OPENER: LazyLock<memchr::memmem::Finder<'static>> =
1507 LazyLock::new(|| memchr::memmem::Finder::new(b"</"));
1508
1509fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1510 let bytes = json.as_bytes();
1514 let mut start = 0;
1515 if let Some(first) = memchr::memchr(b'<', bytes) {
1516 for offset in END_TAG_OPENER.find_iter(&bytes[first..]) {
1517 let index = first + offset;
1518 if index > start {
1519 writer.write(&json[start..index])?;
1520 }
1521 writer.write("<\\/")?;
1522 start = index + 2;
1523 }
1524 }
1525 if start < json.len() {
1526 writer.write(&json[start..])?;
1527 }
1528 Ok(())
1529}
1530
1531fn write_json_field_name(
1532 writer: &mut dyn ResponseWriter,
1533 wrote_field: &mut bool,
1534 name: &str,
1535) -> Result<()> {
1536 if *wrote_field {
1537 writer.write(",")?;
1538 }
1539 *wrote_field = true;
1540 writer.write("\"")?;
1541 writer.write(name)?;
1542 writer.write("\":")
1543}
1544
1545fn write_json_field<T>(
1546 writer: &mut dyn ResponseWriter,
1547 scratch: &mut Vec<u8>,
1548 wrote_field: &mut bool,
1549 name: &str,
1550 value: &T,
1551) -> Result<()>
1552where
1553 T: Serialize + ?Sized,
1554{
1555 write_json_field_name(writer, wrote_field, name)?;
1556 write_script_safe_json(writer, scratch, value)
1557}
1558
1559struct ProjectedState<'a> {
1577 value: &'a Value,
1578 keys: KeyView<'a>,
1579}
1580
1581impl Serialize for ProjectedState<'_> {
1582 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1583 where
1584 S: serde::Serializer,
1585 {
1586 let Value::Object(map) = self.value else {
1587 return serializer.serialize_map(Some(0))?.end();
1588 };
1589
1590 let mut out = serializer.serialize_map(None)?;
1591 if self.keys.len() < map.len() {
1592 let mut previous = None;
1593 for key in self.keys.iter() {
1594 if key == STATE_INJECT_KEY {
1595 continue;
1596 }
1597 if previous == Some(key) {
1598 continue;
1599 }
1600 previous = Some(key);
1601 if let Some(value) = map.get(key) {
1602 out.serialize_entry(key, value)?;
1603 }
1604 }
1605 } else {
1606 for (key, value) in map {
1607 if key == STATE_INJECT_KEY {
1608 continue;
1609 }
1610 if self.keys.contains(key.as_str()) {
1611 out.serialize_entry(key, value)?;
1612 }
1613 }
1614 }
1615 out.end()
1616 }
1617}
1618
1619struct StateWithoutReservedKey<'a> {
1640 value: &'a Value,
1641}
1642
1643impl Serialize for StateWithoutReservedKey<'_> {
1644 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1645 where
1646 S: serde::Serializer,
1647 {
1648 let Value::Object(map) = self.value else {
1649 return self.value.serialize(serializer);
1650 };
1651 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1652 for (key, value) in map {
1653 if key == STATE_INJECT_KEY {
1654 continue;
1655 }
1656 out.serialize_entry(key, value)?;
1657 }
1658 out.end()
1659 }
1660}
1661
1662fn write_full_state(
1667 writer: &mut dyn ResponseWriter,
1668 scratch: &mut Vec<u8>,
1669 state: &Value,
1670) -> Result<()> {
1671 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1672 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1673 }
1674 write_script_safe_json(writer, scratch, state)
1675}
1676
1677pub(crate) fn write_selected_state(
1678 writer: &mut dyn ResponseWriter,
1679 scratch: &mut Vec<u8>,
1680 state: &Value,
1681 selection: &StateSelection<'_>,
1682) -> Result<()> {
1683 let keys = match selection {
1684 StateSelection::Full => return write_full_state(writer, scratch, state),
1685 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1686 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1687 StateSelection::FullExceptKeyIds(selection) => {
1688 return write_script_safe_json(
1689 writer,
1690 scratch,
1691 &StateWithoutSelectedKeys {
1692 value: state,
1693 keys: KeyView::Ids(*selection),
1694 },
1695 );
1696 }
1697 };
1698 if keys.is_empty() {
1699 return writer.write("{}");
1700 }
1701
1702 debug_assert!(
1707 keys.iter()
1708 .zip(keys.iter().skip(1))
1709 .all(|(left, right)| left <= right),
1710 "hydration keys must be sorted for binary-search projection"
1711 );
1712 if let Value::Object(map) = state {
1713 let selects_entire_map =
1714 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1715 if selects_entire_map {
1716 return write_full_state(writer, scratch, state);
1717 }
1718 }
1719 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1720}
1721
1722pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1725
1726pub(crate) enum StateSelection<'a> {
1728 Full,
1730 Keys(Vec<&'a str>),
1732 KeyIds(HydrationKeySelection<'a>),
1739 FullExceptKeyIds(HydrationKeySelection<'a>),
1741}
1742
1743#[derive(Clone, Copy)]
1745pub(crate) struct HydrationKeySelection<'a> {
1746 pub(crate) ids: &'a [u32],
1747 pub(crate) index: &'a ComponentReachabilityIndex,
1748}
1749
1750impl<'a> HydrationKeySelection<'a> {
1751 fn key(self, position: usize) -> Option<&'a str> {
1752 let id = self.ids.get(position).copied()?;
1753 self.index.hydration_key(id)
1754 }
1755}
1756
1757#[derive(Clone, Copy)]
1759enum KeyView<'a> {
1760 Borrowed(&'a [&'a str]),
1761 Ids(HydrationKeySelection<'a>),
1762}
1763
1764impl<'a> KeyView<'a> {
1765 fn len(self) -> usize {
1766 match self {
1767 Self::Borrowed(keys) => keys.len(),
1768 Self::Ids(selection) => selection.ids.len(),
1769 }
1770 }
1771
1772 fn is_empty(self) -> bool {
1773 self.len() == 0
1774 }
1775
1776 fn get(self, position: usize) -> Option<&'a str> {
1777 match self {
1778 Self::Borrowed(keys) => keys.get(position).copied(),
1779 Self::Ids(selection) => selection.key(position),
1780 }
1781 }
1782
1783 fn iter(self) -> impl Iterator<Item = &'a str> {
1784 (0..self.len()).map_while(move |position| self.get(position))
1785 }
1786
1787 fn contains(self, key: &str) -> bool {
1790 match self {
1791 Self::Borrowed(keys) => keys
1792 .binary_search_by(|candidate| str::cmp(candidate, key))
1793 .is_ok(),
1794 Self::Ids(selection) => selection
1795 .ids
1796 .binary_search_by(|id| {
1797 selection
1798 .index
1799 .hydration_key(*id)
1800 .map_or(std::cmp::Ordering::Less, |candidate| {
1801 str::cmp(candidate, key)
1802 })
1803 })
1804 .is_ok(),
1805 }
1806 }
1807}
1808
1809#[derive(Clone, Copy)]
1810enum ComponentStateSurface {
1811 Hydration,
1812 Navigation,
1813}
1814
1815pub(crate) fn collect_hydration_state<'a, 'b>(
1821 protocol: &'a WebUIProtocol,
1822 components: impl IntoIterator<Item = &'b str>,
1823) -> StateSelection<'a> {
1824 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1825 return StateSelection::Full;
1826 }
1827 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1828}
1829
1830pub(crate) fn collect_hydration_key_ids_into(
1838 protocol: &WebUIProtocol,
1839 index: &ComponentReachabilityIndex,
1840 components: impl IntoIterator<Item = u32>,
1841 ids: &mut Vec<u32>,
1842) -> bool {
1843 ids.clear();
1844 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1845 return true;
1846 }
1847 for component in components {
1848 if index.extend_hydration_keys(component, ids) {
1849 ids.clear();
1850 return true;
1851 }
1852 }
1853 ids.sort_unstable();
1854 ids.dedup();
1855 false
1856}
1857
1858pub(crate) fn collect_navigation_state<'a, 'b>(
1860 protocol: &'a WebUIProtocol,
1861 components: impl IntoIterator<Item = &'b str>,
1862) -> StateSelection<'a> {
1863 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1864}
1865
1866fn collect_component_state<'a, 'b>(
1867 protocol: &'a WebUIProtocol,
1868 components: impl IntoIterator<Item = &'b str>,
1869 surface: ComponentStateSurface,
1870) -> StateSelection<'a> {
1871 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1872 if collect_component_state_into(protocol, components, surface, &mut keys) {
1873 StateSelection::Full
1874 } else {
1875 StateSelection::Keys(keys)
1876 }
1877}
1878
1879fn collect_component_state_into<'a, 'b>(
1882 protocol: &'a WebUIProtocol,
1883 components: impl IntoIterator<Item = &'b str>,
1884 surface: ComponentStateSurface,
1885 keys: &mut Vec<&'a str>,
1886) -> bool {
1887 keys.clear();
1888 for name in components {
1889 let Some(component) = protocol.components.get(name) else {
1890 return true;
1891 };
1892 let (mode, component_keys) = match surface {
1893 ComponentStateSurface::Hydration => {
1894 (component.hydration_mode, &component.hydration_keys)
1895 }
1896 ComponentStateSurface::Navigation => (
1897 match component.navigation_mode {
1898 Some(mode) => mode,
1899 None if !component.navigation_keys.is_empty() => {
1900 StateProjectionMode::Keys as i32
1901 }
1902 None => return true,
1903 },
1904 &component.navigation_keys,
1905 ),
1906 };
1907 if mode == StateProjectionMode::All as i32 {
1908 return true;
1909 }
1910 if mode == StateProjectionMode::Keys as i32
1911 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1912 {
1913 keys.extend(component_keys.iter().map(String::as_str));
1914 } else if mode != StateProjectionMode::None as i32 {
1915 return true;
1916 }
1917 }
1918 keys.sort_unstable();
1919 keys.dedup();
1920 false
1921}
1922
1923pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1924 writer: &mut dyn ResponseWriter,
1925 scratch: &mut Vec<u8>,
1926 bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1927) -> Result<()> {
1928 let mut wrote_field = false;
1929
1930 writer.write("{")?;
1931 if let Some(declaration_id) = bootstrap.declaration_id {
1932 write_json_field(
1933 writer,
1934 scratch,
1935 &mut wrote_field,
1936 "declarationId",
1937 &declaration_id,
1938 )?;
1939 }
1940 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1941 write_json_field(
1942 writer,
1943 scratch,
1944 &mut wrote_field,
1945 "enclosingSpanInstanceId",
1946 &span_id,
1947 )?;
1948 }
1949 if !bootstrap.chain.is_empty() {
1950 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1951 }
1952 write_json_field(
1955 writer,
1956 scratch,
1957 &mut wrote_field,
1958 "componentStyles",
1959 bootstrap.component_styles,
1960 )?;
1961 if !bootstrap.css_hrefs.is_empty() {
1962 write_json_field(
1963 writer,
1964 scratch,
1965 &mut wrote_field,
1966 "css",
1967 bootstrap.css_hrefs,
1968 )?;
1969 }
1970 write_json_field(
1971 writer,
1972 scratch,
1973 &mut wrote_field,
1974 "inventory",
1975 bootstrap.inventory,
1976 )?;
1977 if let Some(nonce) = bootstrap.nonce {
1978 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1979 }
1980 match bootstrap.state {
1981 WebUiBootstrapState::Complete { value, selection } => {
1982 write_json_field_name(writer, &mut wrote_field, "state")?;
1983 write_selected_state(writer, scratch, value, &selection)?;
1984 }
1985 WebUiBootstrapState::Reference {
1986 record_sequence,
1987 value,
1988 delta,
1989 } => {
1990 write_json_field(
1991 writer,
1992 scratch,
1993 &mut wrote_field,
1994 "stateRef",
1995 &record_sequence,
1996 )?;
1997 if let Some(delta) = delta {
1998 write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1999 write_selected_state(writer, scratch, value, &delta)?;
2000 }
2001 }
2002 }
2003 if !bootstrap.style_specs.is_empty() {
2004 write_json_field(
2005 writer,
2006 scratch,
2007 &mut wrote_field,
2008 "styles",
2009 bootstrap.style_specs,
2010 )?;
2011 }
2012 if bootstrap
2013 .templates
2014 .iter()
2015 .any(|template| !template.template_json.is_empty())
2016 {
2017 write_json_field_name(writer, &mut wrote_field, "templates")?;
2018 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
2019 }
2020 writer.write("}")
2021}
2022
2023fn write_webui_data_block(
2024 writer: &mut dyn ResponseWriter,
2025 scratch: &mut Vec<u8>,
2026 bootstrap: WebUiBootstrap<'_>,
2027) -> Result<()> {
2028 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
2029 if let Some(nonce) = bootstrap.nonce {
2030 writer.write(" nonce=\"")?;
2031 writer.write(nonce)?;
2032 writer.write("\"")?;
2033 }
2034
2035 writer.write(">")?;
2036 write_webui_bootstrap(writer, scratch, bootstrap)?;
2037 writer.write("</script>\n")
2038}
2039
2040fn write_component_asset_style_manifest(
2041 writer: &mut dyn ResponseWriter,
2042 manifest: &str,
2043 nonce: Option<&str>,
2044) -> Result<()> {
2045 if manifest.is_empty() {
2046 return Ok(());
2047 }
2048 writer.write("<script type=\"application/json\" id=\"")?;
2049 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
2050 writer.write("\"")?;
2051 if let Some(nonce) = nonce {
2052 writer.write(" nonce=\"")?;
2053 writer.write(&crate::html_encode::encode_safe(nonce))?;
2054 writer.write("\"")?;
2055 }
2056 writer.write(">")?;
2057 writer.write(manifest)?;
2058 writer.write("</script>")
2059}
2060
2061fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
2062 if context.component_asset_styles_emitted {
2063 return Ok(());
2064 }
2065 context.component_asset_styles_emitted = true;
2066 context.writer.write(context.component_asset_style_links)?;
2067 write_component_asset_style_manifest(
2068 context.writer,
2069 context.component_asset_style_manifest,
2070 context.nonce,
2071 )
2072}
2073
2074fn write_webui_template_json_map(
2075 writer: &mut dyn ResponseWriter,
2076 scratch: &mut Vec<u8>,
2077 templates: &[WebUiTemplatePayload<'_>],
2078) -> Result<()> {
2079 writer.write("{")?;
2080 let mut wrote = false;
2081 for template in templates {
2082 if template.template_json.is_empty() {
2083 continue;
2084 }
2085 if wrote {
2086 writer.write(",")?;
2087 }
2088 wrote = true;
2089 write_script_safe_json(writer, scratch, template.tag_name)?;
2090 writer.write(":")?;
2091 write_script_safe_json_str(writer, template.template_json)?;
2092 }
2093 writer.write("}")
2094}
2095
2096fn resolve_value_from_sources<'ctx, 'state>(
2097 path: &str,
2098 loop_vars: &'ctx [LoopBinding<'_, 'state>],
2099 visible_loop_scope: VisibleLoopScope,
2100 local_values: LocalValueSources<'ctx, '_, 'state>,
2101 state: &'state Value,
2102) -> Option<Cow<'ctx, Value>>
2103where
2104 'state: 'ctx,
2105{
2106 if let Some(binding) = loop_vars
2107 .get(visible_loop_scope.start..visible_loop_scope.end)
2108 .and_then(|bindings| bindings.last())
2109 {
2110 let name = binding.name;
2111 if path.len() == name.len() && path == name {
2112 return Some(Cow::Borrowed(binding.value));
2113 }
2114 if path.len() > name.len()
2115 && path.as_bytes().get(name.len()) == Some(&b'.')
2116 && path.starts_with(name)
2117 {
2118 if let Some(value) =
2119 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2120 {
2121 return Some(value);
2122 }
2123 return find_value_by_dotted_path_ref(path, state);
2124 }
2125 }
2126
2127 if let Some(first_part) = path.split('.').next() {
2128 if let Some(local_value) = local_values.borrowed.get(first_part) {
2129 if first_part.len() == path.len() {
2130 return Some(Cow::Borrowed(local_value));
2131 }
2132 let remaining = &path[first_part.len() + 1..];
2133 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2134 return Some(value);
2135 }
2136 return find_value_by_dotted_path_ref(path, state);
2137 }
2138 if let Some(local_value) = local_values.owned.get(first_part) {
2139 if first_part.len() == path.len() {
2140 return Some(Cow::Borrowed(local_value));
2141 }
2142 let remaining = &path[first_part.len() + 1..];
2143 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2144 return Some(value);
2145 }
2146 return find_value_by_dotted_path_ref(path, state);
2147 }
2148
2149 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2150 .iter()
2151 .rev()
2152 {
2153 if binding.name != first_part {
2154 continue;
2155 }
2156 if first_part.len() == path.len() {
2157 return Some(Cow::Borrowed(binding.value));
2158 }
2159 let remaining = &path[first_part.len() + 1..];
2160 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2161 return Some(value);
2162 }
2163 return find_value_by_dotted_path_ref(path, state);
2164 }
2165 }
2166
2167 find_value_by_dotted_path_ref(path, state)
2168}
2169
2170fn resolve_borrowed_collection<'state>(
2171 path: &str,
2172 loop_vars: &[LoopBinding<'_, 'state>],
2173 visible_loop_scope: VisibleLoopScope,
2174 local_values: LocalValueSources<'_, '_, 'state>,
2175 state: &'state Value,
2176) -> Option<&'state [Value]> {
2177 let first_part = path.split('.').next()?;
2178 if let Some(binding) = loop_vars
2179 .get(visible_loop_scope.start..visible_loop_scope.end)
2180 .and_then(|bindings| bindings.last())
2181 .filter(|binding| binding.name == first_part)
2182 {
2183 if first_part.len() == path.len() {
2184 return binding.value.as_array().map(Vec::as_slice);
2185 }
2186 let remaining = &path[first_part.len() + 1..];
2187 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2188 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2189 Some(Cow::Owned(_)) => None,
2190 None => borrowed_state_array(path, state),
2191 };
2192 }
2193 if let Some(local_value) = local_values.borrowed.get(first_part) {
2194 let local_match = if first_part.len() == path.len() {
2195 Some(Cow::Borrowed(local_value))
2196 } else {
2197 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2198 };
2199 return match local_match {
2200 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2201 Some(Cow::Owned(_)) => None,
2202 None => borrowed_state_array(path, state),
2203 };
2204 }
2205 if let Some(local_value) = local_values.owned.get(first_part) {
2206 let local_match = if first_part.len() == path.len() {
2207 Some(Cow::Borrowed(local_value))
2208 } else {
2209 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2210 };
2211 if local_match.is_some() {
2212 return None;
2213 }
2214 return borrowed_state_array(path, state);
2215 }
2216
2217 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2218 .iter()
2219 .rev()
2220 {
2221 if binding.name != first_part {
2222 continue;
2223 }
2224 if first_part.len() == path.len() {
2225 return binding.value.as_array().map(Vec::as_slice);
2226 }
2227 let remaining = &path[first_part.len() + 1..];
2228 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2229 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2230 Some(Cow::Owned(_)) => None,
2231 None => borrowed_state_array(path, state),
2232 };
2233 }
2234
2235 borrowed_state_array(path, state)
2236}
2237
2238fn resolve_state_backed_value<'state>(
2239 path: &str,
2240 loop_vars: &[LoopBinding<'_, 'state>],
2241 visible_loop_scope: VisibleLoopScope,
2242 local_values: LocalValueSources<'_, '_, 'state>,
2243 state: &'state Value,
2244) -> Option<&'state Value> {
2245 let first_part = path.split('.').next()?;
2246 if let Some(binding) = loop_vars
2247 .get(visible_loop_scope.start..visible_loop_scope.end)
2248 .and_then(|bindings| bindings.last())
2249 .filter(|binding| binding.name == first_part)
2250 {
2251 if first_part.len() == path.len() {
2252 return Some(binding.value);
2253 }
2254 let remaining = &path[first_part.len() + 1..];
2255 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2256 Some(Cow::Borrowed(value)) => Some(value),
2257 Some(Cow::Owned(_)) => None,
2258 None => borrowed_state_value(path, state),
2259 };
2260 }
2261 if let Some(local_value) = local_values.borrowed.get(first_part) {
2262 if first_part.len() == path.len() {
2263 return Some(local_value);
2264 }
2265 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2266 Some(Cow::Borrowed(value)) => Some(value),
2267 Some(Cow::Owned(_)) => None,
2268 None => borrowed_state_value(path, state),
2269 };
2270 }
2271 if let Some(local_value) = local_values.owned.get(first_part) {
2272 if first_part.len() == path.len() {
2273 return None;
2274 }
2275 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2276 Some(_) => None,
2277 None => borrowed_state_value(path, state),
2278 };
2279 }
2280
2281 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2282 .iter()
2283 .rev()
2284 {
2285 if binding.name != first_part {
2286 continue;
2287 }
2288 if first_part.len() == path.len() {
2289 return Some(binding.value);
2290 }
2291 let remaining = &path[first_part.len() + 1..];
2292 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2293 Some(Cow::Borrowed(value)) => Some(value),
2294 Some(Cow::Owned(_)) => None,
2295 None => borrowed_state_value(path, state),
2296 };
2297 }
2298
2299 borrowed_state_value(path, state)
2300}
2301
2302fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2303 match find_value_by_dotted_path_ref(path, state) {
2304 Some(Cow::Borrowed(value)) => Some(value),
2305 Some(Cow::Owned(_)) | None => None,
2306 }
2307}
2308
2309fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2310 match find_value_by_dotted_path_ref(path, state)? {
2311 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2312 Cow::Owned(_) => None,
2313 }
2314}
2315
2316impl WebUIHandler {
2317 pub fn new() -> Self {
2319 Self {
2320 plugin_factory: None,
2321 }
2322 }
2323
2324 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2329 Self {
2330 plugin_factory: Some(factory),
2331 }
2332 }
2333
2334 #[cfg(test)]
2335 fn handle(
2336 &self,
2337 document: &WebUIProtocol,
2338 state: &Value,
2339 options: &RenderOptions<'_>,
2340 writer: &mut dyn ResponseWriter,
2341 ) -> Result<()> {
2342 let protocol = Protocol::new(document.clone());
2343 self.render(&protocol, state, options, writer)
2344 }
2345
2346 fn process_fragment_target<'data>(
2352 &self,
2353 target: Option<usize>,
2354 fragment_id: &str,
2355 context: &mut WebUIProcessContext<'data, '_, '_>,
2356 ) -> Result<()> {
2357 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2358 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2359 };
2360 let Some(fragment_list) = context.render_fragments.list(index) else {
2361 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2362 };
2363 self.process_fragment(fragment_list, context)
2364 }
2365
2366 fn process_fragment<'data>(
2371 &self,
2372 fragment_list: RenderFragmentList<'data>,
2373 context: &mut WebUIProcessContext<'data, '_, '_>,
2374 ) -> Result<()> {
2375 self.process_fragment_from(fragment_list, 0, context)
2376 }
2377
2378 fn process_fragment_from<'data>(
2379 &self,
2380 fragment_list: RenderFragmentList<'data>,
2381 start: usize,
2382 context: &mut WebUIProcessContext<'data, '_, '_>,
2383 ) -> Result<()> {
2384 let fragments = fragment_list.fragments;
2385 let best_route = if fragment_list.has_routes {
2391 route_renderer::find_best_route_match(
2392 fragments,
2393 context.request_path,
2394 &context.route_base,
2395 context.route_index,
2396 )
2397 } else {
2398 None
2399 };
2400 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2401 }
2402
2403 fn process_fragment_range<'data>(
2404 &self,
2405 fragment_list: RenderFragmentList<'data>,
2406 range: std::ops::Range<usize>,
2407 best_route: &Option<(String, route_matcher::RouteMatch)>,
2408 context: &mut WebUIProcessContext<'data, '_, '_>,
2409 ) -> Result<()> {
2410 let fragments = fragment_list.fragments;
2411 let Some(selected) = fragments.get(range.clone()) else {
2412 return Err(invalid_fragment_range_error(&range, fragments.len()));
2413 };
2414 for (offset, item) in selected.iter().enumerate() {
2415 let index = range.start + offset;
2416 if context.streaming.is_some() {
2417 validate_pending_streaming_root(item, context)?;
2418 validate_streaming_root_opening(&fragments[..index], item)?;
2419 }
2420 match item.fragment.as_ref() {
2424 Some(Fragment::Raw(raw)) => {
2425 context.writer.write(&raw.value)?;
2426 }
2427 Some(Fragment::Component(component)) => {
2428 self.process_component(
2429 &component.fragment_id,
2430 fragment_list.target(index),
2431 ComponentHostOrigin::ParserProduced,
2432 context,
2433 )?;
2434 }
2435 Some(Fragment::ForLoop(for_loop)) => {
2436 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2437 }
2438 Some(Fragment::Signal(signal)) => {
2439 self.process_signal(signal, context)?;
2440 }
2441 Some(Fragment::IfCond(if_cond)) => {
2442 self.process_if(if_cond, fragment_list.target(index), context)?;
2443 }
2444 Some(Fragment::Attribute(attr)) => {
2445 self.process_attribute(
2446 attr,
2447 fragment_list.target(index),
2448 fragment_list.component_attr_name(index),
2449 context,
2450 )?;
2451 }
2452 Some(Fragment::Plugin(plugin_frag)) => {
2453 if let Some(p) = &mut context.plugin {
2454 p.on_element_data(&plugin_frag.data, context.writer)?;
2455 }
2456 }
2457 Some(Fragment::Route(route_frag)) => {
2458 self.process_route(route_frag, best_route, context)?;
2459 }
2460 Some(Fragment::Outlet(_)) => {
2461 self.process_outlet(context)?;
2462 }
2463 Some(Fragment::Boundary(_)) => {}
2464 None => {}
2465 }
2466 }
2467 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2468 }
2469
2470 fn process_outlet<'protocol>(
2476 &self,
2477 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2478 ) -> Result<()> {
2479 let mut children = std::mem::take(&mut context.route_children);
2485 if children.is_empty() {
2486 return Ok(());
2487 }
2488
2489 let request_segments = route_matcher::split_request_path(context.request_path);
2491 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2492 for (idx, child) in children.iter().enumerate() {
2493 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2494 context.route_index,
2495 &child.path,
2496 &context.route_base,
2497 &request_segments,
2498 child.exact,
2499 ) {
2500 let is_better = best
2501 .as_ref()
2502 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2503 if is_better {
2504 best = Some((idx, m));
2505 }
2506 }
2507 }
2508
2509 let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2510 if let Some((idx, ref rm)) = best {
2511 route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2512 let descended = descend_into(&mut children, idx);
2513 let Some(matched_child) = children.get(idx) else {
2514 return Ok(());
2515 };
2516 let comp = &matched_child.fragment_id;
2517
2518 if !comp.is_empty() {
2519 let saved_route_base = (rm.consumed_segments > 0).then(|| {
2520 let base = route_matcher::compute_route_base(
2521 context.request_path,
2522 rm.consumed_segments,
2523 );
2524 std::mem::replace(&mut context.route_base, Cow::Owned(base))
2525 });
2526 context.route_children = descended;
2527
2528 context.writer.write("<webui-route")?;
2530 context.writer.write(" path=\"")?;
2531 context.writer.write(&matched_child.path)?;
2532 context.writer.write("\"")?;
2533 context.writer.write(" component=\"")?;
2534 context.writer.write(comp)?;
2535 context.writer.write("\"")?;
2536 if matched_child.exact {
2537 context.writer.write(" exact")?;
2538 }
2539 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2540 let ri = context.route_chain_index;
2542 context.route_chain_index += 1;
2543 context.writer.write(" data-ri=\"")?;
2544 write_usize(context.writer, ri)?;
2545 context.writer.write("\" active>")?;
2546
2547 if !Self::component_owns_css_tree(comp, context.protocol) {
2548 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2549 }
2550 if !matched_child.content_fragment_id.is_empty() {
2551 self.process_fragment_target(
2552 None,
2553 &matched_child.content_fragment_id,
2554 context,
2555 )?;
2556 }
2557
2558 context.writer.write("<")?;
2559 context.writer.write(comp)?;
2560 if let Some(p) = &context.plugin {
2561 p.write_route_component_state(context.state, context.writer)?;
2562 }
2563 write_interaction_marker(comp, context)?;
2564 prepare_generated_streaming_root(comp, context)?;
2565 context.writer.write(">")?;
2566
2567 self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2568
2569 context.writer.write("</")?;
2570 context.writer.write(comp)?;
2571 context.writer.write(">")?;
2572 context.writer.write("</webui-route>")?;
2573
2574 if let Some(saved) = saved_route_base {
2575 context.route_base = saved;
2576 }
2577 context.route_children = Cow::Borrowed(&[]);
2581 }
2582 }
2583
2584 route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2585 }
2586
2587 fn emit_css_module_importmap(
2598 &self,
2599 specifier: &str,
2600 css: &str,
2601 context: &mut WebUIProcessContext,
2602 ) -> Result<()> {
2603 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2604 context.writer.write(&tag)?;
2605 Ok(())
2606 }
2607
2608 fn emit_component_style_closure(
2610 &self,
2611 root: &str,
2612 install: StyleClosureInstall,
2613 context: &mut WebUIProcessContext,
2614 ) -> Result<()> {
2615 if context.protocol.style_closures.is_empty() {
2616 return Ok(());
2617 }
2618 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2619 match context.shadow_style_roots.last() {
2620 Some(shadow_root) => {
2621 if !shadow_root.static_closure_emitted {
2622 return Err(HandlerError::Invariant(
2623 "routed styles reached a ShadowRoot before its static style hook"
2624 .to_string(),
2625 ));
2626 }
2627 let root_name = context
2628 .component_index
2629 .iter()
2630 .find_map(|(name, index)| {
2631 (*index == shadow_root.component_index).then_some(name.as_str())
2632 })
2633 .ok_or_else(|| {
2634 HandlerError::Invariant(
2635 "active Shadow style root lost its protocol index".to_string(),
2636 )
2637 })?;
2638 Some(
2639 context
2640 .protocol
2641 .style_closures
2642 .get(root_name)
2643 .ok_or_else(|| {
2644 HandlerError::Invariant(format!(
2645 "component style closure metadata is missing Shadow root `{root_name}`"
2646 ))
2647 })?,
2648 )
2649 }
2650 None => None,
2651 }
2652 } else {
2653 None
2654 };
2655 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2656 HandlerError::Invariant(format!(
2657 "component style closure metadata is missing root `{root}`"
2658 ))
2659 })?;
2660 let is_document_tree = context.shadow_style_roots.is_empty();
2661 let strategy = context.css_strategy;
2662
2663 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2668 let mut emitted_resources = EmittedStyleUnits::new();
2669
2670 for position in 0..unit_count {
2671 let unit = context
2672 .protocol
2673 .style_closure_unit(closure, &context.style_chunk_index, position)
2674 .ok_or_else(|| {
2675 HandlerError::Invariant(format!(
2676 "component style closure `{root}` references out-of-range unit {position}"
2677 ))
2678 })?;
2679 let (name, chunk) = (unit.name, unit.chunk);
2680 if !emitted_resources.insert(name) {
2681 continue;
2682 }
2683 let resource = unit.resource.ok_or_else(|| match chunk {
2684 Some(index) => HandlerError::Invariant(format!(
2685 "component style closure `{root}` references missing style chunk {index}"
2686 )),
2687 None => HandlerError::Invariant(format!(
2688 "component style closure `{root}` references missing resource `{name}`"
2689 )),
2690 })?;
2691 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2692 continue;
2693 }
2694 if let Some(static_closure) = shadow_static_closure {
2695 let resource_index = match chunk {
2696 Some(index) => {
2697 if static_closure.style_chunks.contains(&index) {
2698 continue;
2699 }
2700 index
2701 }
2702 None => {
2703 if static_closure.component_tags.iter().any(|tag| tag == name) {
2704 continue;
2705 }
2706 context.component_index.get(name).copied().ok_or_else(|| {
2707 HandlerError::Invariant(format!(
2708 "component style resource `{name}` is missing its protocol index"
2709 ))
2710 })?
2711 }
2712 };
2713 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2714 HandlerError::Invariant(
2715 "active Shadow style root disappeared during routed style delivery"
2716 .to_string(),
2717 )
2718 })?;
2719 if shadow_root.routed_resources.contains(&resource_index) {
2720 continue;
2721 }
2722 shadow_root.routed_resources.push(resource_index);
2723 }
2724
2725 match strategy {
2726 webui_protocol::CssStrategy::Link => {
2727 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2728 context
2729 .writer
2730 .write(&crate::html_encode::encode_safe(resource))?;
2731 context.writer.write("\" data-webui-resource=\"")?;
2732 context
2733 .writer
2734 .write(&crate::html_encode::encode_safe(name))?;
2735 context.writer.write("\" data-webui-strategy=\"link\">")?;
2736 }
2737 strategy => {
2738 context.writer.write("<style")?;
2739 if let Some(nonce) = context.nonce {
2740 context.writer.write(" nonce=\"")?;
2741 context
2742 .writer
2743 .write(&crate::html_encode::encode_safe(nonce))?;
2744 context.writer.write("\"")?;
2745 }
2746 context.writer.write(" data-webui-resource=\"")?;
2747 context
2748 .writer
2749 .write(&crate::html_encode::encode_safe(name))?;
2750 context.writer.write("\" data-webui-strategy=\"")?;
2751 context
2752 .writer
2753 .write(if strategy == webui_protocol::CssStrategy::Module {
2754 "module"
2755 } else {
2756 "style"
2757 })?;
2758 context.writer.write("\">")?;
2759 if context.style_resources_requiring_escape.contains(name) {
2760 crate::html_encode::write_style_text(context.writer, resource)?;
2761 } else {
2762 context.writer.write(resource)?;
2763 }
2764 context.writer.write("</style>")?;
2765 }
2766 }
2767 }
2768 Ok(())
2769 }
2770
2771 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2772 if context.route_chain.is_none() {
2773 let plan = crate::route_handler::collect_route_chain_plan(
2774 context.protocol,
2775 context.entry_id,
2776 context.request_path,
2777 context.route_index,
2778 );
2779 context.route_document_style_targets = plan.document_style_targets;
2780 context.route_chain = Some(plan.entries);
2781 }
2782 }
2783
2784 fn emit_active_route_styles(
2785 &self,
2786 preloaded: &mut Vec<u32>,
2787 context: &mut WebUIProcessContext,
2788 ) -> Result<()> {
2789 Self::ensure_request_route_chain(context);
2790 let Some(chain) = context.route_chain.take() else {
2791 return Err(route_style_plan_missing_error());
2792 };
2793 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2794 if chain.len() != document_targets.len() {
2795 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2796 context.route_document_style_targets = document_targets;
2797 context.route_chain = Some(chain);
2798 return Err(error);
2799 }
2800
2801 let result = (|| {
2802 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2803 if *targets_document {
2804 self.emit_component_style_closure(
2805 &entry.component,
2806 StyleClosureInstall::Static,
2807 context,
2808 )?;
2809 continue;
2810 }
2811
2812 if context.css_strategy != webui_protocol::CssStrategy::Link {
2813 continue;
2814 }
2815 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2816 }
2817 Ok(())
2818 })();
2819
2820 context.route_document_style_targets = document_targets;
2821 context.route_chain = Some(chain);
2822 result
2823 }
2824
2825 fn emit_component_style_preloads(
2826 &self,
2827 root: &str,
2828 preloaded: &mut Vec<u32>,
2829 context: &mut WebUIProcessContext,
2830 ) -> Result<()> {
2831 let Some(closure) = context.protocol.style_closures.get(root) else {
2832 return Ok(());
2833 };
2834 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2836 for position in 0..unit_count {
2837 let Some(unit) =
2838 context
2839 .protocol
2840 .style_closure_unit(closure, &context.style_chunk_index, position)
2841 else {
2842 continue;
2843 };
2844 let Some(href) = unit.resource else {
2845 continue;
2846 };
2847 let name = unit.name;
2848 if context.document_style_resources.contains(name) {
2849 continue;
2850 }
2851 let Some(&resource_index) = context.style_resource_index.get(name) else {
2852 continue;
2853 };
2854 if preloaded.contains(&resource_index) {
2855 continue;
2856 }
2857 context
2858 .writer
2859 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2860 context
2861 .writer
2862 .write(&crate::html_encode::encode_safe(href))?;
2863 context.writer.write("\">")?;
2864 preloaded.push(resource_index);
2865 }
2866 Ok(())
2867 }
2868
2869 fn emit_reachable_shadow_preloads(
2870 &self,
2871 preloaded: &mut Vec<u32>,
2872 context: &mut WebUIProcessContext,
2873 ) -> Result<()> {
2874 if context.css_strategy != webui_protocol::CssStrategy::Link {
2875 return Ok(());
2876 }
2877 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2878 crate::route_handler::collect_reachable_component_order_for_request(
2879 context.protocol,
2880 context.entry_id,
2881 context.request_path,
2882 context.route_index,
2883 )
2884 });
2885 let result = (|| {
2886 for component in &reachable {
2887 if context.protocol.component_uses_shadow_dom(component) {
2888 self.emit_component_style_preloads(component, preloaded, context)?;
2889 }
2890 }
2891 Ok(())
2892 })();
2893 context.reachable_components = Some(reachable);
2894 result
2895 }
2896
2897 fn process_shadow_style_signal(
2898 &self,
2899 root: &str,
2900 context: &mut WebUIProcessContext,
2901 ) -> Result<()> {
2902 let Some(root_index) = context.component_index.get(root).copied() else {
2903 return Err(HandlerError::Invariant(format!(
2904 "Shadow style hook `{root}` references an unknown component"
2905 )));
2906 };
2907 if context
2908 .shadow_style_roots
2909 .last()
2910 .map(|shadow_root| shadow_root.component_index)
2911 != Some(root_index)
2912 {
2913 return Err(HandlerError::Invariant(format!(
2914 "Shadow style hook `{root}` does not match the active component root"
2915 )));
2916 }
2917 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2918 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2919 HandlerError::Invariant(
2920 "active Shadow style root disappeared after its static style hook".to_string(),
2921 )
2922 })?;
2923 shadow_root.static_closure_emitted = true;
2924 Ok(())
2925 }
2926
2927 fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2936 if context.css_strategy != webui_protocol::CssStrategy::Module {
2937 return Ok(());
2938 }
2939 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2940 streaming_template_already_sent(streaming, context.component_index, fragment_id)
2941 });
2942 if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2943 if let Some(css) = context
2944 .protocol
2945 .components
2946 .get(fragment_id)
2947 .map(|c| c.css.as_str())
2948 .filter(|s| !s.is_empty())
2949 {
2950 self.emit_css_module_importmap(fragment_id, css, context)?;
2951 }
2952 }
2953 Ok(())
2954 }
2955
2956 fn process_route<'protocol>(
2958 &self,
2959 route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2960 best_route: &Option<(String, route_matcher::RouteMatch)>,
2961 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2962 ) -> Result<()> {
2963 let is_matched = best_route
2964 .as_ref()
2965 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2966
2967 context.writer.write("<webui-route")?;
2968 context.writer.write(" path=\"")?;
2969 context.writer.write(&route_frag.path)?;
2970 context.writer.write("\"")?;
2971 if !route_frag.fragment_id.is_empty() {
2972 context.writer.write(" component=\"")?;
2973 context.writer.write(&route_frag.fragment_id)?;
2974 context.writer.write("\"")?;
2975 }
2976 if route_frag.exact {
2977 context.writer.write(" exact")?;
2978 }
2979 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2980
2981 if is_matched {
2982 let ri = context.route_chain_index;
2984 context.route_chain_index += 1;
2985 context.writer.write(" data-ri=\"")?;
2986 write_usize(context.writer, ri)?;
2987 context.writer.write("\" active>")?;
2988
2989 let saved_route_base = best_route.as_ref().map(|(_, rm)| {
2990 let base =
2991 route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
2992 std::mem::replace(&mut context.route_base, Cow::Owned(base))
2993 });
2994 let saved_route_children = std::mem::replace(
2995 &mut context.route_children,
2996 Cow::Borrowed(&route_frag.children),
2997 );
2998
2999 if !route_frag.content_fragment_id.is_empty() {
3000 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
3001 }
3002
3003 if !route_frag.fragment_id.is_empty() {
3004 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
3005 self.emit_component_style_closure(
3006 &route_frag.fragment_id,
3007 StyleClosureInstall::Routed,
3008 context,
3009 )?;
3010 }
3011 context.writer.write("<")?;
3012 context.writer.write(&route_frag.fragment_id)?;
3013 if let Some(p) = &context.plugin {
3014 p.write_route_component_state(context.state, context.writer)?;
3015 }
3016 write_interaction_marker(&route_frag.fragment_id, context)?;
3017 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
3018 context.writer.write(">")?;
3019
3020 self.process_component(
3021 &route_frag.fragment_id,
3022 None,
3023 ComponentHostOrigin::HandlerGenerated,
3024 context,
3025 )?;
3026
3027 context.writer.write("</")?;
3028 context.writer.write(&route_frag.fragment_id)?;
3029 context.writer.write(">")?;
3030 }
3031 if let Some(saved) = saved_route_base {
3032 context.route_base = saved;
3033 }
3034 context.route_children = saved_route_children;
3035 } else {
3036 context.writer.write(" style=\"display:none\">")?;
3037 }
3038
3039 context.writer.write("</webui-route>")?;
3040 Ok(())
3041 }
3042
3043 fn process_component(
3045 &self,
3046 fragment_id: &str,
3047 target: Option<usize>,
3048 origin: ComponentHostOrigin,
3049 context: &mut WebUIProcessContext,
3050 ) -> Result<()> {
3051 if context.streaming.is_some() {
3052 consume_streaming_component_root(fragment_id, origin, context)?;
3053 record_checkpoint_tag(context, fragment_id);
3056 }
3057
3058 if !context.rendered_components.contains(fragment_id) {
3064 self.emit_css_module(fragment_id, context)?;
3065 context.rendered_components.insert(fragment_id.to_string());
3066 }
3067
3068 let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
3069 if owns_css_tree {
3070 Self::push_shadow_style_root(fragment_id, context)?;
3071 }
3072
3073 let saved_local_vars = std::mem::take(&mut context.local_vars);
3075 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
3076 let saved_component_attrs = std::mem::replace(
3080 &mut context.component_attrs,
3081 take_scope_map(&mut context.scope_pool),
3082 );
3083 let saved_component_borrowed_attrs = std::mem::replace(
3084 &mut context.component_borrowed_attrs,
3085 take_borrowed_scope(&mut context.borrowed_scope_pool),
3086 );
3087 context.local_vars = saved_component_attrs;
3088 context.local_borrowed_vars = saved_component_borrowed_attrs;
3089 context.collecting_component_attrs = false;
3090 let saved_loop_scope = context.visible_loop_scope;
3091 context.visible_loop_scope = VisibleLoopScope {
3092 start: context.loop_vars.len(),
3093 end: context.loop_vars.len(),
3094 };
3095
3096 if let Some(p) = &mut context.plugin {
3097 p.push_scope();
3098 }
3099
3100 let render_result = self.process_fragment_target(target, fragment_id, context);
3101 context.visible_loop_scope = saved_loop_scope;
3102
3103 if owns_css_tree {
3104 Self::pop_shadow_style_root(fragment_id, context)?;
3105 }
3106 render_result?;
3107
3108 if let Some(p) = &mut context.plugin {
3109 p.pop_scope();
3110 }
3111
3112 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3115 recycle_scope_map(&mut context.scope_pool, used_locals);
3116 let used_borrowed_locals =
3117 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3118 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3119 context.component_attrs.clear();
3122 context.component_borrowed_attrs.clear();
3123
3124 Ok(())
3125 }
3126
3127 #[inline]
3128 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3129 !protocol.style_closures.is_empty()
3130 && protocol
3131 .components
3132 .get(component)
3133 .is_some_and(|data| data.uses_shadow_dom)
3134 }
3135
3136 pub(crate) fn push_shadow_style_root(
3137 component: &str,
3138 context: &mut WebUIProcessContext,
3139 ) -> Result<()> {
3140 if !context.protocol.style_closures.contains_key(component) {
3141 return Err(HandlerError::Invariant(format!(
3142 "component style closure metadata is missing Shadow root `{component}`"
3143 )));
3144 }
3145 let root_index = context
3146 .component_index
3147 .get(component)
3148 .copied()
3149 .ok_or_else(|| {
3150 HandlerError::Invariant(format!(
3151 "Shadow component `{component}` is missing its protocol index"
3152 ))
3153 })?;
3154 context.shadow_style_roots.push(ShadowStyleRoot {
3155 component_index: root_index,
3156 static_closure_emitted: false,
3157 routed_resources: Vec::new(),
3158 });
3159 Ok(())
3160 }
3161
3162 pub(crate) fn pop_shadow_style_root(
3163 component: &str,
3164 context: &mut WebUIProcessContext,
3165 ) -> Result<()> {
3166 context.shadow_style_roots.pop().ok_or_else(|| {
3167 HandlerError::Invariant(format!(
3168 "Shadow component `{component}` lost its active style root"
3169 ))
3170 })?;
3171 Ok(())
3172 }
3173
3174 fn resolve_value_owned(
3176 &self,
3177 path: &str,
3178 context: &WebUIProcessContext<'_, '_, '_>,
3179 ) -> Option<Value> {
3180 resolve_value_from_sources(
3181 path,
3182 &context.loop_vars,
3183 context.visible_loop_scope,
3184 LocalValueSources {
3185 owned: &context.local_vars,
3186 borrowed: &context.local_borrowed_vars,
3187 },
3188 context.state,
3189 )
3190 .map(Cow::into_owned)
3191 }
3192
3193 fn evaluate_condition(
3200 &self,
3201 condition: &webui_protocol::ConditionExpr,
3202 context: &WebUIProcessContext,
3203 ) -> Result<bool> {
3204 let loop_vars = &context.loop_vars;
3205 let visible_loop_scope = context.visible_loop_scope;
3206 let local_values = LocalValueSources {
3207 owned: &context.local_vars,
3208 borrowed: &context.local_borrowed_vars,
3209 };
3210 let state = context.state;
3211 match evaluate_with_resolver(condition, |path| {
3212 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3213 }) {
3214 Ok(result) => Ok(result),
3215 Err(ExpressionError::MissingValue(_)) => Ok(false),
3216 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3217 }
3218 }
3219
3220 fn process_for_loop<'protocol, 'state>(
3226 &self,
3227 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3228 target: Option<usize>,
3229 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3230 ) -> Result<()> {
3231 if let Some(items) = resolve_borrowed_collection(
3232 &for_loop.collection,
3233 &context.loop_vars,
3234 context.visible_loop_scope,
3235 LocalValueSources {
3236 owned: &context.local_vars,
3237 borrowed: &context.local_borrowed_vars,
3238 },
3239 context.state,
3240 ) {
3241 return self.process_borrowed_for_loop(for_loop, target, items, context);
3242 }
3243 self.process_owned_for_loop(for_loop, target, context)
3244 }
3245
3246 fn process_borrowed_for_loop<'protocol, 'state>(
3247 &self,
3248 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3249 target: Option<usize>,
3250 items: &'state [Value],
3251 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3252 ) -> Result<()> {
3253 if let Some(plugin) = &mut context.plugin {
3254 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3255 }
3256
3257 let item_name = for_loop.item.as_str();
3258 let saved_value = context.local_vars.remove(item_name);
3259 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3260 let saved_scope = context.visible_loop_scope;
3261 for (index, item) in items.iter().enumerate() {
3262 if let Some(plugin) = &mut context.plugin {
3263 plugin.on_repeat_item_start(index, context.writer)?;
3264 plugin.push_scope();
3265 }
3266
3267 context.loop_vars.push(LoopBinding {
3268 name: item_name,
3269 value: item,
3270 });
3271 context.visible_loop_scope.end = context.loop_vars.len();
3272 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3273 context.loop_vars.pop();
3274 context.visible_loop_scope = saved_scope;
3275
3276 if let Some(plugin) = &mut context.plugin {
3277 plugin.pop_scope();
3278 plugin.on_repeat_item_end(index, context.writer)?;
3279 }
3280 }
3281 if let Some(value) = saved_value {
3282 context.local_vars.insert(item_name.to_string(), value);
3283 }
3284 if let Some(value) = saved_borrowed_value {
3285 context.local_borrowed_vars.insert(item_name, value);
3286 }
3287
3288 if let Some(plugin) = &mut context.plugin {
3289 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3290 }
3291 Ok(())
3292 }
3293
3294 fn process_owned_for_loop<'protocol, 'state>(
3295 &self,
3296 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3297 target: Option<usize>,
3298 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3299 ) -> Result<()> {
3300 let collection_name = &for_loop.collection;
3301
3302 let items = match self.resolve_value_owned(collection_name, context) {
3305 Some(Value::Array(arr)) => arr,
3306 Some(_) => {
3307 return Err(HandlerError::TypeError(format!(
3308 "Collection '{}' is not an array",
3309 collection_name
3310 )))
3311 }
3312 None => Vec::new(),
3313 };
3314
3315 if let Some(p) = &mut context.plugin {
3316 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3317 }
3318
3319 let item_name = for_loop.item.as_str();
3327 let saved_value = context.local_vars.remove(item_name);
3328 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3329 if !items.is_empty() {
3333 context
3334 .local_vars
3335 .insert(item_name.to_string(), Value::Null);
3336 }
3337 for (i, item) in items.into_iter().enumerate() {
3338 if let Some(p) = &mut context.plugin {
3339 p.on_repeat_item_start(i, context.writer)?;
3340 p.push_scope();
3341 }
3342
3343 if let Some(slot) = context.local_vars.get_mut(item_name) {
3345 *slot = item;
3346 }
3347 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3348
3349 if let Some(p) = &mut context.plugin {
3350 p.pop_scope();
3351 p.on_repeat_item_end(i, context.writer)?;
3352 }
3353 }
3354 match saved_value {
3356 Some(value) => {
3357 context.local_vars.insert(item_name.to_string(), value);
3358 }
3359 None => {
3360 context.local_vars.remove(item_name);
3361 }
3362 }
3363 if let Some(value) = saved_borrowed_value {
3364 context.local_borrowed_vars.insert(item_name, value);
3365 }
3366
3367 if let Some(p) = &mut context.plugin {
3368 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3369 }
3370
3371 Ok(())
3372 }
3373
3374 #[inline]
3380 fn process_signal<'data>(
3381 &self,
3382 signal: &'data webui_protocol::WebUIFragmentSignal,
3383 context: &mut WebUIProcessContext<'data, '_, '_>,
3384 ) -> Result<()> {
3385 if signal.raw {
3386 self.process_raw_signal(signal, context)
3387 } else {
3388 self.process_state_signal(signal, false, context)
3389 }
3390 }
3391
3392 #[inline(never)]
3393 fn process_raw_signal<'data>(
3394 &self,
3395 signal: &'data webui_protocol::WebUIFragmentSignal,
3396 context: &mut WebUIProcessContext<'data, '_, '_>,
3397 ) -> Result<()> {
3398 let Some(structural_value) = structural_signal_value(signal) else {
3399 return self.process_state_signal(signal, !signal.raw_text_context, context);
3400 };
3401
3402 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3403 return self.process_shadow_style_signal(root, context);
3404 }
3405
3406 if context.streaming.is_some()
3407 && self.process_streaming_signal(structural_value, context)?
3408 {
3409 return Ok(());
3410 }
3411
3412 if structural_value == "head_end" && !context.head_end_emitted {
3416 context.head_end_emitted = true;
3417 if let Some(nonce) = context.nonce {
3418 context
3419 .writer
3420 .write("<meta name=\"webui-nonce\" content=\"")?;
3421 context
3422 .writer
3423 .write(&crate::html_encode::encode_safe(nonce))?;
3424 context.writer.write("\">")?;
3425 }
3426
3427 if !context.protocol.component_render_css.is_empty() {
3430 context.writer.write("<style data-webui-render-policy")?;
3431 if let Some(nonce) = context.nonce {
3432 context.writer.write(" nonce=\"")?;
3433 context
3434 .writer
3435 .write(&crate::html_encode::encode_safe(nonce))?;
3436 context.writer.write("\"")?;
3437 }
3438 context.writer.write(">")?;
3439 context
3440 .writer
3441 .write(&context.protocol.component_render_css)?;
3442 context.writer.write("</style>")?;
3443 }
3444
3445 if !context.protocol.style_closures.is_empty() {
3446 let entry_id = context.entry_id;
3447 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3448 let mut preloaded = Vec::new();
3449 self.emit_active_route_styles(&mut preloaded, context)?;
3450 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3451 }
3452
3453 if !context.protocol.module_preloads.is_empty() {
3465 for href in &context.protocol.module_preloads {
3466 context
3467 .writer
3468 .write("<link rel=\"modulepreload\" href=\"")?;
3469 context.writer.write(href)?;
3470 context.writer.write("\">")?;
3471 }
3472 }
3473
3474 write_component_asset_styles(context)?;
3475
3476 if let Some(html) = context.head_inject {
3482 context.writer.write(html)?;
3483 }
3484
3485 if let Some(html) = context.state_inject.head_end {
3489 context.writer.write(html)?;
3490 }
3491 }
3492
3493 if structural_value == "body_start" && !context.body_start_emitted {
3496 context.body_start_emitted = true;
3497 write_component_asset_styles(context)?;
3498 if let Some(html) = context.state_inject.body_start {
3499 context.writer.write(html)?;
3500 }
3501 }
3502
3503 if structural_value == "body_end" && !context.body_end_emitted {
3508 context.body_end_emitted = true;
3509 if context.plugin.is_some() {
3510 let reachable = context.reachable_components.take().unwrap_or_else(|| {
3523 crate::route_handler::collect_reachable_component_order_for_request(
3524 context.protocol,
3525 context.entry_id,
3526 context.request_path,
3527 context.route_index,
3528 )
3529 });
3530 let state_selection =
3531 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3532
3533 if context.css_strategy == webui_protocol::CssStrategy::Module {
3537 for name in &reachable {
3538 if !context.rendered_components.contains(name) {
3539 if let Some(css) = context
3540 .protocol
3541 .components
3542 .get(name)
3543 .map(|c| c.css.as_str())
3544 .filter(|s| !s.is_empty())
3545 {
3546 self.emit_css_module_importmap(name, css, context)?;
3547 }
3548 }
3549 }
3550 }
3551
3552 let template_payloads = context
3556 .plugin
3557 .as_ref()
3558 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3559
3560 if template_payloads.is_none() {
3561 if let Some(ref p) = context.plugin {
3563 p.emit_templates(
3564 context.protocol,
3565 &reachable,
3566 context.nonce,
3567 context.writer,
3568 )?;
3569 }
3570 }
3571
3572 let inventory_hex = crate::route_handler::encode_component_inventory(
3574 &context.rendered_components,
3575 context.component_index,
3576 );
3577
3578 Self::ensure_request_route_chain(context);
3580 let chain = context.route_chain.as_deref().ok_or_else(|| {
3581 HandlerError::Invariant(
3582 "request route chain disappeared after collection".to_string(),
3583 )
3584 })?;
3585 let chain_json: Vec<Value> = chain
3586 .iter()
3587 .map(crate::route_handler::RouteChainEntry::to_json)
3588 .collect();
3589
3590 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3592 let mut css_hrefs: Vec<&str> = Vec::new();
3593 if is_link {
3594 for name in &reachable {
3595 if let Some(href) = context
3596 .protocol
3597 .components
3598 .get(name)
3599 .map(|c| c.css_href.as_str())
3600 .filter(|h| !h.is_empty())
3601 {
3602 css_hrefs.push(href);
3603 }
3604 }
3605 }
3606
3607 let mut style_specs: Vec<&str> = Vec::new();
3609 if context.css_strategy == webui_protocol::CssStrategy::Module {
3610 for name in &reachable {
3611 if context
3612 .protocol
3613 .components
3614 .get(name)
3615 .map(|c| !c.css.is_empty())
3616 .unwrap_or(false)
3617 {
3618 style_specs.push(name);
3619 }
3620 }
3621 }
3622
3623 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3624 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3625 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3626 style_roots.push(context.entry_id);
3627 for entry in chain {
3628 style_roots.push(entry.component.as_str());
3629 if !entry.pending_component.is_empty() {
3630 style_roots.push(entry.pending_component.as_str());
3631 }
3632 if !entry.error_component.is_empty() {
3633 style_roots.push(entry.error_component.as_str());
3634 }
3635 }
3636 style_roots.extend(reachable.iter().map(String::as_str));
3637 let component_styles =
3638 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3639 write_webui_data_block(
3640 context.writer,
3641 &mut context.json_scratch,
3642 WebUiBootstrap {
3643 declaration_id: None,
3644 enclosing_span_instance_id: None,
3645 state: WebUiBootstrapState::Complete {
3646 value: context.state,
3647 selection: state_selection,
3648 },
3649 chain: &chain_json,
3650 inventory: &inventory_hex,
3651 nonce: context.nonce,
3652 css_hrefs: &css_hrefs,
3653 style_specs: &style_specs,
3654 component_styles: &component_styles,
3655 templates: payloads,
3656 },
3657 )?;
3658
3659 if let Some(ref plugin) = context.plugin {
3663 plugin.emit_bootstrap_extension(
3664 BootstrapExtensionContext {
3665 protocol: context.protocol,
3666 components: &reachable,
3667 payloads,
3668 nonce: context.nonce,
3669 },
3670 context.writer,
3671 )?;
3672 }
3673 }
3674
3675 if let Some(html) = context.body_inject {
3682 context.writer.write(html)?;
3683 }
3684
3685 if let Some(html) = context.state_inject.body_end {
3688 context.writer.write(html)?;
3689 }
3690 }
3691
3692 Ok(())
3696 }
3697
3698 #[inline]
3710 fn process_state_signal(
3711 &self,
3712 signal: &webui_protocol::WebUIFragmentSignal,
3713 owns_html_range: bool,
3714 context: &mut WebUIProcessContext,
3715 ) -> Result<()> {
3716 if let Some(p) = &mut context.plugin {
3717 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3718 }
3719
3720 if let Some(value) = resolve_value_from_sources(
3721 &signal.value,
3722 &context.loop_vars,
3723 context.visible_loop_scope,
3724 LocalValueSources {
3725 owned: &context.local_vars,
3726 borrowed: &context.local_borrowed_vars,
3727 },
3728 context.state,
3729 ) {
3730 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3731 }
3732
3733 if let Some(p) = &mut context.plugin {
3734 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3735 }
3736 Ok(())
3737 }
3738
3739 fn write_signal_value(
3748 &self,
3749 value: &Value,
3750 raw: bool,
3751 writer: &mut dyn ResponseWriter,
3752 ) -> Result<()> {
3753 if write_plain_json_scalar(writer, value)? {
3756 return Ok(());
3757 }
3758 if raw {
3759 match value {
3760 Value::String(s) => writer.write(s),
3761 _ => writer.write(&value.to_string()),
3762 }
3763 } else {
3764 match value {
3765 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3766 _ => {
3767 let s = value.to_string();
3768 writer.write(&crate::html_encode::encode_safe(&s))
3769 }
3770 }
3771 }
3772 }
3773
3774 fn process_if(
3776 &self,
3777 if_cond: &webui_protocol::WebUIFragmentIf,
3778 target: Option<usize>,
3779 context: &mut WebUIProcessContext,
3780 ) -> Result<()> {
3781 let condition = if_cond
3782 .condition
3783 .as_ref()
3784 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3785 let condition_met = self.evaluate_condition(condition, context)?;
3786
3787 if let Some(p) = &mut context.plugin {
3788 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3789 }
3790
3791 if condition_met {
3792 if let Some(p) = &mut context.plugin {
3793 p.push_scope();
3794 }
3795
3796 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3797
3798 if let Some(p) = &mut context.plugin {
3799 p.pop_scope();
3800 }
3801 }
3802
3803 if let Some(p) = &mut context.plugin {
3804 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3805 }
3806
3807 Ok(())
3808 }
3809
3810 fn process_attribute<'protocol, 'state>(
3816 &self,
3817 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3818 template_target: Option<usize>,
3819 component_name: Option<&'protocol str>,
3820 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3821 ) -> Result<()> {
3822 if attr.attr_start {
3825 context.component_attrs.clear();
3826 context.component_borrowed_attrs.clear();
3827 context.collecting_component_attrs = true;
3828 }
3829
3830 if let Some(condition) = &attr.condition_tree {
3832 let condition_met = self.evaluate_condition(condition, context)?;
3833
3834 if context.collecting_component_attrs && !attr.attr_skip {
3835 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3836 context.component_borrowed_attrs.remove(name);
3837 context
3838 .component_attrs
3839 .insert(name.to_owned(), Value::Bool(condition_met));
3840 }
3841
3842 if condition_met {
3843 context.writer.write(" ")?;
3844 context.writer.write(&attr.name)?;
3845 }
3846 return Ok(());
3847 }
3848
3849 if !attr.template.is_empty() {
3851 let raw_value =
3852 self.render_template_attr_value(&attr.template, template_target, context)?;
3853 let escaped = crate::html_encode::encode_safe(&raw_value);
3854 write_attr(context.writer, &attr.name, &escaped)?;
3855
3856 if context.collecting_component_attrs && !attr.attr_skip {
3857 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3858 context.component_borrowed_attrs.remove(name);
3859 context
3860 .component_attrs
3861 .insert(name.to_owned(), Value::String(raw_value));
3862 }
3863 return Ok(());
3864 }
3865
3866 if attr.raw_value || !attr.value.is_empty() {
3868 if attr.raw_value {
3869 write_attr(
3871 context.writer,
3872 &attr.name,
3873 &crate::html_encode::encode_safe(&attr.value),
3874 )?;
3875 if context.collecting_component_attrs && !attr.attr_skip {
3876 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3877 context.component_borrowed_attrs.remove(name);
3878 context
3879 .component_attrs
3880 .insert(name.to_owned(), Value::String(attr.value.clone()));
3881 }
3882 } else if attr.complex {
3883 if context.collecting_component_attrs && !attr.attr_skip {
3885 let state_backed_value = resolve_state_backed_value(
3890 &attr.value,
3891 &context.loop_vars,
3892 context.visible_loop_scope,
3893 LocalValueSources {
3894 owned: &context.local_vars,
3895 borrowed: &context.local_borrowed_vars,
3896 },
3897 context.state,
3898 );
3899 if let Some(value) = state_backed_value {
3900 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3901 remove_owned_component_attr(&mut context.component_attrs, name);
3902 context.component_borrowed_attrs.insert(name, value);
3903 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3904 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3905 context.component_borrowed_attrs.remove(name);
3906 context.component_attrs.insert(name.to_owned(), value);
3907 }
3908 }
3909 } else {
3910 let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3914 resolve_state_backed_value(
3915 &attr.value,
3916 &context.loop_vars,
3917 context.visible_loop_scope,
3918 LocalValueSources {
3919 owned: &context.local_vars,
3920 borrowed: &context.local_borrowed_vars,
3921 },
3922 context.state,
3923 )
3924 } else {
3925 None
3926 };
3927 let value = match state_backed_value {
3930 Some(value) => Some(Cow::Borrowed(value)),
3931 None => resolve_value_from_sources(
3932 &attr.value,
3933 &context.loop_vars,
3934 context.visible_loop_scope,
3935 LocalValueSources {
3936 owned: &context.local_vars,
3937 borrowed: &context.local_borrowed_vars,
3938 },
3939 context.state,
3940 ),
3941 };
3942 match value.as_deref() {
3945 Some(Value::String(s)) => {
3946 write_attr(
3947 context.writer,
3948 &attr.name,
3949 &crate::html_encode::encode_safe(s),
3950 )?;
3951 }
3952 Some(Value::Null) | None => {
3953 write_attr(context.writer, &attr.name, "")?;
3954 }
3955 Some(other) => {
3956 let mut scalar = ScalarBuffer::new();
3957 match format_plain_json_scalar(&mut scalar, other) {
3958 Some(rendered) => {
3959 write_attr(context.writer, &attr.name, rendered)?;
3960 }
3961 None => {
3962 let s = other.to_string();
3963 write_attr(
3964 context.writer,
3965 &attr.name,
3966 &crate::html_encode::encode_safe(&s),
3967 )?;
3968 }
3969 }
3970 }
3971 }
3972
3973 if context.collecting_component_attrs && !attr.attr_skip {
3974 if let Some(borrowed) = state_backed_value {
3975 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3976 remove_owned_component_attr(&mut context.component_attrs, name);
3977 context.component_borrowed_attrs.insert(name, borrowed);
3978 } else {
3979 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3980 context.component_borrowed_attrs.remove(name);
3981 context.component_attrs.insert(
3982 name.to_owned(),
3983 value
3984 .map(Cow::into_owned)
3985 .unwrap_or(Value::String(String::new())),
3986 );
3987 }
3988 }
3989 }
3990 }
3991
3992 Ok(())
3993 }
3994
3995 fn render_template_attr_value(
3997 &self,
3998 template_id: &str,
3999 target: Option<usize>,
4000 context: &WebUIProcessContext,
4001 ) -> Result<String> {
4002 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
4003 return Err(HandlerError::MissingFragment(template_id.to_string()));
4004 };
4005 let fragments = context
4006 .render_fragments
4007 .list(index)
4008 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
4009 let mut raw_value = String::with_capacity(template_attr_capacity(fragments.fragments));
4010 for frag in fragments.fragments {
4011 match frag.fragment.as_ref() {
4012 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4013 Some(Fragment::Signal(signal)) => {
4014 if let Some(value) = resolve_value_from_sources(
4015 &signal.value,
4016 &context.loop_vars,
4017 context.visible_loop_scope,
4018 LocalValueSources {
4019 owned: &context.local_vars,
4020 borrowed: &context.local_borrowed_vars,
4021 },
4022 context.state,
4023 ) {
4024 match value.as_ref() {
4025 Value::String(s) => raw_value.push_str(s),
4026 other => {
4027 let mut scalar = ScalarBuffer::new();
4028 match format_plain_json_scalar(&mut scalar, other) {
4029 Some(rendered) => raw_value.push_str(rendered),
4030 None => raw_value.push_str(&other.to_string()),
4031 }
4032 }
4033 }
4034 }
4035 }
4036 _ => {}
4037 }
4038 }
4039 Ok(raw_value)
4040 }
4041
4042 pub fn render<'a>(
4044 &self,
4045 protocol: &'a Protocol,
4046 state: &'a Value,
4047 options: &RenderOptions<'a>,
4048 writer: &'a mut dyn ResponseWriter,
4049 ) -> Result<()> {
4050 protocol.ensure_style_metadata()?;
4051 let document = protocol.protocol();
4052 let render_fragments = protocol.render_fragments().resolve(document);
4053 let Some(entry_index) = render_fragments.index(options.entry_id) else {
4054 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4055 };
4056 let entry = render_fragments
4057 .list(entry_index)
4058 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4059 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4060 let has_document_head_boundary = !entry_owns_css_tree
4061 && !document.style_closures.is_empty()
4062 && entry.fragments.iter().any(|fragment| {
4063 matches!(
4064 fragment.fragment.as_ref(),
4065 Some(Fragment::Signal(signal))
4066 if structural_signal_value(signal) == Some("head_end")
4067 )
4068 });
4069 let doctype_split = (!entry_owns_css_tree
4070 && !has_document_head_boundary
4071 && !document.style_closures.is_empty())
4072 .then(|| {
4073 let Some(Fragment::Raw(raw)) = entry
4074 .fragments
4075 .first()
4076 .and_then(|fragment| fragment.fragment.as_ref())
4077 else {
4078 return None;
4079 };
4080 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4081 })
4082 .flatten();
4083 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4084 let mut context = WebUIProcessContext {
4085 protocol: document,
4086 render_fragments,
4087 component_asset_style_manifest,
4088 component_asset_style_links: protocol.component_asset_style_links(),
4089 state,
4090 writer,
4091 local_vars: ScopeMap::default(),
4092 local_borrowed_vars: BorrowedScope::default(),
4093 loop_vars: Vec::new(),
4094 visible_loop_scope: VisibleLoopScope::EMPTY,
4095 component_attrs: ScopeMap::default(),
4096 component_borrowed_attrs: BorrowedScope::default(),
4097 collecting_component_attrs: false,
4098 request_path: options.request_path,
4099 route_base: Cow::Borrowed("/"),
4100 rendered_components: HashSet::new(),
4101 plugin: self.plugin_factory.map(|f| f()),
4102 route_children: Cow::Borrowed(&[]),
4103 entry_id: options.entry_id,
4104 nonce: options.nonce.filter(|s| !s.is_empty()),
4107 head_inject: options.head_inject.filter(|s| !s.is_empty()),
4108 body_inject: options.body_inject.filter(|s| !s.is_empty()),
4109 state_inject: StateInject::resolve(state),
4110 head_end_emitted: false,
4111 body_start_emitted: false,
4112 component_asset_styles_emitted: false,
4113 component_index: protocol.component_index(),
4114 style_resource_index: protocol.style_resource_index(),
4115 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4116 style_chunk_index: protocol.protocol().style_chunk_index(),
4117 css_strategy: protocol.css_strategy(),
4118 body_end_emitted: false,
4119 route_index: protocol.route_index(),
4120 route_chain_index: 0,
4121 route_chain: None,
4122 route_document_style_targets: Vec::new(),
4123 reachable_components: None,
4124 streaming: None,
4125 json_scratch: Vec::new(),
4126 scope_pool: Vec::new(),
4127 document_style_resources: HashSet::new(),
4128 shadow_style_roots: Vec::new(),
4129 borrowed_scope_pool: Vec::new(),
4130 };
4131
4132 if entry_owns_css_tree {
4133 Self::push_shadow_style_root(options.entry_id, &mut context)?;
4134 }
4135
4136 let render_result = if let Some((first_raw, split)) = doctype_split {
4137 context.writer.write(&first_raw[..split])?;
4138 self.emit_component_style_closure(
4139 options.entry_id,
4140 StyleClosureInstall::Static,
4141 &mut context,
4142 )?;
4143 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4144 context.writer.write(&first_raw[split..])?;
4145 self.process_fragment_from(entry, 1, &mut context)
4146 } else {
4147 if !entry_owns_css_tree
4148 && !has_document_head_boundary
4149 && !document.style_closures.is_empty()
4150 {
4151 self.emit_component_style_closure(
4152 options.entry_id,
4153 StyleClosureInstall::Static,
4154 &mut context,
4155 )?;
4156 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4157 }
4158 self.process_fragment(entry, &mut context)
4159 };
4160
4161 if entry_owns_css_tree {
4162 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4163 }
4164 render_result?;
4165 writer.end()?;
4166
4167 Ok(())
4168 }
4169}
4170
4171impl Default for WebUIHandler {
4172 fn default() -> Self {
4173 Self::new()
4174 }
4175}
4176
4177const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4180
4181fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4185 fragments
4186 .iter()
4187 .map(|frag| match frag.fragment.as_ref() {
4188 Some(Fragment::Raw(raw)) => raw.value.len(),
4189 Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4190 _ => 0,
4191 })
4192 .sum()
4193}
4194
4195fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4197 writer.write_attribute(name, value)
4198}
4199
4200#[cfg(test)]
4201fn handle(
4202 protocol: &WebUIProtocol,
4203 state: &Value,
4204 options: &RenderOptions<'_>,
4205 writer: &mut dyn ResponseWriter,
4206) -> Result<()> {
4207 let handler = WebUIHandler::new();
4208 handler.handle(protocol, state, options, writer)
4209}
4210
4211#[cfg(test)]
4212mod scalar_buffer_tests {
4213 use super::*;
4214
4215 #[test]
4218 fn plain_scalars_match_value_to_string() {
4219 let cases = [
4220 Value::Null,
4221 Value::Bool(true),
4222 Value::Bool(false),
4223 serde_json::json!(0),
4224 serde_json::json!(-1),
4225 serde_json::json!(42),
4226 serde_json::json!(i64::MIN),
4227 serde_json::json!(i64::MAX),
4228 serde_json::json!(u64::MAX),
4229 serde_json::json!(0.5),
4230 serde_json::json!(-0.0_f64),
4231 serde_json::json!(1.7976931348623157e308_f64),
4232 serde_json::json!(-1.7976931348623157e308_f64),
4233 serde_json::json!(f64::MIN_POSITIVE),
4234 ];
4235 for value in cases {
4236 let mut buffer = ScalarBuffer::new();
4237 let rendered = format_plain_json_scalar(&mut buffer, &value);
4238 assert_eq!(
4239 rendered,
4240 Some(value.to_string().as_str()),
4241 "scalar rendering diverged for {value:?}"
4242 );
4243 }
4244 }
4245
4246 #[test]
4249 fn non_plain_values_are_declined() {
4250 let cases = [
4251 serde_json::json!("<script>"),
4252 serde_json::json!([1, 2]),
4253 serde_json::json!({ "a": 1 }),
4254 ];
4255 for value in cases {
4256 let mut buffer = ScalarBuffer::new();
4257 assert_eq!(
4258 format_plain_json_scalar(&mut buffer, &value),
4259 None,
4260 "expected fallback for {value:?}"
4261 );
4262 }
4263 }
4264
4265 #[test]
4267 fn overflow_is_reported_instead_of_truncating() {
4268 let mut buffer = ScalarBuffer::new();
4269 assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4270 }
4271}
4272
4273#[cfg(test)]
4274mod tests {
4275 use super::*;
4276 use crate::streaming::STREAMING_MARKER;
4277 use std::cell::RefCell;
4278 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4279 use webui_protocol::{
4280 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4281 WebUIFragmentAttribute, WebUiFragmentRoute,
4282 };
4283 use webui_test_utils::test_json;
4284
4285 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4286 WebUIFragment::signal(
4287 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4288 true,
4289 )
4290 }
4291
4292 #[test]
4293 fn route_descent_borrows_protocol_children() {
4294 let routes = vec![WebUiFragmentRoute {
4295 children: vec![WebUiFragmentRoute {
4296 fragment_id: "child".to_string(),
4297 ..Default::default()
4298 }],
4299 ..Default::default()
4300 }];
4301 let mut level = RouteChildren::Borrowed(&routes);
4302
4303 let descended = descend_into(&mut level, 0);
4304
4305 let Cow::Borrowed(children) = descended else {
4306 panic!("protocol-owned route children should remain borrowed");
4307 };
4308 assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4309 }
4310
4311 #[test]
4312 fn route_descent_moves_parked_children() {
4313 let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4314 children: vec![WebUiFragmentRoute {
4315 fragment_id: "child".to_string(),
4316 ..Default::default()
4317 }],
4318 ..Default::default()
4319 }]);
4320
4321 let descended = descend_into(&mut level, 0);
4322
4323 let Cow::Owned(children) = descended else {
4324 panic!("parked route children must not borrow session-owned storage");
4325 };
4326 assert_eq!(children[0].fragment_id, "child");
4327 let Cow::Owned(source) = level else {
4328 panic!("the parked source level must stay owned");
4329 };
4330 assert!(
4331 source[0].children.is_empty(),
4332 "owned route children must move out instead of being cloned"
4333 );
4334 }
4335
4336 #[test]
4337 fn borrowed_collection_reuses_state_array() {
4338 let state = test_json!({
4339 "items": [
4340 {"name": "first"},
4341 {"name": "second"}
4342 ]
4343 });
4344 let local_vars = ScopeMap::default();
4345 let local_borrowed_vars = BorrowedScope::default();
4346 let loop_vars = Vec::new();
4347 let items = resolve_borrowed_collection(
4348 "items",
4349 &loop_vars,
4350 VisibleLoopScope::EMPTY,
4351 LocalValueSources {
4352 owned: &local_vars,
4353 borrowed: &local_borrowed_vars,
4354 },
4355 &state,
4356 )
4357 .unwrap_or_else(|| panic!("state array should be borrowed"));
4358 let state_items = state["items"]
4359 .as_array()
4360 .unwrap_or_else(|| panic!("fixture items should be an array"));
4361 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4362 }
4363
4364 #[test]
4365 fn owned_local_collection_keeps_precedence() {
4366 let state = test_json!({"items": [{"name": "global"}]});
4367 let local_vars =
4368 ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4369 let local_borrowed_vars = BorrowedScope::default();
4370 assert!(resolve_borrowed_collection(
4371 "items",
4372 &[],
4373 VisibleLoopScope::EMPTY,
4374 LocalValueSources {
4375 owned: &local_vars,
4376 borrowed: &local_borrowed_vars,
4377 },
4378 &state,
4379 )
4380 .is_none());
4381 }
4382
4383 #[test]
4384 fn borrowed_component_collection_reuses_state_array() {
4385 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4386 let contacts = &state["teams"][0]["contacts"];
4387 let mut local_borrowed_vars = BorrowedScope::default();
4388 local_borrowed_vars.insert("contacts", contacts);
4389 let local_vars = ScopeMap::default();
4390 let items = resolve_borrowed_collection(
4391 "contacts",
4392 &[],
4393 VisibleLoopScope::EMPTY,
4394 LocalValueSources {
4395 owned: &local_vars,
4396 borrowed: &local_borrowed_vars,
4397 },
4398 &state,
4399 )
4400 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4401 let state_items = contacts
4402 .as_array()
4403 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4404 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4405 }
4406
4407 #[test]
4408 fn nested_borrowed_collection_reuses_child_array() {
4409 let state = test_json!({
4410 "items": [{
4411 "children": [
4412 {"name": "first"},
4413 {"name": "second"}
4414 ]
4415 }]
4416 });
4417 let item = &state["items"][0];
4418 let loop_vars = vec![LoopBinding {
4419 name: "item",
4420 value: item,
4421 }];
4422 let local_vars = ScopeMap::default();
4423 let local_borrowed_vars = BorrowedScope::default();
4424 let children = resolve_borrowed_collection(
4425 "item.children",
4426 &loop_vars,
4427 VisibleLoopScope { start: 0, end: 1 },
4428 LocalValueSources {
4429 owned: &local_vars,
4430 borrowed: &local_borrowed_vars,
4431 },
4432 &state,
4433 )
4434 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4435 let state_children = item["children"]
4436 .as_array()
4437 .unwrap_or_else(|| panic!("fixture children should be an array"));
4438 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4439 }
4440
4441 #[test]
4442 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4443 let state = test_json!({
4444 "item": {"name": "global", "fallback": "global fallback"},
4445 "outer": {"name": "outer"},
4446 "inner": {"name": "inner"}
4447 });
4448 let loop_vars = vec![
4449 LoopBinding {
4450 name: "item",
4451 value: &state["outer"],
4452 },
4453 LoopBinding {
4454 name: "item",
4455 value: &state["inner"],
4456 },
4457 ];
4458 let local_vars = ScopeMap::default();
4459 let local_borrowed_vars = BorrowedScope::default();
4460 let sources = LocalValueSources {
4461 owned: &local_vars,
4462 borrowed: &local_borrowed_vars,
4463 };
4464
4465 assert_eq!(
4466 resolve_value_from_sources(
4467 "item.name",
4468 &loop_vars,
4469 VisibleLoopScope { start: 0, end: 2 },
4470 sources,
4471 &state,
4472 )
4473 .as_deref()
4474 .and_then(Value::as_str),
4475 Some("inner")
4476 );
4477 assert_eq!(
4478 resolve_value_from_sources(
4479 "item.fallback",
4480 &loop_vars,
4481 VisibleLoopScope { start: 0, end: 2 },
4482 sources,
4483 &state,
4484 )
4485 .as_deref()
4486 .and_then(Value::as_str),
4487 Some("global fallback")
4488 );
4489 assert_eq!(
4490 resolve_value_from_sources(
4491 "item.name",
4492 &loop_vars,
4493 VisibleLoopScope { start: 2, end: 2 },
4494 sources,
4495 &state,
4496 )
4497 .as_deref()
4498 .and_then(Value::as_str),
4499 Some("global")
4500 );
4501 }
4502
4503 #[test]
4504 fn state_backed_resolver_matches_general_borrowed_resolution() {
4505 let state = test_json!({
4506 "global": {"name": "global"},
4507 "inner": {"fallback": "global fallback"},
4508 "borrowed": {"name": "borrowed"},
4509 "items": [1, 2, 3]
4510 });
4511 let loop_values = test_json!({
4512 "outer": {"name": "outer"},
4513 "inner": {"name": "inner"}
4514 });
4515 let loop_vars = vec![
4516 LoopBinding {
4517 name: "outer",
4518 value: &loop_values["outer"],
4519 },
4520 LoopBinding {
4521 name: "inner",
4522 value: &loop_values["inner"],
4523 },
4524 ];
4525 let mut local_borrowed_vars = BorrowedScope::default();
4526 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4527 let local_vars =
4528 ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4529 let sources = LocalValueSources {
4530 owned: &local_vars,
4531 borrowed: &local_borrowed_vars,
4532 };
4533 let scope = VisibleLoopScope { start: 0, end: 2 };
4534
4535 for path in [
4536 "global.name",
4537 "inner.name",
4538 "inner.fallback",
4539 "outer.name",
4540 "borrowed.name",
4541 ] {
4542 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4543 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4544 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4545 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4546 let Cow::Borrowed(resolved) = resolved else {
4547 panic!("{path} should remain borrowed");
4548 };
4549 assert!(
4550 std::ptr::eq(state_backed, resolved),
4551 "{path} resolved from different sources"
4552 );
4553 }
4554
4555 assert!(
4556 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4557 "owned local values must use the general resolver"
4558 );
4559 assert!(
4560 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4561 .is_none(),
4562 "synthetic values must use the general resolver"
4563 );
4564 }
4565
4566 #[test]
4567 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4568 let values = [
4569 Value::String("a".to_string()),
4570 Value::String("b".to_string()),
4571 Value::String("c".to_string()),
4572 Value::String("d".to_string()),
4573 Value::String("e".to_string()),
4574 Value::String("f".to_string()),
4575 Value::String("replacement".to_string()),
4576 ];
4577 let mut scope = BorrowedScope::default();
4578 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4579 assert!(scope.insert(name, &values[index]).is_none());
4580 }
4581 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4582 assert_eq!(scope.overflow.len(), 2);
4583 assert_eq!(scope.get("a"), Some(&values[0]));
4584 assert_eq!(scope.get("f"), Some(&values[5]));
4585 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4586 assert_eq!(scope.get("a"), Some(&values[6]));
4587 assert_eq!(scope.remove("b"), Some(&values[1]));
4588 assert!(scope.get("b").is_none());
4589 assert_eq!(scope.get("f"), Some(&values[5]));
4590 scope.clear();
4591 assert!(scope.get("a").is_none());
4592 assert!(scope.overflow.is_empty());
4593 }
4594
4595 struct TestWriter {
4597 content: RefCell<String>,
4598 ended: RefCell<bool>,
4599 }
4600
4601 impl TestWriter {
4602 fn new() -> Self {
4603 Self {
4604 content: RefCell::new(String::new()),
4605 ended: RefCell::new(false),
4606 }
4607 }
4608
4609 fn get_content(&self) -> String {
4610 self.content.borrow().clone()
4611 }
4612
4613 fn is_ended(&self) -> bool {
4614 *self.ended.borrow()
4615 }
4616 }
4617
4618 impl ResponseWriter for TestWriter {
4619 fn write(&mut self, content: &str) -> Result<()> {
4620 self.content.borrow_mut().push_str(content);
4621 Ok(())
4622 }
4623
4624 fn end(&mut self) -> Result<()> {
4625 *self.ended.borrow_mut() = true;
4626 Ok(())
4627 }
4628 }
4629
4630 #[test]
4631 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4632 struct AttributeWriter {
4633 output: String,
4634 }
4635
4636 impl ResponseWriter for AttributeWriter {
4637 fn write(&mut self, content: &str) -> Result<()> {
4638 self.output.push_str(content);
4639 Ok(())
4640 }
4641
4642 crate::string_response_writer_methods!(output);
4643
4644 fn end(&mut self) -> Result<()> {
4645 Ok(())
4646 }
4647 }
4648
4649 let mut writer = AttributeWriter {
4650 output: String::new(),
4651 };
4652 write_attr(&mut writer, "data-id", "42")
4653 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4654 writer
4655 .write_boolean_attribute("disabled")
4656 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4657 assert_eq!(writer.output, " data-id=\"42\" disabled");
4658 }
4659
4660 #[test]
4661 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4662 let mut fragments = HashMap::new();
4663 fragments.insert(
4664 "index.html".to_string(),
4665 FragmentList {
4666 fragments: vec![
4667 WebUIFragment {
4668 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4669 name: ":data-title".to_string(),
4670 value: "source".to_string(),
4671 complex: true,
4672 attr_start: true,
4673 ..Default::default()
4674 })),
4675 },
4676 WebUIFragment::component("prepared-child"),
4677 ],
4678 contains_boundary: false,
4679 },
4680 );
4681 fragments.insert(
4682 "prepared-child".to_string(),
4683 FragmentList {
4684 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4685 contains_boundary: false,
4686 },
4687 );
4688 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4689 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4690 let entry = resolved
4691 .list_by_id("index.html")
4692 .unwrap_or_else(|| panic!("entry render plan should exist"));
4693 let child_index = protocol
4694 .render_fragments()
4695 .index("prepared-child")
4696 .unwrap_or_else(|| panic!("component target should be indexed"));
4697 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4698 assert_eq!(entry.target(1), Some(child_index));
4699 assert!(!entry.has_routes);
4700 let source_entry = protocol
4701 .protocol()
4702 .fragments
4703 .get("index.html")
4704 .unwrap_or_else(|| panic!("source entry should exist"));
4705 assert!(std::ptr::eq(
4706 entry.fragments.as_ptr(),
4707 source_entry.fragments.as_ptr()
4708 ));
4709
4710 let state = test_json!({"source": "Prepared"});
4711 let options = RenderOptions::new("index.html", "/");
4712 for handler in [
4713 WebUIHandler::new(),
4714 WebUIHandler::with_plugin(|| {
4715 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4716 }),
4717 ] {
4718 let mut writer = TestWriter::new();
4719 assert!(handler
4720 .render(&protocol, &state, &options, &mut writer)
4721 .is_ok());
4722 assert!(writer.get_content().contains("Prepared"));
4723 }
4724 }
4725
4726 #[test]
4727 fn prepared_graph_records_route_presence_per_fragment_list() {
4728 let mut fragments = HashMap::new();
4729 fragments.insert(
4730 "index.html".to_string(),
4731 FragmentList {
4732 fragments: vec![WebUIFragment::route("/", "dash-page")],
4733 contains_boundary: false,
4734 },
4735 );
4736 fragments.insert(
4737 "dash-page".to_string(),
4738 FragmentList {
4739 fragments: vec![WebUIFragment::raw("dashboard")],
4740 contains_boundary: false,
4741 },
4742 );
4743 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4744 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4745 let entry = resolved
4746 .list_by_id("index.html")
4747 .unwrap_or_else(|| panic!("entry render plan should exist"));
4748 let page = resolved
4749 .list_by_id("dash-page")
4750 .unwrap_or_else(|| panic!("page render plan should exist"));
4751 assert!(entry.has_routes);
4753 assert!(!page.has_routes);
4754 }
4755
4756 #[test]
4757 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4758 let names = [
4762 "data-title",
4763 ":data-title",
4764 "aria-labelledby",
4765 "aria-valuemax",
4766 "readonly",
4767 "tabindex",
4768 "class",
4769 "for",
4770 "maxlength",
4771 "novalidate",
4772 "already-camel",
4773 "trailing-",
4774 "-leading",
4775 "multi-part-attribute-name",
4776 "single",
4777 "",
4778 ];
4779 for name in names {
4780 let stripped = name.strip_prefix(':').unwrap_or(name);
4781 assert_eq!(
4782 component_attr_name(name),
4783 webui_protocol::attrs::attribute_to_camel(stripped),
4784 "prepared name diverged for `{name}`"
4785 );
4786 }
4787 }
4788
4789 #[test]
4790 fn prepared_component_prop_names_match_per_branch_stripping() {
4791 let complex = WebUIFragmentAttribute {
4795 name: "::data-title".to_string(),
4796 value: "source".to_string(),
4797 complex: true,
4798 ..Default::default()
4799 };
4800 assert_eq!(component_attr_source(&complex), ":data-title");
4801 assert_eq!(
4802 component_attr_name(component_attr_source(&complex)),
4803 "dataTitle"
4804 );
4805
4806 let raw = WebUIFragmentAttribute {
4808 raw_value: true,
4809 ..complex.clone()
4810 };
4811 assert_eq!(component_attr_source(&raw), "::data-title");
4812
4813 let templated = WebUIFragmentAttribute {
4815 template: "tpl".to_string(),
4816 ..complex.clone()
4817 };
4818 assert_eq!(component_attr_source(&templated), "::data-title");
4819
4820 let empty_value = WebUIFragmentAttribute {
4822 value: String::new(),
4823 ..complex.clone()
4824 };
4825 assert_eq!(component_attr_source(&empty_value), "::data-title");
4826
4827 let ordinary = WebUIFragmentAttribute {
4829 name: ":data-title".to_string(),
4830 ..complex
4831 };
4832 assert_eq!(component_attr_source(&ordinary), "data-title");
4833 assert_eq!(
4834 component_attr_name(component_attr_source(&ordinary)),
4835 "dataTitle"
4836 );
4837 }
4838
4839 #[test]
4840 fn render_slots_match_continuation_slots() {
4841 let mut fragments = HashMap::new();
4846 for id in [
4847 "index.html",
4848 "zeta-last",
4849 "alpha-first",
4850 "middle-one",
4851 "middle-two",
4852 ] {
4853 fragments.insert(
4854 id.to_string(),
4855 FragmentList {
4856 fragments: vec![WebUIFragment::raw(id)],
4857 contains_boundary: false,
4858 },
4859 );
4860 }
4861 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4862 let index = protocol.render_fragments();
4863 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4864 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4865 {
4866 let id = protocol
4867 .fragment_id(slot)
4868 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4869 assert_eq!(
4870 index.index(id),
4871 Some(slot as usize),
4872 "render slot must equal continuation slot for `{id}`"
4873 );
4874 assert_eq!(protocol.fragment_slot(id), Some(slot));
4875 }
4876 }
4877
4878 #[test]
4879 fn prepared_graph_reports_missing_fragments_without_panicking() {
4880 let mut fragments = HashMap::new();
4881 fragments.insert(
4882 "index.html".to_string(),
4883 FragmentList {
4884 fragments: vec![WebUIFragment::component("absent-child")],
4885 contains_boundary: false,
4886 },
4887 );
4888 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4889 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4890 let entry = resolved
4891 .list_by_id("index.html")
4892 .unwrap_or_else(|| panic!("entry render plan should exist"));
4893 assert_eq!(entry.target(0), None);
4896 assert!(resolved.list_by_id("absent-child").is_none());
4897 assert!(resolved.list(usize::MAX).is_none());
4898
4899 let state = test_json!({});
4900 let options = RenderOptions::new("index.html", "/");
4901 let mut writer = TestWriter::new();
4902 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4903 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4904 other => panic!("expected a missing fragment error, got {other:?}"),
4905 }
4906
4907 let mut writer = TestWriter::new();
4908 match WebUIHandler::new().render(
4909 &protocol,
4910 &state,
4911 &RenderOptions::new("absent-entry", "/"),
4912 &mut writer,
4913 ) {
4914 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4915 other => panic!("expected a missing entry error, got {other:?}"),
4916 }
4917 assert!(protocol.has_fragment("index.html"));
4920 assert!(!protocol.has_fragment("absent-entry"));
4921 }
4922
4923 #[test]
4924 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4925 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4926 let mut fragments = HashMap::with_capacity(list_count);
4927 for index in 0..list_count {
4928 fragments.insert(
4929 format!("fragment-{index}"),
4930 FragmentList {
4931 fragments: vec![WebUIFragment::raw("content")],
4932 contains_boundary: false,
4933 },
4934 );
4935 }
4936 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4937 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4938 assert!(resolved.cache[0].get().is_none());
4940 assert!(resolved.spilled.get().is_none());
4941 assert!(resolved.list(0).is_some());
4942 assert!(resolved.cache[0].get().is_some());
4943 assert!(resolved.spilled.get().is_none());
4945 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4948 let spilled = resolved
4949 .spilled
4950 .get()
4951 .expect("a slot past the inline cache should allocate the spill region");
4952 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4953 assert!(spilled[0].get().is_some());
4954 assert!(resolved.list(list_count - 1).is_some());
4955
4956 for (id, source) in &protocol.protocol().fragments {
4957 let prepared = resolved
4958 .list_by_id(id)
4959 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4960 assert!(std::ptr::eq(
4962 prepared.fragments.as_ptr(),
4963 source.fragments.as_ptr()
4964 ));
4965 }
4966 }
4967
4968 #[test]
4969 fn test_handle_raw() {
4970 let mut fragments = HashMap::new();
4972 fragments.insert(
4973 "index.html".to_string(),
4974 FragmentList {
4975 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4976 contains_boundary: false,
4977 },
4978 );
4979
4980 let protocol = WebUIProtocol::new(fragments);
4981 let state = test_json!({});
4982
4983 let mut writer = TestWriter::new();
4985
4986 assert!(
4988 handle(
4989 &protocol,
4990 &state,
4991 &RenderOptions::new("index.html", "/"),
4992 &mut writer
4993 )
4994 .is_ok(),
4995 "Failed to handle raw protocol"
4996 );
4997
4998 assert_eq!(writer.get_content(), "Hello, WebUI!");
5000 assert!(writer.is_ended());
5001 }
5002
5003 #[test]
5004 fn test_handle_signal() {
5005 let mut fragments = HashMap::new();
5007 fragments.insert(
5008 "index.html".to_string(),
5009 FragmentList {
5010 fragments: vec![
5011 WebUIFragment::raw("Hello, "),
5012 WebUIFragment::signal("name", false),
5013 WebUIFragment::raw("!"),
5014 ],
5015 contains_boundary: false,
5016 },
5017 );
5018
5019 let protocol = WebUIProtocol::new(fragments);
5020 let state = test_json!({"name": "WebUI"});
5021
5022 let mut writer = TestWriter::new();
5024
5025 assert!(
5027 handle(
5028 &protocol,
5029 &state,
5030 &RenderOptions::new("index.html", "/"),
5031 &mut writer
5032 )
5033 .is_ok(),
5034 "Failed to handle signal protocol"
5035 );
5036
5037 assert_eq!(writer.get_content(), "Hello, WebUI!");
5039 assert!(writer.is_ended());
5040 }
5041
5042 #[test]
5043 fn test_handle_for_loop() {
5044 let mut fragments = HashMap::new();
5046 fragments.insert(
5047 "index.html".to_string(),
5048 FragmentList {
5049 fragments: vec![
5050 WebUIFragment::raw("People: "),
5051 WebUIFragment::for_loop("person", "people", "person-item"),
5052 ],
5053 contains_boundary: false,
5054 },
5055 );
5056
5057 fragments.insert(
5058 "person-item".to_string(),
5059 FragmentList {
5060 fragments: vec![
5061 WebUIFragment::signal("person.name", false),
5062 WebUIFragment::raw(", "),
5063 ],
5064 contains_boundary: false,
5065 },
5066 );
5067
5068 let protocol = WebUIProtocol::new(fragments);
5069 let state = test_json!({
5070 "people": [
5071 {"name": "Alice"},
5072 {"name": "Bob"},
5073 {"name": "Charlie"}
5074 ]
5075 });
5076
5077 let mut writer = TestWriter::new();
5079
5080 assert!(
5082 handle(
5083 &protocol,
5084 &state,
5085 &RenderOptions::new("index.html", "/"),
5086 &mut writer
5087 )
5088 .is_ok(),
5089 "Failed to handle for loop protocol"
5090 );
5091
5092 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5094 assert!(writer.is_ended());
5095 }
5096
5097 #[test]
5098 fn test_handle_if_condition() {
5099 let mut fragments = HashMap::new();
5101 fragments.insert(
5102 "index.html".to_string(),
5103 FragmentList {
5104 fragments: vec![
5105 WebUIFragment::raw("Status: "),
5106 WebUIFragment::if_cond(
5107 webui_protocol::ConditionExpr::identifier("isActive"),
5108 "active-content",
5109 ),
5110 WebUIFragment::raw("End"),
5111 ],
5112 contains_boundary: false,
5113 },
5114 );
5115
5116 fragments.insert(
5117 "active-content".to_string(),
5118 FragmentList {
5119 fragments: vec![WebUIFragment::raw("Active")],
5120 contains_boundary: false,
5121 },
5122 );
5123
5124 let protocol = WebUIProtocol::new(fragments);
5125
5126 let state_true = test_json!({"isActive": true});
5128 let mut writer_true = TestWriter::new();
5129 assert!(
5130 handle(
5131 &protocol,
5132 &state_true,
5133 &RenderOptions::new("index.html", "/"),
5134 &mut writer_true
5135 )
5136 .is_ok(),
5137 "Failed to handle if condition (true case)"
5138 );
5139 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5140 assert!(writer_true.is_ended());
5141
5142 let state_false = test_json!({"isActive": false});
5144 let mut writer_false = TestWriter::new();
5145 assert!(
5146 handle(
5147 &protocol,
5148 &state_false,
5149 &RenderOptions::new("index.html", "/"),
5150 &mut writer_false
5151 )
5152 .is_ok(),
5153 "Failed to handle if condition (false case)"
5154 );
5155 assert_eq!(writer_false.get_content(), "Status: End");
5156 assert!(writer_false.is_ended());
5157 }
5158
5159 #[test]
5160 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5161 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>"#;
5162 let mut parser = HtmlParser::new();
5163 parser
5164 .parse("index.html", source)
5165 .expect("parse missing-path condition fixture");
5166 let protocol = WebUIProtocol::new(parser.into_fragment_records());
5167 let state = test_json!({
5168 "items": [
5169 {"label": "Normal"},
5170 {"label": "Search", "searchPresentation": true}
5171 ]
5172 });
5173 let mut writer = TestWriter::new();
5174
5175 handle(
5176 &protocol,
5177 &state,
5178 &RenderOptions::new("index.html", "/"),
5179 &mut writer,
5180 )
5181 .expect("render missing-path condition fixture");
5182
5183 assert_eq!(
5184 writer.get_content(),
5185 "top-negated<span>Normal</span><mark>Search</mark>"
5186 );
5187 }
5188
5189 #[test]
5190 fn native_dynamic_attribute_does_not_leak_into_next_component() {
5191 let mut fragments = HashMap::new();
5192 fragments.insert(
5193 "index.html".to_string(),
5194 FragmentList {
5195 fragments: vec![
5196 WebUIFragment::raw("<div"),
5197 WebUIFragment {
5198 fragment: Some(web_ui_fragment::Fragment::Attribute(
5199 WebUIFragmentAttribute {
5200 name: "title".into(),
5201 value: "nativeTitle".into(),
5202 ..Default::default()
5203 },
5204 )),
5205 },
5206 WebUIFragment::raw("></div><my-comp>"),
5207 WebUIFragment::component("my-comp"),
5208 WebUIFragment::raw("</my-comp>"),
5209 ],
5210 contains_boundary: false,
5211 },
5212 );
5213 fragments.insert(
5214 "my-comp".to_string(),
5215 FragmentList {
5216 fragments: vec![WebUIFragment::signal("title", false)],
5217 contains_boundary: false,
5218 },
5219 );
5220 let protocol = WebUIProtocol::new(fragments);
5221 let state = test_json!({
5222 "nativeTitle": "native",
5223 "title": "global"
5224 });
5225 let mut writer = TestWriter::new();
5226 handle(
5227 &protocol,
5228 &state,
5229 &RenderOptions::new("index.html", "/"),
5230 &mut writer,
5231 )
5232 .unwrap_or_else(|error| panic!("render failed: {error}"));
5233 assert_eq!(
5234 writer.get_content(),
5235 "<div title=\"native\"></div><my-comp>global</my-comp>"
5236 );
5237 }
5238
5239 #[test]
5240 fn test_handle_component() {
5241 let mut fragments = HashMap::new();
5243 fragments.insert(
5244 "index.html".to_string(),
5245 FragmentList {
5246 fragments: vec![
5247 WebUIFragment::raw("Component: "),
5248 WebUIFragment::component("my-component"),
5249 ],
5250 contains_boundary: false,
5251 },
5252 );
5253
5254 fragments.insert(
5255 "my-component".to_string(),
5256 FragmentList {
5257 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5258 contains_boundary: false,
5259 },
5260 );
5261
5262 let protocol = WebUIProtocol::new(fragments);
5263 let state = test_json!({});
5264
5265 let mut writer = TestWriter::new();
5267
5268 assert!(
5270 handle(
5271 &protocol,
5272 &state,
5273 &RenderOptions::new("index.html", "/"),
5274 &mut writer
5275 )
5276 .is_ok(),
5277 "Failed to handle component protocol"
5278 );
5279
5280 assert_eq!(
5282 writer.get_content(),
5283 "Component: <div>Component Content</div>"
5284 );
5285 assert!(writer.is_ended());
5286 }
5287
5288 #[test]
5289 fn test_missing_fragment() {
5290 let mut fragments = HashMap::new();
5292 fragments.insert(
5293 "index.html".to_string(),
5294 FragmentList {
5295 fragments: vec![WebUIFragment::component("missing-component")],
5296 contains_boundary: false,
5297 },
5298 );
5299
5300 let protocol = WebUIProtocol::new(fragments);
5301 let state = test_json!({});
5302
5303 let mut writer = TestWriter::new();
5305
5306 let result = handle(
5308 &protocol,
5309 &state,
5310 &RenderOptions::new("index.html", "/"),
5311 &mut writer,
5312 );
5313
5314 assert!(result.is_err());
5316 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5317 assert_eq!(fragment_id, "missing-component");
5318 } else {
5319 panic!("Expected MissingFragment error");
5320 }
5321 }
5322
5323 #[test]
5324 fn test_missing_signal_renders_empty() {
5325 let mut fragments = HashMap::new();
5327 fragments.insert(
5328 "index.html".to_string(),
5329 FragmentList {
5330 fragments: vec![
5331 WebUIFragment::raw("Hello, "),
5332 WebUIFragment::signal("missing_field", false),
5333 WebUIFragment::raw("!"),
5334 ],
5335 contains_boundary: false,
5336 },
5337 );
5338
5339 let protocol = WebUIProtocol::new(fragments);
5340 let state = test_json!({});
5341
5342 let mut writer = TestWriter::new();
5343
5344 assert!(
5345 handle(
5346 &protocol,
5347 &state,
5348 &RenderOptions::new("index.html", "/"),
5349 &mut writer
5350 )
5351 .is_ok(),
5352 "Missing signal should not produce an error"
5353 );
5354
5355 assert_eq!(writer.get_content(), "Hello, !");
5356 assert!(writer.is_ended());
5357 }
5358
5359 #[test]
5362 fn test_boolean_attr_true() {
5363 let mut fragments = HashMap::new();
5364 fragments.insert(
5365 "index.html".to_string(),
5366 FragmentList {
5367 fragments: vec![
5368 WebUIFragment::raw("<button"),
5369 WebUIFragment::attribute_boolean(
5370 "disabled",
5371 ConditionExpr::identifier("isDisabled"),
5372 ),
5373 WebUIFragment::raw(">Click</button>"),
5374 ],
5375 contains_boundary: false,
5376 },
5377 );
5378 let protocol = WebUIProtocol::new(fragments);
5379 let state = test_json!({"isDisabled": true});
5380 let mut writer = TestWriter::new();
5381 handle(
5382 &protocol,
5383 &state,
5384 &RenderOptions::new("index.html", "/"),
5385 &mut writer,
5386 )
5387 .unwrap();
5388 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5389 }
5390
5391 #[test]
5392 fn test_boolean_attr_false() {
5393 let mut fragments = HashMap::new();
5394 fragments.insert(
5395 "index.html".to_string(),
5396 FragmentList {
5397 fragments: vec![
5398 WebUIFragment::raw("<button"),
5399 WebUIFragment::attribute_boolean(
5400 "disabled",
5401 ConditionExpr::identifier("isDisabled"),
5402 ),
5403 WebUIFragment::raw(">Click</button>"),
5404 ],
5405 contains_boundary: false,
5406 },
5407 );
5408 let protocol = WebUIProtocol::new(fragments);
5409 let state = test_json!({"isDisabled": false});
5410 let mut writer = TestWriter::new();
5411 handle(
5412 &protocol,
5413 &state,
5414 &RenderOptions::new("index.html", "/"),
5415 &mut writer,
5416 )
5417 .unwrap();
5418 assert_eq!(writer.get_content(), "<button>Click</button>");
5419 }
5420
5421 #[test]
5422 fn test_boolean_attr_missing() {
5423 let mut fragments = HashMap::new();
5424 fragments.insert(
5425 "index.html".to_string(),
5426 FragmentList {
5427 fragments: vec![
5428 WebUIFragment::raw("<input type=\"checkbox\""),
5429 WebUIFragment::attribute_boolean(
5430 "checked",
5431 ConditionExpr::identifier("checked"),
5432 ),
5433 WebUIFragment::raw(">"),
5434 ],
5435 contains_boundary: false,
5436 },
5437 );
5438 let protocol = WebUIProtocol::new(fragments);
5439 let state = test_json!({});
5440 let mut writer = TestWriter::new();
5441 handle(
5442 &protocol,
5443 &state,
5444 &RenderOptions::new("index.html", "/"),
5445 &mut writer,
5446 )
5447 .unwrap();
5448 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5449 }
5450
5451 #[test]
5452 fn test_boolean_attr_multiple() {
5453 let mut fragments = HashMap::new();
5454 fragments.insert(
5455 "index.html".to_string(),
5456 FragmentList {
5457 fragments: vec![
5458 WebUIFragment::raw("<input type=\"checkbox\""),
5459 WebUIFragment::attribute_boolean(
5460 "checked",
5461 ConditionExpr::identifier("checked"),
5462 ),
5463 WebUIFragment::attribute_boolean(
5464 "disabled",
5465 ConditionExpr::identifier("disabled"),
5466 ),
5467 WebUIFragment::raw(">"),
5468 ],
5469 contains_boundary: false,
5470 },
5471 );
5472 let protocol = WebUIProtocol::new(fragments);
5473 let state = test_json!({"checked": true, "disabled": false});
5474 let mut writer = TestWriter::new();
5475 handle(
5476 &protocol,
5477 &state,
5478 &RenderOptions::new("index.html", "/"),
5479 &mut writer,
5480 )
5481 .unwrap();
5482 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5483 }
5484
5485 #[test]
5488 fn test_attribute_with_value() {
5489 let mut fragments = HashMap::new();
5490 fragments.insert(
5491 "index.html".to_string(),
5492 FragmentList {
5493 fragments: vec![
5494 WebUIFragment::raw("<input"),
5495 WebUIFragment::attribute("value", "inputValue"),
5496 WebUIFragment::raw(">"),
5497 ],
5498 contains_boundary: false,
5499 },
5500 );
5501 let protocol = WebUIProtocol::new(fragments);
5502 let state = test_json!({"inputValue": "Hello"});
5503 let mut writer = TestWriter::new();
5504 handle(
5505 &protocol,
5506 &state,
5507 &RenderOptions::new("index.html", "/"),
5508 &mut writer,
5509 )
5510 .unwrap();
5511 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5512 }
5513
5514 #[test]
5515 fn test_attribute_with_falsy_numeric() {
5516 let mut fragments = HashMap::new();
5517 fragments.insert(
5518 "index.html".to_string(),
5519 FragmentList {
5520 fragments: vec![
5521 WebUIFragment::raw("<div name=\"test\""),
5522 WebUIFragment::attribute("handle", "number"),
5523 WebUIFragment::raw("></div>"),
5524 ],
5525 contains_boundary: false,
5526 },
5527 );
5528 let protocol = WebUIProtocol::new(fragments);
5529 let state = test_json!({"number": 0});
5530 let mut writer = TestWriter::new();
5531 handle(
5532 &protocol,
5533 &state,
5534 &RenderOptions::new("index.html", "/"),
5535 &mut writer,
5536 )
5537 .unwrap();
5538 assert_eq!(
5539 writer.get_content(),
5540 "<div name=\"test\" handle=\"0\"></div>"
5541 );
5542 }
5543
5544 #[test]
5547 fn test_attribute_array_value_is_escaped() {
5548 let mut fragments = HashMap::new();
5549 fragments.insert(
5550 "index.html".to_string(),
5551 FragmentList {
5552 fragments: vec![
5553 WebUIFragment::raw("<a"),
5554 WebUIFragment::attribute("href", "value"),
5555 WebUIFragment::raw(">demo</a>"),
5556 ],
5557 contains_boundary: false,
5558 },
5559 );
5560 let protocol = WebUIProtocol::new(fragments);
5561 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5562 let mut writer = TestWriter::new();
5563 handle(
5564 &protocol,
5565 &state,
5566 &RenderOptions::new("index.html", "/"),
5567 &mut writer,
5568 )
5569 .unwrap();
5570 let content = writer.get_content();
5571 assert!(
5574 content.contains("""),
5575 "Double quotes inside attribute value must be escaped: {content}"
5576 );
5577 assert_eq!(
5580 content.matches("=\"").count(),
5581 1,
5582 "Only one attribute assignment expected: {content}"
5583 );
5584 }
5585
5586 #[test]
5587 fn test_attribute_object_value_is_escaped() {
5588 let mut fragments = HashMap::new();
5589 fragments.insert(
5590 "index.html".to_string(),
5591 FragmentList {
5592 fragments: vec![
5593 WebUIFragment::raw("<div"),
5594 WebUIFragment::attribute("data-cfg", "cfg"),
5595 WebUIFragment::raw("></div>"),
5596 ],
5597 contains_boundary: false,
5598 },
5599 );
5600 let protocol = WebUIProtocol::new(fragments);
5601 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5602 let mut writer = TestWriter::new();
5603 handle(
5604 &protocol,
5605 &state,
5606 &RenderOptions::new("index.html", "/"),
5607 &mut writer,
5608 )
5609 .unwrap();
5610 let content = writer.get_content();
5611 assert!(
5612 content.contains("""),
5613 "Double quotes inside attribute value must be escaped: {content}"
5614 );
5615 assert_eq!(
5616 content.matches("=\"").count(),
5617 1,
5618 "Only one attribute assignment expected: {content}"
5619 );
5620 }
5621
5622 #[test]
5625 fn test_mixed_attribute_template() {
5626 let mut fragments = HashMap::new();
5627 fragments.insert(
5628 "index.html".to_string(),
5629 FragmentList {
5630 fragments: vec![
5631 WebUIFragment::raw("<input"),
5632 WebUIFragment::attribute_template("value", "attr-1"),
5633 WebUIFragment::raw(">"),
5634 ],
5635 contains_boundary: false,
5636 },
5637 );
5638 fragments.insert(
5639 "attr-1".to_string(),
5640 FragmentList {
5641 fragments: vec![
5642 WebUIFragment::raw("hello "),
5643 WebUIFragment::signal("item", false),
5644 ],
5645 contains_boundary: false,
5646 },
5647 );
5648 let protocol = WebUIProtocol::new(fragments);
5649 let state = test_json!({"item": "world"});
5650 let mut writer = TestWriter::new();
5651 handle(
5652 &protocol,
5653 &state,
5654 &RenderOptions::new("index.html", "/"),
5655 &mut writer,
5656 )
5657 .unwrap();
5658 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5659 }
5660
5661 #[test]
5664 fn test_raw_signal_not_escaped() {
5665 let mut fragments = HashMap::new();
5666 fragments.insert(
5667 "index.html".to_string(),
5668 FragmentList {
5669 fragments: vec![
5670 WebUIFragment::signal("html", false),
5671 WebUIFragment::signal("html", true),
5672 ],
5673 contains_boundary: false,
5674 },
5675 );
5676 let protocol = WebUIProtocol::new(fragments);
5677 let state = test_json!({"html": "<strong>hi</strong>"});
5678 let mut writer = TestWriter::new();
5679 handle(
5680 &protocol,
5681 &state,
5682 &RenderOptions::new("index.html", "/"),
5683 &mut writer,
5684 )
5685 .unwrap();
5686 assert_eq!(
5687 writer.get_content(),
5688 "<strong>hi</strong><strong>hi</strong>"
5689 );
5690 }
5691
5692 fn render_named_for(template: &str, state: &Value) -> String {
5695 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5696 parser.parse("index.html", template).unwrap();
5697 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5698 let mut writer = TestWriter::new();
5699 WebUIHandler::new()
5700 .render(
5701 &protocol,
5702 state,
5703 &RenderOptions::new("index.html", "/"),
5704 &mut writer,
5705 )
5706 .unwrap();
5707 writer.get_content()
5708 }
5709
5710 #[test]
5711 fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5712 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>"#;
5713 let state = test_json!({
5714 "child": { "name": "global" },
5715 "items": [
5716 { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5717 { "name": "Name", "children": [] },
5718 { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5719 ]
5720 });
5721 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>");
5722 }
5723
5724 #[test]
5725 fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5726 let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5727 let state = test_json!({
5728 "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5729 });
5730 assert_eq!(render_named_for(source, &state), "abbcca");
5731 assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5732 }
5733
5734 #[test]
5735 fn named_for_forward_and_mutual_references_follow_finite_data() {
5736 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>"#;
5737 let state = test_json!({
5738 "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5739 "empty": []
5740 });
5741 assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5742 }
5743
5744 #[test]
5745 fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5746 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>"#;
5747 let state = test_json!({
5748 "groups": [
5749 { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5750 { "name": "two", "items": [{ "name": "last" }] }
5751 ]
5752 });
5753 assert_eq!(
5754 render_named_for(source, &state),
5755 "one:<root>;one:&leaf;two:last;"
5756 );
5757 }
5758
5759 #[test]
5760 fn named_for_non_array_collection_returns_the_existing_type_error() {
5761 let mut parser = HtmlParser::new();
5762 parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5763 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5764 let mut writer = TestWriter::new();
5765 let error = WebUIHandler::new()
5766 .render(
5767 &protocol,
5768 &test_json!({"items": [{"children": 1}]}),
5769 &RenderOptions::new("index.html", "/"),
5770 &mut writer,
5771 )
5772 .unwrap_err();
5773 assert!(matches!(error, HandlerError::TypeError(_)));
5774 }
5775
5776 #[test]
5777 fn test_nested_for_loop() {
5778 let mut fragments = HashMap::new();
5779 fragments.insert(
5780 "index.html".to_string(),
5781 FragmentList {
5782 fragments: vec![
5783 WebUIFragment::raw("<div>"),
5784 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5785 WebUIFragment::raw("</div>"),
5786 ],
5787 contains_boundary: false,
5788 },
5789 );
5790 fragments.insert(
5791 "outer".to_string(),
5792 FragmentList {
5793 fragments: vec![
5794 WebUIFragment::raw("<div>"),
5795 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5796 WebUIFragment::raw("</div>"),
5797 ],
5798 contains_boundary: false,
5799 },
5800 );
5801 fragments.insert(
5802 "inner".to_string(),
5803 FragmentList {
5804 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5805 contains_boundary: false,
5806 },
5807 );
5808 let protocol = WebUIProtocol::new(fragments);
5809 let state = test_json!({
5810 "outerItems": [
5811 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5812 {"innerItems": [{"name": "C"}]}
5813 ]
5814 });
5815 let mut writer = TestWriter::new();
5816 handle(
5817 &protocol,
5818 &state,
5819 &RenderOptions::new("index.html", "/"),
5820 &mut writer,
5821 )
5822 .unwrap();
5823 assert_eq!(
5824 writer.get_content(),
5825 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5826 );
5827 }
5828
5829 #[test]
5830 fn test_nested_for_with_signals() {
5831 let mut fragments = HashMap::new();
5832 fragments.insert(
5833 "index.html".to_string(),
5834 FragmentList {
5835 fragments: vec![
5836 WebUIFragment::raw("<div>"),
5837 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5838 WebUIFragment::raw("</div>"),
5839 ],
5840 contains_boundary: false,
5841 },
5842 );
5843 fragments.insert(
5844 "outerTemplate".to_string(),
5845 FragmentList {
5846 fragments: vec![
5847 WebUIFragment::raw("<div>"),
5848 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5849 WebUIFragment::raw("</div>"),
5850 ],
5851 contains_boundary: false,
5852 },
5853 );
5854 fragments.insert(
5855 "innerTemplate".to_string(),
5856 FragmentList {
5857 fragments: vec![
5858 WebUIFragment::raw("<span>"),
5859 WebUIFragment::signal("innerItem.name", false),
5860 WebUIFragment::raw("</span>"),
5861 ],
5862 contains_boundary: false,
5863 },
5864 );
5865 let protocol = WebUIProtocol::new(fragments);
5866 let state = test_json!({
5867 "outerItems": [
5868 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5869 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5870 ]
5871 });
5872 let mut writer = TestWriter::new();
5873 handle(
5874 &protocol,
5875 &state,
5876 &RenderOptions::new("index.html", "/"),
5877 &mut writer,
5878 )
5879 .unwrap();
5880 assert_eq!(
5881 writer.get_content(),
5882 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5883 );
5884 }
5885
5886 #[test]
5887 fn test_nested_for_with_global_state() {
5888 let mut fragments = HashMap::new();
5889 fragments.insert(
5890 "index.html".to_string(),
5891 FragmentList {
5892 fragments: vec![
5893 WebUIFragment::raw("<div>"),
5894 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5895 WebUIFragment::raw("</div>"),
5896 ],
5897 contains_boundary: false,
5898 },
5899 );
5900 fragments.insert(
5901 "outerTemplate".to_string(),
5902 FragmentList {
5903 fragments: vec![
5904 WebUIFragment::raw("<div>"),
5905 WebUIFragment::signal("globalOuter", false),
5906 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5907 WebUIFragment::raw("</div>"),
5908 ],
5909 contains_boundary: false,
5910 },
5911 );
5912 fragments.insert(
5913 "innerTemplate".to_string(),
5914 FragmentList {
5915 fragments: vec![
5916 WebUIFragment::raw("<span>"),
5917 WebUIFragment::signal("innerItem.name", false),
5918 WebUIFragment::signal("globalInner", false),
5919 WebUIFragment::raw("</span>"),
5920 ],
5921 contains_boundary: false,
5922 },
5923 );
5924 let protocol = WebUIProtocol::new(fragments);
5925 let state = test_json!({
5926 "globalOuter": "GO",
5927 "globalInner": "GI",
5928 "outerItems": [
5929 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5930 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5931 ]
5932 });
5933 let mut writer = TestWriter::new();
5934 handle(
5935 &protocol,
5936 &state,
5937 &RenderOptions::new("index.html", "/"),
5938 &mut writer,
5939 )
5940 .unwrap();
5941 assert_eq!(
5942 writer.get_content(),
5943 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5944 );
5945 }
5946
5947 #[test]
5950 fn test_if_in_for_uses_local_state() {
5951 let mut fragments = HashMap::new();
5952 fragments.insert(
5953 "index.html".to_string(),
5954 FragmentList {
5955 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5956 contains_boundary: false,
5957 },
5958 );
5959 fragments.insert(
5960 "item-tpl".to_string(),
5961 FragmentList {
5962 fragments: vec![WebUIFragment::if_cond(
5963 ConditionExpr::identifier("item.visible"),
5964 "visible-tpl",
5965 )],
5966 contains_boundary: false,
5967 },
5968 );
5969 fragments.insert(
5970 "visible-tpl".to_string(),
5971 FragmentList {
5972 fragments: vec![WebUIFragment::signal("item.name", false)],
5973 contains_boundary: false,
5974 },
5975 );
5976 let protocol = WebUIProtocol::new(fragments);
5977 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5978 let mut writer = TestWriter::new();
5979 handle(
5980 &protocol,
5981 &state,
5982 &RenderOptions::new("index.html", "/"),
5983 &mut writer,
5984 )
5985 .unwrap();
5986 assert_eq!(writer.get_content(), "Show");
5987 }
5988
5989 #[test]
5990 fn test_for_if_local_overrides_global() {
5991 let mut fragments = HashMap::new();
5992 fragments.insert(
5993 "index.html".to_string(),
5994 FragmentList {
5995 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5996 contains_boundary: false,
5997 },
5998 );
5999 fragments.insert(
6000 "item-tpl".to_string(),
6001 FragmentList {
6002 fragments: vec![WebUIFragment::if_cond(
6003 ConditionExpr::identifier("item.flag"),
6004 "show-tpl",
6005 )],
6006 contains_boundary: false,
6007 },
6008 );
6009 fragments.insert(
6010 "show-tpl".to_string(),
6011 FragmentList {
6012 fragments: vec![WebUIFragment::raw("yes")],
6013 contains_boundary: false,
6014 },
6015 );
6016 let protocol = WebUIProtocol::new(fragments);
6017 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6019 let mut writer = TestWriter::new();
6020 handle(
6021 &protocol,
6022 &state,
6023 &RenderOptions::new("index.html", "/"),
6024 &mut writer,
6025 )
6026 .unwrap();
6027 assert_eq!(writer.get_content(), "yes");
6028 }
6029
6030 #[test]
6033 fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6034 let cases = [
6035 (
6036 r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6037 test_json!({}),
6038 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6039 ),
6040 (
6041 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6042 test_json!({"open": true}),
6043 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6044 ),
6045 (
6046 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6047 test_json!({"open": false}),
6048 r#"<dialog aria-label="Canvas information"></dialog>"#,
6049 ),
6050 (
6051 r#"<my-drawer aria-label=""></my-drawer>"#,
6052 test_json!({"ariaLabel": "Fallback"}),
6053 r#"<dialog aria-label=""></dialog>"#,
6054 ),
6055 (
6056 r#"<my-drawer open aria-label="Canvas & information"></my-drawer>"#,
6057 test_json!({}),
6058 r#"<dialog open aria-label="Canvas & information"></dialog>"#,
6059 ),
6060 ];
6061 for (input, state, expected) in cases {
6062 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6063 parser
6064 .component_registry_mut()
6065 .register_component(ComponentRegistration::new(
6066 "my-drawer",
6067 r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6068 None,
6069 true,
6070 ))
6071 .unwrap();
6072 parser.parse("index.html", input).unwrap();
6073 let protocol = WebUIProtocol::new(parser.into_fragment_records());
6074 let mut writer = TestWriter::new();
6075 handle(
6076 &protocol,
6077 &state,
6078 &RenderOptions::new("index.html", "/"),
6079 &mut writer,
6080 )
6081 .unwrap();
6082 assert!(
6083 writer.get_content().contains(expected),
6084 "{input}: {}",
6085 writer.get_content()
6086 );
6087 }
6088 }
6089
6090 #[test]
6091 fn test_component_attr_state_simple() {
6092 let mut fragments = HashMap::new();
6093 fragments.insert(
6094 "index.html".to_string(),
6095 FragmentList {
6096 fragments: vec![
6097 WebUIFragment::raw("<my-comp"),
6098 WebUIFragment {
6099 fragment: Some(web_ui_fragment::Fragment::Attribute(
6100 WebUIFragmentAttribute {
6101 name: "title".into(),
6102 value: "Attribute Title".into(),
6103 attr_start: true,
6104 raw_value: true,
6105 ..Default::default()
6106 },
6107 )),
6108 },
6109 WebUIFragment::raw(">"),
6110 WebUIFragment::component("my-comp"),
6111 WebUIFragment::raw("</my-comp>"),
6112 ],
6113
6114 contains_boundary: false,
6115 },
6116 );
6117 fragments.insert(
6118 "my-comp".to_string(),
6119 FragmentList {
6120 fragments: vec![
6121 WebUIFragment::raw("<span>"),
6122 WebUIFragment::signal("title", false),
6123 WebUIFragment::raw("</span>"),
6124 ],
6125 contains_boundary: false,
6126 },
6127 );
6128 let protocol = WebUIProtocol::new(fragments);
6129 let state = test_json!({"title": "Global Title"});
6130 let mut writer = TestWriter::new();
6131 handle(
6132 &protocol,
6133 &state,
6134 &RenderOptions::new("index.html", "/"),
6135 &mut writer,
6136 )
6137 .unwrap();
6138 assert_eq!(
6139 writer.get_content(),
6140 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6141 );
6142 }
6143
6144 #[test]
6145 fn test_component_attr_state_template() {
6146 let mut fragments = HashMap::new();
6147 fragments.insert(
6148 "index.html".to_string(),
6149 FragmentList {
6150 fragments: vec![
6151 WebUIFragment::raw("<my-comp"),
6152 WebUIFragment {
6153 fragment: Some(web_ui_fragment::Fragment::Attribute(
6154 WebUIFragmentAttribute {
6155 name: "title".into(),
6156 template: "title-attr".into(),
6157 attr_start: true,
6158 ..Default::default()
6159 },
6160 )),
6161 },
6162 WebUIFragment::raw(">"),
6163 WebUIFragment::component("my-comp"),
6164 WebUIFragment::raw("</my-comp>"),
6165 ],
6166
6167 contains_boundary: false,
6168 },
6169 );
6170 fragments.insert(
6171 "title-attr".to_string(),
6172 FragmentList {
6173 fragments: vec![
6174 WebUIFragment::raw("hello "),
6175 WebUIFragment::signal("item", false),
6176 ],
6177 contains_boundary: false,
6178 },
6179 );
6180 fragments.insert(
6181 "my-comp".to_string(),
6182 FragmentList {
6183 fragments: vec![
6184 WebUIFragment::raw("<span>"),
6185 WebUIFragment::signal("title", false),
6186 WebUIFragment::raw("</span>"),
6187 ],
6188 contains_boundary: false,
6189 },
6190 );
6191 let protocol = WebUIProtocol::new(fragments);
6192 let state = test_json!({"item": "<world>"});
6193 let mut writer = TestWriter::new();
6194 handle(
6195 &protocol,
6196 &state,
6197 &RenderOptions::new("index.html", "/"),
6198 &mut writer,
6199 )
6200 .unwrap();
6201 assert_eq!(
6202 writer.get_content(),
6203 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
6204 );
6205 }
6206
6207 #[test]
6208 fn test_component_attr_camel_case() {
6209 let mut fragments = HashMap::new();
6210 fragments.insert(
6211 "index.html".to_string(),
6212 FragmentList {
6213 fragments: vec![
6214 WebUIFragment::raw("<my-comp"),
6215 WebUIFragment {
6216 fragment: Some(web_ui_fragment::Fragment::Attribute(
6217 WebUIFragmentAttribute {
6218 name: "data-title".into(),
6219 template: "dt-attr".into(),
6220 attr_start: true,
6221 ..Default::default()
6222 },
6223 )),
6224 },
6225 WebUIFragment::raw(">"),
6226 WebUIFragment::component("my-comp"),
6227 WebUIFragment::raw("</my-comp>"),
6228 ],
6229
6230 contains_boundary: false,
6231 },
6232 );
6233 fragments.insert(
6234 "dt-attr".to_string(),
6235 FragmentList {
6236 fragments: vec![
6237 WebUIFragment::raw("prefix "),
6238 WebUIFragment::signal("item", false),
6239 ],
6240 contains_boundary: false,
6241 },
6242 );
6243 fragments.insert(
6244 "my-comp".to_string(),
6245 FragmentList {
6246 fragments: vec![
6247 WebUIFragment::raw("<span>"),
6248 WebUIFragment::signal("dataTitle", false),
6249 WebUIFragment::raw("</span>"),
6250 ],
6251 contains_boundary: false,
6252 },
6253 );
6254 let protocol = WebUIProtocol::new(fragments);
6255 let state = test_json!({"item": "a&b"});
6256 let mut writer = TestWriter::new();
6257 handle(
6258 &protocol,
6259 &state,
6260 &RenderOptions::new("index.html", "/"),
6261 &mut writer,
6262 )
6263 .unwrap();
6264 assert_eq!(
6265 writer.get_content(),
6266 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
6267 );
6268 }
6269
6270 #[test]
6271 fn test_component_complex_attr() {
6272 let mut fragments = HashMap::new();
6273 fragments.insert(
6274 "index.html".to_string(),
6275 FragmentList {
6276 fragments: vec![
6277 WebUIFragment::raw("<my-comp"),
6278 WebUIFragment {
6279 fragment: Some(web_ui_fragment::Fragment::Attribute(
6280 WebUIFragmentAttribute {
6281 name: ":item".into(),
6282 value: "complexItem".into(),
6283 attr_start: true,
6284 complex: true,
6285 ..Default::default()
6286 },
6287 )),
6288 },
6289 WebUIFragment::raw(">"),
6290 WebUIFragment::component("my-comp"),
6291 WebUIFragment::raw("</my-comp>"),
6292 ],
6293
6294 contains_boundary: false,
6295 },
6296 );
6297 fragments.insert(
6298 "my-comp".to_string(),
6299 FragmentList {
6300 fragments: vec![
6301 WebUIFragment::raw("<span>"),
6302 WebUIFragment::signal("item.foo", false),
6303 WebUIFragment::raw("</span><p>"),
6304 WebUIFragment::signal("item.bar", false),
6305 WebUIFragment::raw("</p>"),
6306 ],
6307 contains_boundary: false,
6308 },
6309 );
6310 let protocol = WebUIProtocol::new(fragments);
6311 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6312 let mut writer = TestWriter::new();
6313 handle(
6314 &protocol,
6315 &state,
6316 &RenderOptions::new("index.html", "/"),
6317 &mut writer,
6318 )
6319 .unwrap();
6320 assert_eq!(
6321 writer.get_content(),
6322 "<my-comp><span>1</span><p>true</p></my-comp>"
6323 );
6324 }
6325
6326 #[test]
6327 fn test_component_no_parent_pollution() {
6328 let mut fragments = HashMap::new();
6329 fragments.insert(
6330 "index.html".to_string(),
6331 FragmentList {
6332 fragments: vec![
6333 WebUIFragment::raw("<parent"),
6334 WebUIFragment {
6335 fragment: Some(web_ui_fragment::Fragment::Attribute(
6336 WebUIFragmentAttribute {
6337 name: "var".into(),
6338 value: "var".into(),
6339 attr_start: true,
6340 ..Default::default()
6341 },
6342 )),
6343 },
6344 WebUIFragment::raw(">"),
6345 WebUIFragment::component("parent"),
6346 WebUIFragment::raw("</parent>"),
6347 ],
6348
6349 contains_boundary: false,
6350 },
6351 );
6352 fragments.insert(
6353 "parent".to_string(),
6354 FragmentList {
6355 fragments: vec![
6356 WebUIFragment::raw("Before: "),
6357 WebUIFragment::signal("var", false),
6358 WebUIFragment::raw("<child foo"),
6359 WebUIFragment {
6360 fragment: Some(web_ui_fragment::Fragment::Attribute(
6361 WebUIFragmentAttribute {
6362 name: "var".into(),
6363 value: "replaced".into(),
6364 raw_value: true,
6365 ..Default::default()
6366 },
6367 )),
6368 },
6369 WebUIFragment::raw(">"),
6370 WebUIFragment::component("child"),
6371 WebUIFragment::raw("Label</child>After: "),
6372 WebUIFragment::signal("var", false),
6373 ],
6374
6375 contains_boundary: false,
6376 },
6377 );
6378 fragments.insert(
6379 "child".to_string(),
6380 FragmentList {
6381 fragments: vec![],
6382 contains_boundary: false,
6383 },
6384 );
6385 let protocol = WebUIProtocol::new(fragments);
6386 let state = test_json!({"var": "original<&"});
6387 let mut writer = TestWriter::new();
6388 handle(
6389 &protocol,
6390 &state,
6391 &RenderOptions::new("index.html", "/"),
6392 &mut writer,
6393 )
6394 .unwrap();
6395 assert_eq!(
6396 writer.get_content(),
6397 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
6398 );
6399 }
6400
6401 #[test]
6402 fn test_component_boolean_attr_state() {
6403 let mut fragments = HashMap::new();
6404 fragments.insert(
6405 "index.html".to_string(),
6406 FragmentList {
6407 fragments: vec![
6408 WebUIFragment::raw("<my-comp"),
6409 WebUIFragment {
6410 fragment: Some(web_ui_fragment::Fragment::Attribute(
6411 WebUIFragmentAttribute {
6412 name: "disabled".into(),
6413 attr_start: true,
6414 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6415 ..Default::default()
6416 },
6417 )),
6418 },
6419 WebUIFragment::raw(">"),
6420 WebUIFragment::component("my-comp"),
6421 WebUIFragment::raw("</my-comp>"),
6422 ],
6423
6424 contains_boundary: false,
6425 },
6426 );
6427 fragments.insert(
6428 "my-comp".to_string(),
6429 FragmentList {
6430 fragments: vec![WebUIFragment::if_cond(
6431 ConditionExpr::identifier("disabled"),
6432 "show",
6433 )],
6434 contains_boundary: false,
6435 },
6436 );
6437 fragments.insert(
6438 "show".to_string(),
6439 FragmentList {
6440 fragments: vec![WebUIFragment::raw("disabled!")],
6441 contains_boundary: false,
6442 },
6443 );
6444 let protocol = WebUIProtocol::new(fragments);
6445 let state = test_json!({"isDisabled": true});
6446 let mut writer = TestWriter::new();
6447 handle(
6448 &protocol,
6449 &state,
6450 &RenderOptions::new("index.html", "/"),
6451 &mut writer,
6452 )
6453 .unwrap();
6454 assert_eq!(
6455 writer.get_content(),
6456 "<my-comp disabled>disabled!</my-comp>"
6457 );
6458 }
6459
6460 fn render_signal(value: &str) -> String {
6464 let mut fragments = HashMap::new();
6465 fragments.insert(
6466 "index.html".to_string(),
6467 FragmentList {
6468 fragments: vec![WebUIFragment::signal("v", false)],
6469 contains_boundary: false,
6470 },
6471 );
6472 let protocol = WebUIProtocol::new(fragments);
6473 let state = test_json!({"v": value});
6474 let mut writer = TestWriter::new();
6475 handle(
6476 &protocol,
6477 &state,
6478 &RenderOptions::new("index.html", "/"),
6479 &mut writer,
6480 )
6481 .unwrap();
6482 writer.get_content()
6483 }
6484
6485 #[test]
6486 fn test_escape_ampersand() {
6487 assert_eq!(render_signal("&"), "&");
6488 }
6489
6490 #[test]
6491 fn test_escape_less_than() {
6492 assert_eq!(render_signal("<"), "<");
6493 }
6494
6495 #[test]
6496 fn test_escape_greater_than() {
6497 assert_eq!(render_signal(">"), ">");
6498 }
6499
6500 #[test]
6501 fn test_escape_double_quote() {
6502 assert_eq!(render_signal("\""), """);
6503 }
6504
6505 #[test]
6506 fn test_escape_single_quote() {
6507 let result = render_signal("'");
6509 assert_eq!(
6510 result, "'",
6511 "Expected ' for single quote, got: {result}"
6512 );
6513 }
6514
6515 #[test]
6516 fn test_escape_multiple_special_chars() {
6517 let result = render_signal("<script>alert('xss');</script>");
6518 assert!(
6519 result.contains("<") && result.contains(">"),
6520 "Expected escaped HTML, got: {}",
6521 result
6522 );
6523 assert!(
6524 !result.contains("<script>"),
6525 "Should not contain raw <script> tag"
6526 );
6527 }
6528
6529 #[test]
6530 fn test_escape_no_special_chars() {
6531 assert_eq!(render_signal("Hello World"), "Hello World");
6532 }
6533
6534 #[test]
6535 fn test_escape_empty_string() {
6536 assert_eq!(render_signal(""), "");
6537 }
6538
6539 #[test]
6540 fn test_escape_special_at_beginning() {
6541 let result = render_signal("<Hello");
6542 assert!(
6543 result.starts_with("<"),
6544 "Expected < at start, got: {}",
6545 result
6546 );
6547 }
6548
6549 #[test]
6550 fn test_escape_special_at_end() {
6551 let result = render_signal("Hello>");
6552 assert!(
6553 result.ends_with(">"),
6554 "Expected > at end, got: {}",
6555 result
6556 );
6557 }
6558
6559 #[test]
6560 fn test_escape_special_in_middle() {
6561 let result = render_signal("Hel&lo");
6562 assert!(
6563 result.contains("&"),
6564 "Expected & in middle, got: {}",
6565 result
6566 );
6567 }
6568
6569 #[test]
6572 fn test_boolean_attr_truthy_values() {
6573 {
6575 let mut fragments = HashMap::new();
6576 fragments.insert(
6577 "index.html".to_string(),
6578 FragmentList {
6579 fragments: vec![
6580 WebUIFragment::raw("<input"),
6581 WebUIFragment::attribute_boolean(
6582 "checked",
6583 ConditionExpr::identifier("checked"),
6584 ),
6585 WebUIFragment::raw(">"),
6586 ],
6587 contains_boundary: false,
6588 },
6589 );
6590 let protocol = WebUIProtocol::new(fragments);
6591 let state = test_json!({"checked": 1});
6592 let mut writer = TestWriter::new();
6593 handle(
6594 &protocol,
6595 &state,
6596 &RenderOptions::new("index.html", "/"),
6597 &mut writer,
6598 )
6599 .unwrap();
6600 assert_eq!(writer.get_content(), "<input checked>");
6601 }
6602 {
6604 let mut fragments = HashMap::new();
6605 fragments.insert(
6606 "index.html".to_string(),
6607 FragmentList {
6608 fragments: vec![
6609 WebUIFragment::raw("<input"),
6610 WebUIFragment::attribute_boolean(
6611 "checked",
6612 ConditionExpr::identifier("checked"),
6613 ),
6614 WebUIFragment::raw(">"),
6615 ],
6616 contains_boundary: false,
6617 },
6618 );
6619 let protocol = WebUIProtocol::new(fragments);
6620 let state = test_json!({"checked": "yes"});
6621 let mut writer = TestWriter::new();
6622 handle(
6623 &protocol,
6624 &state,
6625 &RenderOptions::new("index.html", "/"),
6626 &mut writer,
6627 )
6628 .unwrap();
6629 assert_eq!(writer.get_content(), "<input checked>");
6630 }
6631 {
6633 let mut fragments = HashMap::new();
6634 fragments.insert(
6635 "index.html".to_string(),
6636 FragmentList {
6637 fragments: vec![
6638 WebUIFragment::raw("<input"),
6639 WebUIFragment::attribute_boolean(
6640 "checked",
6641 ConditionExpr::identifier("checked"),
6642 ),
6643 WebUIFragment::raw(">"),
6644 ],
6645 contains_boundary: false,
6646 },
6647 );
6648 let protocol = WebUIProtocol::new(fragments);
6649 let state = test_json!({"checked": {}});
6650 let mut writer = TestWriter::new();
6651 handle(
6652 &protocol,
6653 &state,
6654 &RenderOptions::new("index.html", "/"),
6655 &mut writer,
6656 )
6657 .unwrap();
6658 assert_eq!(writer.get_content(), "<input>");
6660 }
6661 {
6663 let mut fragments = HashMap::new();
6664 fragments.insert(
6665 "index.html".to_string(),
6666 FragmentList {
6667 fragments: vec![
6668 WebUIFragment::raw("<input"),
6669 WebUIFragment::attribute_boolean(
6670 "checked",
6671 ConditionExpr::identifier("checked"),
6672 ),
6673 WebUIFragment::raw(">"),
6674 ],
6675 contains_boundary: false,
6676 },
6677 );
6678 let protocol = WebUIProtocol::new(fragments);
6679 let state = test_json!({"checked": "false"});
6680 let mut writer = TestWriter::new();
6681 handle(
6682 &protocol,
6683 &state,
6684 &RenderOptions::new("index.html", "/"),
6685 &mut writer,
6686 )
6687 .unwrap();
6688 assert_eq!(writer.get_content(), "<input checked>");
6689 }
6690 }
6691
6692 #[test]
6693 fn test_boolean_attr_falsy_values() {
6694 {
6696 let mut fragments = HashMap::new();
6697 fragments.insert(
6698 "index.html".to_string(),
6699 FragmentList {
6700 fragments: vec![
6701 WebUIFragment::raw("<input"),
6702 WebUIFragment::attribute_boolean(
6703 "checked",
6704 ConditionExpr::identifier("checked"),
6705 ),
6706 WebUIFragment::raw(">"),
6707 ],
6708 contains_boundary: false,
6709 },
6710 );
6711 let protocol = WebUIProtocol::new(fragments);
6712 let state = test_json!({"checked": 0});
6713 let mut writer = TestWriter::new();
6714 handle(
6715 &protocol,
6716 &state,
6717 &RenderOptions::new("index.html", "/"),
6718 &mut writer,
6719 )
6720 .unwrap();
6721 assert_eq!(writer.get_content(), "<input>");
6722 }
6723 {
6725 let mut fragments = HashMap::new();
6726 fragments.insert(
6727 "index.html".to_string(),
6728 FragmentList {
6729 fragments: vec![
6730 WebUIFragment::raw("<input"),
6731 WebUIFragment::attribute_boolean(
6732 "checked",
6733 ConditionExpr::identifier("checked"),
6734 ),
6735 WebUIFragment::raw(">"),
6736 ],
6737 contains_boundary: false,
6738 },
6739 );
6740 let protocol = WebUIProtocol::new(fragments);
6741 let state = test_json!({"checked": ""});
6742 let mut writer = TestWriter::new();
6743 handle(
6744 &protocol,
6745 &state,
6746 &RenderOptions::new("index.html", "/"),
6747 &mut writer,
6748 )
6749 .unwrap();
6750 assert_eq!(writer.get_content(), "<input>");
6751 }
6752 {
6754 let mut fragments = HashMap::new();
6755 fragments.insert(
6756 "index.html".to_string(),
6757 FragmentList {
6758 fragments: vec![
6759 WebUIFragment::raw("<input"),
6760 WebUIFragment::attribute_boolean(
6761 "checked",
6762 ConditionExpr::identifier("checked"),
6763 ),
6764 WebUIFragment::raw(">"),
6765 ],
6766 contains_boundary: false,
6767 },
6768 );
6769 let protocol = WebUIProtocol::new(fragments);
6770 let state = test_json!({"checked": false});
6771 let mut writer = TestWriter::new();
6772 handle(
6773 &protocol,
6774 &state,
6775 &RenderOptions::new("index.html", "/"),
6776 &mut writer,
6777 )
6778 .unwrap();
6779 assert_eq!(writer.get_content(), "<input>");
6780 }
6781 {
6783 let mut fragments = HashMap::new();
6784 fragments.insert(
6785 "index.html".to_string(),
6786 FragmentList {
6787 fragments: vec![
6788 WebUIFragment::raw("<input"),
6789 WebUIFragment::attribute_boolean(
6790 "checked",
6791 ConditionExpr::identifier("checked"),
6792 ),
6793 WebUIFragment::raw(">"),
6794 ],
6795 contains_boundary: false,
6796 },
6797 );
6798 let protocol = WebUIProtocol::new(fragments);
6799 let state = test_json!({});
6800 let mut writer = TestWriter::new();
6801 handle(
6802 &protocol,
6803 &state,
6804 &RenderOptions::new("index.html", "/"),
6805 &mut writer,
6806 )
6807 .unwrap();
6808 assert_eq!(writer.get_content(), "<input>");
6809 }
6810 }
6811
6812 #[test]
6813 fn test_boolean_attr_expression_true() {
6814 let mut fragments = HashMap::new();
6815 fragments.insert(
6816 "index.html".to_string(),
6817 FragmentList {
6818 fragments: vec![
6819 WebUIFragment::raw("<button"),
6820 WebUIFragment::attribute_boolean(
6821 "disabled",
6822 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6823 ),
6824 WebUIFragment::raw(">Click</button>"),
6825 ],
6826 contains_boundary: false,
6827 },
6828 );
6829 let protocol = WebUIProtocol::new(fragments);
6830 let state = test_json!({"itemCount": 5});
6831 let mut writer = TestWriter::new();
6832 handle(
6833 &protocol,
6834 &state,
6835 &RenderOptions::new("index.html", "/"),
6836 &mut writer,
6837 )
6838 .unwrap();
6839 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6840 }
6841
6842 #[test]
6843 fn test_boolean_attr_expression_false() {
6844 let mut fragments = HashMap::new();
6845 fragments.insert(
6846 "index.html".to_string(),
6847 FragmentList {
6848 fragments: vec![
6849 WebUIFragment::raw("<button"),
6850 WebUIFragment::attribute_boolean(
6851 "disabled",
6852 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6853 ),
6854 WebUIFragment::raw(">Click</button>"),
6855 ],
6856 contains_boundary: false,
6857 },
6858 );
6859 let protocol = WebUIProtocol::new(fragments);
6860 let state = test_json!({"itemCount": 3});
6861 let mut writer = TestWriter::new();
6862 handle(
6863 &protocol,
6864 &state,
6865 &RenderOptions::new("index.html", "/"),
6866 &mut writer,
6867 )
6868 .unwrap();
6869 assert_eq!(writer.get_content(), "<button>Click</button>");
6870 }
6871
6872 #[test]
6875 fn test_nested_component_attr_capture() {
6876 let mut fragments = HashMap::new();
6877 fragments.insert(
6878 "index.html".to_string(),
6879 FragmentList {
6880 fragments: vec![
6881 WebUIFragment::raw("<parent-component"),
6882 WebUIFragment {
6883 fragment: Some(web_ui_fragment::Fragment::Attribute(
6884 WebUIFragmentAttribute {
6885 name: "title".into(),
6886 template: "parent-title".into(),
6887 attr_start: true,
6888 ..Default::default()
6889 },
6890 )),
6891 },
6892 WebUIFragment::raw(">"),
6893 WebUIFragment::component("parent-component"),
6894 WebUIFragment::raw("</parent-component>"),
6895 ],
6896
6897 contains_boundary: false,
6898 },
6899 );
6900 fragments.insert(
6901 "parent-title".to_string(),
6902 FragmentList {
6903 fragments: vec![
6904 WebUIFragment::raw("Hello "),
6905 WebUIFragment::signal("who", false),
6906 ],
6907 contains_boundary: false,
6908 },
6909 );
6910 fragments.insert(
6911 "parent-component".to_string(),
6912 FragmentList {
6913 fragments: vec![
6914 WebUIFragment::raw("<child-component"),
6915 WebUIFragment {
6916 fragment: Some(web_ui_fragment::Fragment::Attribute(
6917 WebUIFragmentAttribute {
6918 name: "title".into(),
6919 template: "child-title".into(),
6920 attr_start: true,
6921 ..Default::default()
6922 },
6923 )),
6924 },
6925 WebUIFragment::raw(">"),
6926 WebUIFragment::component("child-component"),
6927 WebUIFragment::raw("</child-component>"),
6928 ],
6929
6930 contains_boundary: false,
6931 },
6932 );
6933 fragments.insert(
6934 "child-title".to_string(),
6935 FragmentList {
6936 fragments: vec![
6937 WebUIFragment::raw("Child of "),
6938 WebUIFragment::signal("title", false),
6939 ],
6940 contains_boundary: false,
6941 },
6942 );
6943 fragments.insert(
6944 "child-component".to_string(),
6945 FragmentList {
6946 fragments: vec![
6947 WebUIFragment::raw("<span>"),
6948 WebUIFragment::signal("title", false),
6949 WebUIFragment::raw("</span>"),
6950 ],
6951 contains_boundary: false,
6952 },
6953 );
6954 let protocol = WebUIProtocol::new(fragments);
6955 let state = test_json!({"who": "<world>"});
6956 let mut writer = TestWriter::new();
6957 handle(
6958 &protocol,
6959 &state,
6960 &RenderOptions::new("index.html", "/"),
6961 &mut writer,
6962 )
6963 .unwrap();
6964 assert_eq!(
6965 writer.get_content(),
6966 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
6967 );
6968 }
6969
6970 #[test]
6971 fn test_grandchild_attr_propagation() {
6972 let mut fragments = HashMap::new();
6973 fragments.insert(
6974 "index.html".to_string(),
6975 FragmentList {
6976 fragments: vec![
6977 WebUIFragment::raw("<parent-component"),
6978 WebUIFragment {
6979 fragment: Some(web_ui_fragment::Fragment::Attribute(
6980 WebUIFragmentAttribute {
6981 name: "title".into(),
6982 template: "p-title".into(),
6983 attr_start: true,
6984 ..Default::default()
6985 },
6986 )),
6987 },
6988 WebUIFragment::raw(">"),
6989 WebUIFragment::component("parent-component"),
6990 WebUIFragment::raw("</parent-component>"),
6991 ],
6992
6993 contains_boundary: false,
6994 },
6995 );
6996 fragments.insert(
6997 "p-title".to_string(),
6998 FragmentList {
6999 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7000 contains_boundary: false,
7001 },
7002 );
7003 fragments.insert(
7004 "parent-component".to_string(),
7005 FragmentList {
7006 fragments: vec![
7007 WebUIFragment::raw("<child-component"),
7008 WebUIFragment {
7009 fragment: Some(web_ui_fragment::Fragment::Attribute(
7010 WebUIFragmentAttribute {
7011 name: "title".into(),
7012 template: "c-title".into(),
7013 attr_start: true,
7014 ..Default::default()
7015 },
7016 )),
7017 },
7018 WebUIFragment::raw(">"),
7019 WebUIFragment::component("child-component"),
7020 WebUIFragment::raw("</child-component>"),
7021 ],
7022
7023 contains_boundary: false,
7024 },
7025 );
7026 fragments.insert(
7027 "c-title".to_string(),
7028 FragmentList {
7029 fragments: vec![
7030 WebUIFragment::raw("C("),
7031 WebUIFragment::signal("title", false),
7032 WebUIFragment::raw(")-"),
7033 WebUIFragment::signal("cExtra", false),
7034 ],
7035 contains_boundary: false,
7036 },
7037 );
7038 fragments.insert(
7039 "child-component".to_string(),
7040 FragmentList {
7041 fragments: vec![
7042 WebUIFragment::raw("<grandchild-component"),
7043 WebUIFragment {
7044 fragment: Some(web_ui_fragment::Fragment::Attribute(
7045 WebUIFragmentAttribute {
7046 name: "title".into(),
7047 value: "title".into(),
7048 attr_start: true,
7049 ..Default::default()
7050 },
7051 )),
7052 },
7053 WebUIFragment::raw(">"),
7054 WebUIFragment::component("grandchild-component"),
7055 WebUIFragment::raw("</grandchild-component>"),
7056 ],
7057
7058 contains_boundary: false,
7059 },
7060 );
7061 fragments.insert(
7062 "grandchild-component".to_string(),
7063 FragmentList {
7064 fragments: vec![
7065 WebUIFragment::raw("<span>"),
7066 WebUIFragment::signal("title", false),
7067 WebUIFragment::raw("</span>"),
7068 ],
7069 contains_boundary: false,
7070 },
7071 );
7072 let protocol = WebUIProtocol::new(fragments);
7073 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
7074 let mut writer = TestWriter::new();
7075 handle(
7076 &protocol,
7077 &state,
7078 &RenderOptions::new("index.html", "/"),
7079 &mut writer,
7080 )
7081 .unwrap();
7082 assert_eq!(
7083 writer.get_content(),
7084 "<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>"
7085 );
7086 }
7087
7088 #[test]
7089 fn test_for_loop_component_attr() {
7090 let mut fragments = HashMap::new();
7091 fragments.insert(
7092 "index.html".to_string(),
7093 FragmentList {
7094 fragments: vec![
7095 WebUIFragment::raw("<parent-component"),
7096 WebUIFragment {
7097 fragment: Some(web_ui_fragment::Fragment::Attribute(
7098 WebUIFragmentAttribute {
7099 name: "title".into(),
7100 template: "parent-title-loop".into(),
7101 attr_start: true,
7102 ..Default::default()
7103 },
7104 )),
7105 },
7106 WebUIFragment::raw(">"),
7107 WebUIFragment::component("parent-component"),
7108 WebUIFragment::raw("</parent-component>"),
7109 ],
7110
7111 contains_boundary: false,
7112 },
7113 );
7114 fragments.insert(
7115 "parent-title-loop".to_string(),
7116 FragmentList {
7117 fragments: vec![
7118 WebUIFragment::raw("Parent:"),
7119 WebUIFragment::signal("who", false),
7120 ],
7121 contains_boundary: false,
7122 },
7123 );
7124 fragments.insert(
7125 "parent-component".to_string(),
7126 FragmentList {
7127 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7128 contains_boundary: false,
7129 },
7130 );
7131 fragments.insert(
7132 "child-loop".to_string(),
7133 FragmentList {
7134 fragments: vec![
7135 WebUIFragment::raw("<child-component"),
7136 WebUIFragment {
7137 fragment: Some(web_ui_fragment::Fragment::Attribute(
7138 WebUIFragmentAttribute {
7139 name: "title".into(),
7140 template: "child-title-loop".into(),
7141 attr_start: true,
7142 ..Default::default()
7143 },
7144 )),
7145 },
7146 WebUIFragment::raw(">"),
7147 WebUIFragment::component("child-component"),
7148 WebUIFragment::raw("</child-component>"),
7149 ],
7150
7151 contains_boundary: false,
7152 },
7153 );
7154 fragments.insert(
7155 "child-title-loop".to_string(),
7156 FragmentList {
7157 fragments: vec![
7158 WebUIFragment::raw("Hi "),
7159 WebUIFragment::signal("item.name", false),
7160 WebUIFragment::raw(" / "),
7161 WebUIFragment::signal("title", false),
7162 ],
7163 contains_boundary: false,
7164 },
7165 );
7166 fragments.insert(
7167 "child-component".to_string(),
7168 FragmentList {
7169 fragments: vec![
7170 WebUIFragment::raw("<span>"),
7171 WebUIFragment::signal("title", false),
7172 WebUIFragment::raw("</span>"),
7173 ],
7174 contains_boundary: false,
7175 },
7176 );
7177 let protocol = WebUIProtocol::new(fragments);
7178 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7179 let mut writer = TestWriter::new();
7180 handle(
7181 &protocol,
7182 &state,
7183 &RenderOptions::new("index.html", "/"),
7184 &mut writer,
7185 )
7186 .unwrap();
7187 assert_eq!(
7188 writer.get_content(),
7189 "<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>"
7190 );
7191 }
7192
7193 #[test]
7194 fn test_multiple_template_attrs() {
7195 let mut fragments = HashMap::new();
7196 fragments.insert(
7197 "index.html".to_string(),
7198 FragmentList {
7199 fragments: vec![
7200 WebUIFragment::raw("<my-component"),
7201 WebUIFragment {
7202 fragment: Some(web_ui_fragment::Fragment::Attribute(
7203 WebUIFragmentAttribute {
7204 name: "title".into(),
7205 template: "attr-title".into(),
7206 attr_start: true,
7207 ..Default::default()
7208 },
7209 )),
7210 },
7211 WebUIFragment {
7212 fragment: Some(web_ui_fragment::Fragment::Attribute(
7213 WebUIFragmentAttribute {
7214 name: "data-title".into(),
7215 template: "attr-data-title".into(),
7216 attr_start: false,
7217 ..Default::default()
7218 },
7219 )),
7220 },
7221 WebUIFragment {
7222 fragment: Some(web_ui_fragment::Fragment::Attribute(
7223 WebUIFragmentAttribute {
7224 name: "aria-label".into(),
7225 template: "attr-aria-label".into(),
7226 attr_start: false,
7227 ..Default::default()
7228 },
7229 )),
7230 },
7231 WebUIFragment::raw(">"),
7232 WebUIFragment::component("my-component"),
7233 WebUIFragment::raw("</my-component>"),
7234 ],
7235
7236 contains_boundary: false,
7237 },
7238 );
7239 fragments.insert(
7240 "attr-title".to_string(),
7241 FragmentList {
7242 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7243 contains_boundary: false,
7244 },
7245 );
7246 fragments.insert(
7247 "attr-data-title".to_string(),
7248 FragmentList {
7249 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7250 contains_boundary: false,
7251 },
7252 );
7253 fragments.insert(
7254 "attr-aria-label".to_string(),
7255 FragmentList {
7256 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7257 contains_boundary: false,
7258 },
7259 );
7260 fragments.insert(
7261 "my-component".to_string(),
7262 FragmentList {
7263 fragments: vec![
7264 WebUIFragment::raw("<span>"),
7265 WebUIFragment::signal("title", false),
7266 WebUIFragment::raw("|"),
7267 WebUIFragment::signal("dataTitle", false),
7268 WebUIFragment::raw("|"),
7269 WebUIFragment::signal("ariaLabel", false),
7270 WebUIFragment::raw("</span>"),
7271 ],
7272 contains_boundary: false,
7273 },
7274 );
7275 let protocol = WebUIProtocol::new(fragments);
7276 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7277 let mut writer = TestWriter::new();
7278 handle(
7279 &protocol,
7280 &state,
7281 &RenderOptions::new("index.html", "/"),
7282 &mut writer,
7283 )
7284 .unwrap();
7285 assert_eq!(
7286 writer.get_content(),
7287 "<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>"
7288 );
7289 }
7290
7291 #[test]
7292 fn test_attr_priority_over_global() {
7293 let mut fragments = HashMap::new();
7294 fragments.insert(
7295 "index.html".to_string(),
7296 FragmentList {
7297 fragments: vec![
7298 WebUIFragment::raw("<my-component"),
7299 WebUIFragment {
7300 fragment: Some(web_ui_fragment::Fragment::Attribute(
7301 WebUIFragmentAttribute {
7302 name: "title".into(),
7303 value: "Attribute Title".into(),
7304 raw_value: true,
7305 attr_start: true,
7306 ..Default::default()
7307 },
7308 )),
7309 },
7310 WebUIFragment::raw(">"),
7311 WebUIFragment::component("my-component"),
7312 WebUIFragment::raw("</my-component>"),
7313 ],
7314
7315 contains_boundary: false,
7316 },
7317 );
7318 fragments.insert(
7319 "my-component".to_string(),
7320 FragmentList {
7321 fragments: vec![
7322 WebUIFragment::raw("<span>"),
7323 WebUIFragment::signal("title", false),
7324 WebUIFragment::raw("</span>"),
7325 ],
7326 contains_boundary: false,
7327 },
7328 );
7329 let protocol = WebUIProtocol::new(fragments);
7330 let state = test_json!({"title": "Global Title"});
7331 let mut writer = TestWriter::new();
7332 handle(
7333 &protocol,
7334 &state,
7335 &RenderOptions::new("index.html", "/"),
7336 &mut writer,
7337 )
7338 .unwrap();
7339 assert_eq!(
7340 writer.get_content(),
7341 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7342 );
7343 }
7344
7345 #[test]
7346 fn test_attr_priority_over_local_and_global() {
7347 let mut fragments = HashMap::new();
7348 fragments.insert(
7349 "index.html".to_string(),
7350 FragmentList {
7351 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7352 contains_boundary: false,
7353 },
7354 );
7355 fragments.insert(
7356 "loop".to_string(),
7357 FragmentList {
7358 fragments: vec![
7359 WebUIFragment::raw("<my-component"),
7360 WebUIFragment {
7361 fragment: Some(web_ui_fragment::Fragment::Attribute(
7362 WebUIFragmentAttribute {
7363 name: "title".into(),
7364 value: "Attribute Title".into(),
7365 raw_value: true,
7366 attr_start: true,
7367 ..Default::default()
7368 },
7369 )),
7370 },
7371 WebUIFragment::raw(">"),
7372 WebUIFragment::component("my-component"),
7373 WebUIFragment::raw("</my-component>"),
7374 ],
7375
7376 contains_boundary: false,
7377 },
7378 );
7379 fragments.insert(
7380 "my-component".to_string(),
7381 FragmentList {
7382 fragments: vec![
7383 WebUIFragment::raw("<span>"),
7384 WebUIFragment::signal("title", false),
7385 WebUIFragment::raw("</span>"),
7386 ],
7387 contains_boundary: false,
7388 },
7389 );
7390 let protocol = WebUIProtocol::new(fragments);
7391 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7392 let mut writer = TestWriter::new();
7393 handle(
7394 &protocol,
7395 &state,
7396 &RenderOptions::new("index.html", "/"),
7397 &mut writer,
7398 )
7399 .unwrap();
7400 assert_eq!(
7401 writer.get_content(),
7402 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7403 );
7404 }
7405
7406 #[test]
7407 fn test_boolean_attr_first_component_attr() {
7408 let mut fragments = HashMap::new();
7409 fragments.insert(
7410 "index.html".to_string(),
7411 FragmentList {
7412 fragments: vec![
7413 WebUIFragment::raw("<my-component"),
7414 WebUIFragment {
7415 fragment: Some(web_ui_fragment::Fragment::Attribute(
7416 WebUIFragmentAttribute {
7417 name: "disabled".into(),
7418 attr_start: true,
7419 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7420 ..Default::default()
7421 },
7422 )),
7423 },
7424 WebUIFragment {
7425 fragment: Some(web_ui_fragment::Fragment::Attribute(
7426 WebUIFragmentAttribute {
7427 name: "label".into(),
7428 value: "Component Label".into(),
7429 raw_value: true,
7430 attr_start: false,
7431 ..Default::default()
7432 },
7433 )),
7434 },
7435 WebUIFragment::raw(">"),
7436 WebUIFragment::component("my-component"),
7437 WebUIFragment::raw("</my-component>"),
7438 ],
7439
7440 contains_boundary: false,
7441 },
7442 );
7443 fragments.insert(
7444 "my-component".to_string(),
7445 FragmentList {
7446 fragments: vec![
7447 WebUIFragment::if_cond(
7448 ConditionExpr::identifier("disabled"),
7449 "disabledTemplate",
7450 ),
7451 WebUIFragment::raw("<span>"),
7452 WebUIFragment::signal("label", false),
7453 WebUIFragment::raw("</span>"),
7454 ],
7455 contains_boundary: false,
7456 },
7457 );
7458 fragments.insert(
7459 "disabledTemplate".to_string(),
7460 FragmentList {
7461 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7462 contains_boundary: false,
7463 },
7464 );
7465 let protocol = WebUIProtocol::new(fragments);
7466 let state = test_json!({"isDisabled": true});
7467 let mut writer = TestWriter::new();
7468 handle(
7469 &protocol,
7470 &state,
7471 &RenderOptions::new("index.html", "/"),
7472 &mut writer,
7473 )
7474 .unwrap();
7475 assert_eq!(
7476 writer.get_content(),
7477 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7478 );
7479 }
7480
7481 #[test]
7482 fn test_hyphenated_attr_camelcase() {
7483 let mut fragments = HashMap::new();
7484 fragments.insert(
7485 "index.html".to_string(),
7486 FragmentList {
7487 fragments: vec![
7488 WebUIFragment::raw("<my-component"),
7489 WebUIFragment {
7490 fragment: Some(web_ui_fragment::Fragment::Attribute(
7491 WebUIFragmentAttribute {
7492 name: "key-hyphen".into(),
7493 value: "Local Value".into(),
7494 raw_value: true,
7495 attr_start: true,
7496 ..Default::default()
7497 },
7498 )),
7499 },
7500 WebUIFragment::raw(">"),
7501 WebUIFragment::component("my-component"),
7502 WebUIFragment::raw("</my-component>"),
7503 ],
7504
7505 contains_boundary: false,
7506 },
7507 );
7508 fragments.insert(
7509 "my-component".to_string(),
7510 FragmentList {
7511 fragments: vec![
7512 WebUIFragment::raw("<span>"),
7513 WebUIFragment::signal("keyHyphen", false),
7514 WebUIFragment::raw("</span>"),
7515 ],
7516 contains_boundary: false,
7517 },
7518 );
7519 let protocol = WebUIProtocol::new(fragments);
7520 let state = test_json!({"keyHyphen": "Global Value"});
7521 let mut writer = TestWriter::new();
7522 handle(
7523 &protocol,
7524 &state,
7525 &RenderOptions::new("index.html", "/"),
7526 &mut writer,
7527 )
7528 .unwrap();
7529 assert_eq!(
7530 writer.get_content(),
7531 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7532 );
7533 }
7534
7535 #[test]
7536 fn test_skipped_component_attrs() {
7537 let mut fragments = HashMap::new();
7541 fragments.insert(
7542 "index.html".to_string(),
7543 FragmentList {
7544 fragments: vec![
7545 WebUIFragment::raw("<test-component"),
7546 WebUIFragment {
7548 fragment: Some(web_ui_fragment::Fragment::Attribute(
7549 WebUIFragmentAttribute {
7550 name: "class".into(),
7551 value: "skippedClass".into(),
7552 attr_start: true,
7553 attr_skip: true,
7554 ..Default::default()
7555 },
7556 )),
7557 },
7558 WebUIFragment {
7560 fragment: Some(web_ui_fragment::Fragment::Attribute(
7561 WebUIFragmentAttribute {
7562 name: "style".into(),
7563 value: "skippedStyle".into(),
7564 attr_skip: true,
7565 ..Default::default()
7566 },
7567 )),
7568 },
7569 WebUIFragment {
7571 fragment: Some(web_ui_fragment::Fragment::Attribute(
7572 WebUIFragmentAttribute {
7573 name: "role".into(),
7574 value: "skippedRole".into(),
7575 attr_skip: true,
7576 ..Default::default()
7577 },
7578 )),
7579 },
7580 WebUIFragment {
7582 fragment: Some(web_ui_fragment::Fragment::Attribute(
7583 WebUIFragmentAttribute {
7584 name: "data-testid".into(),
7585 value: "skippedDataTestid".into(),
7586 attr_skip: true,
7587 ..Default::default()
7588 },
7589 )),
7590 },
7591 WebUIFragment {
7593 fragment: Some(web_ui_fragment::Fragment::Attribute(
7594 WebUIFragmentAttribute {
7595 name: "aria-label".into(),
7596 value: "skippedAriaLabel".into(),
7597 attr_skip: true,
7598 ..Default::default()
7599 },
7600 )),
7601 },
7602 WebUIFragment {
7604 fragment: Some(web_ui_fragment::Fragment::Attribute(
7605 WebUIFragmentAttribute {
7606 name: "title".into(),
7607 value: "title".into(),
7608 ..Default::default()
7609 },
7610 )),
7611 },
7612 WebUIFragment::raw(">"),
7613 WebUIFragment::component("test-component"),
7614 WebUIFragment::raw("</test-component>"),
7615 ],
7616
7617 contains_boundary: false,
7618 },
7619 );
7620 fragments.insert(
7621 "test-component".to_string(),
7622 FragmentList {
7623 fragments: vec![
7624 WebUIFragment::raw("<span>"),
7625 WebUIFragment::signal("title", false),
7626 WebUIFragment::raw("-"),
7627 WebUIFragment::signal("class", false),
7628 WebUIFragment::raw("-"),
7629 WebUIFragment::signal("style", false),
7630 WebUIFragment::raw("-"),
7631 WebUIFragment::signal("role", false),
7632 WebUIFragment::raw("-"),
7633 WebUIFragment::signal("dataTestid", false),
7634 WebUIFragment::raw("-"),
7635 WebUIFragment::signal("ariaLabel", false),
7636 WebUIFragment::raw("</span>"),
7637 ],
7638 contains_boundary: false,
7639 },
7640 );
7641 let protocol = WebUIProtocol::new(fragments);
7642 let state = test_json!({
7643 "title": "Hello",
7644 "skippedClass": "my-class",
7645 "skippedStyle": "color:red",
7646 "skippedRole": "button",
7647 "skippedDataTestid": "test-id",
7648 "skippedAriaLabel": "label-text"
7649 });
7650 let mut writer = TestWriter::new();
7651 handle(
7652 &protocol,
7653 &state,
7654 &RenderOptions::new("index.html", "/"),
7655 &mut writer,
7656 )
7657 .unwrap();
7658 assert_eq!(
7662 writer.get_content(),
7663 "<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>"
7664 );
7665 }
7666
7667 #[test]
7670 fn test_attr_inherit_parent_to_child() {
7671 let mut fragments = HashMap::new();
7672 fragments.insert(
7673 "index.html".to_string(),
7674 FragmentList {
7675 fragments: vec![
7676 WebUIFragment::raw("<parent-component"),
7677 WebUIFragment {
7678 fragment: Some(web_ui_fragment::Fragment::Attribute(
7679 WebUIFragmentAttribute {
7680 name: "title".into(),
7681 value: "Parent Title".into(),
7682 raw_value: true,
7683 attr_start: true,
7684 ..Default::default()
7685 },
7686 )),
7687 },
7688 WebUIFragment::raw(">"),
7689 WebUIFragment::component("parent-component"),
7690 WebUIFragment::raw("</parent-component>"),
7691 ],
7692
7693 contains_boundary: false,
7694 },
7695 );
7696 fragments.insert(
7697 "parent-component".to_string(),
7698 FragmentList {
7699 fragments: vec![
7700 WebUIFragment::raw("<h1>"),
7701 WebUIFragment::signal("title", false),
7702 WebUIFragment::raw("</h1><child-component"),
7703 WebUIFragment {
7704 fragment: Some(web_ui_fragment::Fragment::Attribute(
7705 WebUIFragmentAttribute {
7706 name: "title".into(),
7707 value: "title".into(),
7708 attr_start: true,
7709 ..Default::default()
7710 },
7711 )),
7712 },
7713 WebUIFragment::raw(">"),
7714 WebUIFragment::component("child-component"),
7715 WebUIFragment::raw("</child-component>"),
7716 ],
7717
7718 contains_boundary: false,
7719 },
7720 );
7721 fragments.insert(
7722 "child-component".to_string(),
7723 FragmentList {
7724 fragments: vec![
7725 WebUIFragment::raw("<h2>"),
7726 WebUIFragment::signal("title", false),
7727 WebUIFragment::raw("</h2>"),
7728 ],
7729 contains_boundary: false,
7730 },
7731 );
7732 let protocol = WebUIProtocol::new(fragments);
7733 let state = test_json!({});
7734 let mut writer = TestWriter::new();
7735 handle(
7736 &protocol,
7737 &state,
7738 &RenderOptions::new("index.html", "/"),
7739 &mut writer,
7740 )
7741 .unwrap();
7742 assert_eq!(
7743 writer.get_content(),
7744 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7745 );
7746 }
7747
7748 #[test]
7749 fn test_attr_inherit_deep() {
7750 let mut fragments = HashMap::new();
7751 fragments.insert(
7752 "index.html".to_string(),
7753 FragmentList {
7754 fragments: vec![
7755 WebUIFragment::raw("<parent-component"),
7756 WebUIFragment {
7757 fragment: Some(web_ui_fragment::Fragment::Attribute(
7758 WebUIFragmentAttribute {
7759 name: "title".into(),
7760 value: "Parent Title".into(),
7761 raw_value: true,
7762 attr_start: true,
7763 ..Default::default()
7764 },
7765 )),
7766 },
7767 WebUIFragment::raw(">"),
7768 WebUIFragment::component("parent-component"),
7769 WebUIFragment::raw("</parent-component>"),
7770 ],
7771
7772 contains_boundary: false,
7773 },
7774 );
7775 fragments.insert(
7776 "parent-component".to_string(),
7777 FragmentList {
7778 fragments: vec![
7779 WebUIFragment::raw("<child-component"),
7780 WebUIFragment {
7781 fragment: Some(web_ui_fragment::Fragment::Attribute(
7782 WebUIFragmentAttribute {
7783 name: "title".into(),
7784 value: "Child Title".into(),
7785 raw_value: true,
7786 attr_start: true,
7787 ..Default::default()
7788 },
7789 )),
7790 },
7791 WebUIFragment::raw(">"),
7792 WebUIFragment::component("child-component"),
7793 WebUIFragment::raw("</child-component>"),
7794 ],
7795
7796 contains_boundary: false,
7797 },
7798 );
7799 fragments.insert(
7800 "child-component".to_string(),
7801 FragmentList {
7802 fragments: vec![
7803 WebUIFragment::raw("<grandchild-component"),
7804 WebUIFragment {
7805 fragment: Some(web_ui_fragment::Fragment::Attribute(
7806 WebUIFragmentAttribute {
7807 name: "title".into(),
7808 value: "title".into(),
7809 attr_start: true,
7810 ..Default::default()
7811 },
7812 )),
7813 },
7814 WebUIFragment::raw(">"),
7815 WebUIFragment::component("grandchild-component"),
7816 WebUIFragment::raw("</grandchild-component>"),
7817 ],
7818
7819 contains_boundary: false,
7820 },
7821 );
7822 fragments.insert(
7823 "grandchild-component".to_string(),
7824 FragmentList {
7825 fragments: vec![
7826 WebUIFragment::raw("<h3>"),
7827 WebUIFragment::signal("title", false),
7828 WebUIFragment::raw("</h3>"),
7829 ],
7830 contains_boundary: false,
7831 },
7832 );
7833 let protocol = WebUIProtocol::new(fragments);
7834 let state = test_json!({});
7835 let mut writer = TestWriter::new();
7836 handle(
7837 &protocol,
7838 &state,
7839 &RenderOptions::new("index.html", "/"),
7840 &mut writer,
7841 )
7842 .unwrap();
7843 assert_eq!(
7844 writer.get_content(),
7845 "<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>"
7846 );
7847 }
7848
7849 #[test]
7850 fn test_complex_attr_access() {
7851 let mut fragments = HashMap::new();
7852 fragments.insert(
7853 "index.html".to_string(),
7854 FragmentList {
7855 fragments: vec![
7856 WebUIFragment::raw("<my-component"),
7857 WebUIFragment {
7858 fragment: Some(web_ui_fragment::Fragment::Attribute(
7859 WebUIFragmentAttribute {
7860 name: ":item".into(),
7861 value: "complexItem".into(),
7862 attr_start: true,
7863 complex: true,
7864 ..Default::default()
7865 },
7866 )),
7867 },
7868 WebUIFragment::raw(">"),
7869 WebUIFragment::component("my-component"),
7870 WebUIFragment::raw("</my-component>"),
7871 ],
7872
7873 contains_boundary: false,
7874 },
7875 );
7876 fragments.insert(
7877 "my-component".to_string(),
7878 FragmentList {
7879 fragments: vec![
7880 WebUIFragment::raw("<span>"),
7881 WebUIFragment::signal("item.foo", false),
7882 WebUIFragment::raw("</span><p>"),
7883 WebUIFragment::signal("item.bar", false),
7884 WebUIFragment::raw("</p>"),
7885 ],
7886 contains_boundary: false,
7887 },
7888 );
7889 let protocol = WebUIProtocol::new(fragments);
7890 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7891 let mut writer = TestWriter::new();
7892 handle(
7893 &protocol,
7894 &state,
7895 &RenderOptions::new("index.html", "/"),
7896 &mut writer,
7897 )
7898 .unwrap();
7899 assert_eq!(
7900 writer.get_content(),
7901 "<my-component><span>1</span><p>true</p></my-component>"
7902 );
7903 }
7904
7905 #[test]
7906 fn test_complex_attr_for_loop() {
7907 let mut fragments = HashMap::new();
7908 fragments.insert(
7909 "index.html".to_string(),
7910 FragmentList {
7911 fragments: vec![WebUIFragment::for_loop(
7912 "item",
7913 "list.items",
7914 "listTemplate",
7915 )],
7916 contains_boundary: false,
7917 },
7918 );
7919 fragments.insert(
7920 "listTemplate".to_string(),
7921 FragmentList {
7922 fragments: vec![
7923 WebUIFragment {
7924 fragment: Some(web_ui_fragment::Fragment::Attribute(
7925 WebUIFragmentAttribute {
7926 name: ":item".into(),
7927 value: "item".into(),
7928 attr_start: true,
7929 complex: true,
7930 ..Default::default()
7931 },
7932 )),
7933 },
7934 WebUIFragment::component("item_component"),
7935 ],
7936
7937 contains_boundary: false,
7938 },
7939 );
7940 fragments.insert(
7941 "item_component".to_string(),
7942 FragmentList {
7943 fragments: vec![
7944 WebUIFragment::raw("<span>"),
7945 WebUIFragment::signal("item.name", false),
7946 WebUIFragment::raw("</span>"),
7947 ],
7948 contains_boundary: false,
7949 },
7950 );
7951 let protocol = WebUIProtocol::new(fragments);
7952 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7953 let mut writer = TestWriter::new();
7954 handle(
7955 &protocol,
7956 &state,
7957 &RenderOptions::new("index.html", "/"),
7958 &mut writer,
7959 )
7960 .unwrap();
7961 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7962 }
7963
7964 #[test]
7965 fn test_complex_attr_nested_for() {
7966 let mut fragments = HashMap::new();
7967 fragments.insert(
7968 "index.html".to_string(),
7969 FragmentList {
7970 fragments: vec![WebUIFragment::for_loop(
7971 "outer",
7972 "data.outer",
7973 "outerTemplate",
7974 )],
7975 contains_boundary: false,
7976 },
7977 );
7978 fragments.insert(
7979 "outerTemplate".to_string(),
7980 FragmentList {
7981 fragments: vec![WebUIFragment::for_loop(
7982 "middle",
7983 "outer.middle",
7984 "middleTemplate",
7985 )],
7986 contains_boundary: false,
7987 },
7988 );
7989 fragments.insert(
7990 "middleTemplate".to_string(),
7991 FragmentList {
7992 fragments: vec![WebUIFragment::for_loop(
7993 "inner",
7994 "middle.inner",
7995 "innerTemplate",
7996 )],
7997 contains_boundary: false,
7998 },
7999 );
8000 fragments.insert(
8001 "innerTemplate".to_string(),
8002 FragmentList {
8003 fragments: vec![
8004 WebUIFragment::raw("<card"),
8005 WebUIFragment {
8006 fragment: Some(web_ui_fragment::Fragment::Attribute(
8007 WebUIFragmentAttribute {
8008 name: ":outer".into(),
8009 value: "outer".into(),
8010 attr_start: true,
8011 complex: true,
8012 ..Default::default()
8013 },
8014 )),
8015 },
8016 WebUIFragment {
8017 fragment: Some(web_ui_fragment::Fragment::Attribute(
8018 WebUIFragmentAttribute {
8019 name: ":middle".into(),
8020 value: "middle".into(),
8021 attr_start: false,
8022 complex: true,
8023 ..Default::default()
8024 },
8025 )),
8026 },
8027 WebUIFragment {
8028 fragment: Some(web_ui_fragment::Fragment::Attribute(
8029 WebUIFragmentAttribute {
8030 name: ":inner".into(),
8031 value: "inner".into(),
8032 attr_start: false,
8033 complex: true,
8034 ..Default::default()
8035 },
8036 )),
8037 },
8038 WebUIFragment::raw(">"),
8039 WebUIFragment::component("card_component"),
8040 WebUIFragment::raw("</card>"),
8041 ],
8042
8043 contains_boundary: false,
8044 },
8045 );
8046 fragments.insert(
8047 "card_component".to_string(),
8048 FragmentList {
8049 fragments: vec![
8050 WebUIFragment::raw("<p>"),
8051 WebUIFragment::signal("outer.label", false),
8052 WebUIFragment::raw(" / "),
8053 WebUIFragment::signal("middle.label", false),
8054 WebUIFragment::raw(" / "),
8055 WebUIFragment::signal("inner.label", false),
8056 WebUIFragment::raw("</p>"),
8057 ],
8058 contains_boundary: false,
8059 },
8060 );
8061 let protocol = WebUIProtocol::new(fragments);
8062 let state = test_json!({"data": {"outer": [
8063 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8064 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8065 ]}});
8066 let mut writer = TestWriter::new();
8067 handle(
8068 &protocol,
8069 &state,
8070 &RenderOptions::new("index.html", "/"),
8071 &mut writer,
8072 )
8073 .unwrap();
8074 assert_eq!(
8075 writer.get_content(),
8076 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8077 );
8078 }
8079
8080 #[test]
8083 fn test_bool_component_state_true() {
8084 let mut fragments = HashMap::new();
8085 fragments.insert(
8086 "index.html".to_string(),
8087 FragmentList {
8088 fragments: vec![
8089 WebUIFragment::raw("<my-component"),
8090 WebUIFragment {
8091 fragment: Some(web_ui_fragment::Fragment::Attribute(
8092 WebUIFragmentAttribute {
8093 name: "disabled".into(),
8094 attr_start: true,
8095 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8096 ..Default::default()
8097 },
8098 )),
8099 },
8100 WebUIFragment::raw(">"),
8101 WebUIFragment::component("my-component"),
8102 WebUIFragment::raw("</my-component>"),
8103 ],
8104
8105 contains_boundary: false,
8106 },
8107 );
8108 fragments.insert(
8109 "my-component".to_string(),
8110 FragmentList {
8111 fragments: vec![
8112 WebUIFragment::if_cond(
8113 ConditionExpr::identifier("disabled"),
8114 "disabledTemplate",
8115 ),
8116 WebUIFragment::if_cond(
8117 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8118 "enabledTemplate",
8119 ),
8120 ],
8121 contains_boundary: false,
8122 },
8123 );
8124 fragments.insert(
8125 "disabledTemplate".to_string(),
8126 FragmentList {
8127 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8128 contains_boundary: false,
8129 },
8130 );
8131 fragments.insert(
8132 "enabledTemplate".to_string(),
8133 FragmentList {
8134 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8135 contains_boundary: false,
8136 },
8137 );
8138 let protocol = WebUIProtocol::new(fragments);
8139 let state = test_json!({"isDisabled": true});
8140 let mut writer = TestWriter::new();
8141 handle(
8142 &protocol,
8143 &state,
8144 &RenderOptions::new("index.html", "/"),
8145 &mut writer,
8146 )
8147 .unwrap();
8148 assert_eq!(
8149 writer.get_content(),
8150 "<my-component disabled><span>Disabled</span></my-component>"
8151 );
8152 }
8153
8154 #[test]
8155 fn test_bool_component_state_false() {
8156 let mut fragments = HashMap::new();
8157 fragments.insert(
8158 "index.html".to_string(),
8159 FragmentList {
8160 fragments: vec![
8161 WebUIFragment::raw("<my-component"),
8162 WebUIFragment {
8163 fragment: Some(web_ui_fragment::Fragment::Attribute(
8164 WebUIFragmentAttribute {
8165 name: "disabled".into(),
8166 attr_start: true,
8167 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8168 ..Default::default()
8169 },
8170 )),
8171 },
8172 WebUIFragment::raw(">"),
8173 WebUIFragment::component("my-component"),
8174 WebUIFragment::raw("</my-component>"),
8175 ],
8176
8177 contains_boundary: false,
8178 },
8179 );
8180 fragments.insert(
8181 "my-component".to_string(),
8182 FragmentList {
8183 fragments: vec![
8184 WebUIFragment::if_cond(
8185 ConditionExpr::identifier("disabled"),
8186 "disabledTemplate",
8187 ),
8188 WebUIFragment::if_cond(
8189 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8190 "enabledTemplate",
8191 ),
8192 ],
8193 contains_boundary: false,
8194 },
8195 );
8196 fragments.insert(
8197 "disabledTemplate".to_string(),
8198 FragmentList {
8199 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8200 contains_boundary: false,
8201 },
8202 );
8203 fragments.insert(
8204 "enabledTemplate".to_string(),
8205 FragmentList {
8206 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8207 contains_boundary: false,
8208 },
8209 );
8210 let protocol = WebUIProtocol::new(fragments);
8211 let state = test_json!({"isDisabled": false});
8212 let mut writer = TestWriter::new();
8213 handle(
8214 &protocol,
8215 &state,
8216 &RenderOptions::new("index.html", "/"),
8217 &mut writer,
8218 )
8219 .unwrap();
8220 assert_eq!(
8221 writer.get_content(),
8222 "<my-component><span>Enabled</span></my-component>"
8223 );
8224 }
8225
8226 #[test]
8227 fn test_bool_component_state_forward() {
8228 let mut fragments = HashMap::new();
8229 fragments.insert(
8230 "index.html".to_string(),
8231 FragmentList {
8232 fragments: vec![
8233 WebUIFragment::raw("<parent-component"),
8234 WebUIFragment {
8235 fragment: Some(web_ui_fragment::Fragment::Attribute(
8236 WebUIFragmentAttribute {
8237 name: "disabled".into(),
8238 attr_start: true,
8239 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8240 ..Default::default()
8241 },
8242 )),
8243 },
8244 WebUIFragment::raw(">"),
8245 WebUIFragment::component("parent-component"),
8246 WebUIFragment::raw("</parent-component>"),
8247 ],
8248
8249 contains_boundary: false,
8250 },
8251 );
8252 fragments.insert(
8253 "parent-component".to_string(),
8254 FragmentList {
8255 fragments: vec![
8256 WebUIFragment::if_cond(
8257 ConditionExpr::identifier("disabled"),
8258 "parentDisabledTemplate",
8259 ),
8260 WebUIFragment::raw("<child-component"),
8261 WebUIFragment {
8262 fragment: Some(web_ui_fragment::Fragment::Attribute(
8263 WebUIFragmentAttribute {
8264 name: "disabled".into(),
8265 attr_start: true,
8266 condition_tree: Some(ConditionExpr::identifier("disabled")),
8267 ..Default::default()
8268 },
8269 )),
8270 },
8271 WebUIFragment::raw(">"),
8272 WebUIFragment::component("child-component"),
8273 WebUIFragment::raw("</child-component>"),
8274 ],
8275
8276 contains_boundary: false,
8277 },
8278 );
8279 fragments.insert(
8280 "parentDisabledTemplate".to_string(),
8281 FragmentList {
8282 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8283 contains_boundary: false,
8284 },
8285 );
8286 fragments.insert(
8287 "child-component".to_string(),
8288 FragmentList {
8289 fragments: vec![
8290 WebUIFragment::if_cond(
8291 ConditionExpr::identifier("disabled"),
8292 "childDisabledTemplate",
8293 ),
8294 WebUIFragment::if_cond(
8295 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8296 "childEnabledTemplate",
8297 ),
8298 ],
8299 contains_boundary: false,
8300 },
8301 );
8302 fragments.insert(
8303 "childDisabledTemplate".to_string(),
8304 FragmentList {
8305 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8306 contains_boundary: false,
8307 },
8308 );
8309 fragments.insert(
8310 "childEnabledTemplate".to_string(),
8311 FragmentList {
8312 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8313 contains_boundary: false,
8314 },
8315 );
8316
8317 {
8319 let protocol = WebUIProtocol::new(fragments.clone());
8320 let state = test_json!({"isDisabled": true});
8321 let mut writer = TestWriter::new();
8322 handle(
8323 &protocol,
8324 &state,
8325 &RenderOptions::new("index.html", "/"),
8326 &mut writer,
8327 )
8328 .unwrap();
8329 assert_eq!(
8330 writer.get_content(),
8331 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8332 );
8333 }
8334
8335 {
8337 let protocol = WebUIProtocol::new(fragments.clone());
8338 let state = test_json!({"isDisabled": false});
8339 let mut writer = TestWriter::new();
8340 handle(
8341 &protocol,
8342 &state,
8343 &RenderOptions::new("index.html", "/"),
8344 &mut writer,
8345 )
8346 .unwrap();
8347 assert_eq!(
8348 writer.get_content(),
8349 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8350 );
8351 }
8352 }
8353
8354 #[test]
8361 fn test_component_with_template() {
8362 let mut fragments = HashMap::new();
8363 fragments.insert(
8364 "index.html".to_string(),
8365 FragmentList {
8366 fragments: vec![
8367 WebUIFragment::raw("<custom-element>"),
8368 WebUIFragment::component("custom-element"),
8369 WebUIFragment::raw("</custom-element>"),
8370 ],
8371 contains_boundary: false,
8372 },
8373 );
8374 fragments.insert(
8375 "custom-element".to_string(),
8376 FragmentList {
8377 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8378 contains_boundary: false,
8379 },
8380 );
8381 let protocol = WebUIProtocol::new(fragments);
8382 let state = test_json!({});
8383 let mut writer = TestWriter::new();
8384 handle(
8385 &protocol,
8386 &state,
8387 &RenderOptions::new("index.html", "/"),
8388 &mut writer,
8389 )
8390 .unwrap();
8391 assert_eq!(
8392 writer.get_content(),
8393 "<custom-element><div>Custom Element</div></custom-element>"
8394 );
8395 assert!(writer.is_ended());
8396 }
8397
8398 #[test]
8399 fn test_component_with_slots() {
8400 let mut fragments = HashMap::new();
8401 fragments.insert(
8402 "index.html".to_string(),
8403 FragmentList {
8404 fragments: vec![
8405 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8406 WebUIFragment::component("custom-element"),
8407 WebUIFragment::raw("Hello World</custom-element>"),
8408 ],
8409 contains_boundary: false,
8410 },
8411 );
8412 fragments.insert(
8413 "custom-element".to_string(),
8414 FragmentList {
8415 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8416 contains_boundary: false,
8417 },
8418 );
8419 let protocol = WebUIProtocol::new(fragments);
8420 let state = test_json!({});
8421 let mut writer = TestWriter::new();
8422 handle(
8423 &protocol,
8424 &state,
8425 &RenderOptions::new("index.html", "/"),
8426 &mut writer,
8427 )
8428 .unwrap();
8429 assert_eq!(
8430 writer.get_content(),
8431 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8432 );
8433 assert!(writer.is_ended());
8434 }
8435
8436 #[test]
8437 fn test_multiple_nested_components() {
8438 let mut fragments = HashMap::new();
8439 fragments.insert(
8440 "index.html".to_string(),
8441 FragmentList {
8442 fragments: vec![
8443 WebUIFragment::raw("<div>"),
8444 WebUIFragment::for_loop("item", "items", "templateRepeat"),
8445 WebUIFragment::raw("</div>"),
8446 ],
8447 contains_boundary: false,
8448 },
8449 );
8450 fragments.insert(
8451 "custom-button".to_string(),
8452 FragmentList {
8453 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8454 contains_boundary: false,
8455 },
8456 );
8457 fragments.insert(
8458 "custom-element".to_string(),
8459 FragmentList {
8460 fragments: vec![
8461 WebUIFragment::raw("<custom-child>"),
8462 WebUIFragment::component("custom-child"),
8463 WebUIFragment::raw("</custom-child><slot></slot>"),
8464 ],
8465 contains_boundary: false,
8466 },
8467 );
8468 fragments.insert(
8469 "custom-child".to_string(),
8470 FragmentList {
8471 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8472 contains_boundary: false,
8473 },
8474 );
8475 fragments.insert(
8476 "templateRepeat".to_string(),
8477 FragmentList {
8478 fragments: vec![
8479 WebUIFragment::raw("<custom-element>"),
8480 WebUIFragment::component("custom-element"),
8481 WebUIFragment::raw("<custom-button>"),
8482 WebUIFragment::component("custom-button"),
8483 WebUIFragment::raw("Ok</custom-button></custom-element>"),
8484 ],
8485 contains_boundary: false,
8486 },
8487 );
8488 let protocol = WebUIProtocol::new(fragments);
8489 let state = test_json!({"items": [{"name": "Item1"}]});
8490 let mut writer = TestWriter::new();
8491 handle(
8492 &protocol,
8493 &state,
8494 &RenderOptions::new("index.html", "/"),
8495 &mut writer,
8496 )
8497 .unwrap();
8498 assert_eq!(
8499 writer.get_content(),
8500 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8501 );
8502 assert!(writer.is_ended());
8503 }
8504
8505 #[test]
8508 fn test_if_with_binary_expression() {
8509 let mut fragments = HashMap::new();
8510 fragments.insert(
8511 "index.html".to_string(),
8512 FragmentList {
8513 fragments: vec![
8514 WebUIFragment::raw("<div>"),
8515 WebUIFragment::if_cond(
8516 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8517 "if-1",
8518 ),
8519 WebUIFragment::raw("</div>"),
8520 ],
8521 contains_boundary: false,
8522 },
8523 );
8524 fragments.insert(
8525 "if-1".to_string(),
8526 FragmentList {
8527 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8528 contains_boundary: false,
8529 },
8530 );
8531 let protocol = WebUIProtocol::new(fragments);
8532
8533 let state_true = test_json!({"x": 10});
8535 let mut writer_true = TestWriter::new();
8536 handle(
8537 &protocol,
8538 &state_true,
8539 &RenderOptions::new("index.html", "/"),
8540 &mut writer_true,
8541 )
8542 .unwrap();
8543 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8544
8545 let state_false = test_json!({"x": 1});
8547 let mut writer_false = TestWriter::new();
8548 handle(
8549 &protocol,
8550 &state_false,
8551 &RenderOptions::new("index.html", "/"),
8552 &mut writer_false,
8553 )
8554 .unwrap();
8555 assert_eq!(writer_false.get_content(), "<div></div>");
8556 }
8557
8558 #[test]
8559 fn test_for_if_overlapping_local_state() {
8560 let mut fragments = HashMap::new();
8561 fragments.insert(
8562 "index.html".to_string(),
8563 FragmentList {
8564 fragments: vec![
8565 WebUIFragment::raw("<div>"),
8566 WebUIFragment::for_loop("item", "items", "template1"),
8567 WebUIFragment::raw("</div>"),
8568 ],
8569 contains_boundary: false,
8570 },
8571 );
8572 fragments.insert(
8573 "template1".to_string(),
8574 FragmentList {
8575 fragments: vec![
8576 WebUIFragment::raw("<div>"),
8577 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8578 WebUIFragment::raw("</div>"),
8579 ],
8580 contains_boundary: false,
8581 },
8582 );
8583 fragments.insert(
8584 "ifBlock".to_string(),
8585 FragmentList {
8586 fragments: vec![
8587 WebUIFragment::raw("<span>"),
8588 WebUIFragment::signal("item.label", false),
8589 WebUIFragment::raw("</span>"),
8590 ],
8591 contains_boundary: false,
8592 },
8593 );
8594 let protocol = WebUIProtocol::new(fragments);
8595 let state = test_json!({
8596 "flag": false,
8597 "items": [
8598 {"label": "A", "flag": true},
8599 {"label": "B", "flag": false},
8600 {"label": "C", "flag": true}
8601 ]
8602 });
8603 let mut writer = TestWriter::new();
8604 handle(
8605 &protocol,
8606 &state,
8607 &RenderOptions::new("index.html", "/"),
8608 &mut writer,
8609 )
8610 .unwrap();
8611 assert_eq!(
8612 writer.get_content(),
8613 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8614 );
8615 }
8616
8617 #[test]
8618 fn test_for_if_global_flag_no_effect() {
8619 let mut fragments = HashMap::new();
8620 fragments.insert(
8621 "index.html".to_string(),
8622 FragmentList {
8623 fragments: vec![
8624 WebUIFragment::raw("<div>"),
8625 WebUIFragment::for_loop("item", "items", "template1"),
8626 WebUIFragment::raw("</div>"),
8627 ],
8628 contains_boundary: false,
8629 },
8630 );
8631 fragments.insert(
8632 "template1".to_string(),
8633 FragmentList {
8634 fragments: vec![
8635 WebUIFragment::raw("<div>"),
8636 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8637 WebUIFragment::raw("</div>"),
8638 ],
8639 contains_boundary: false,
8640 },
8641 );
8642 fragments.insert(
8643 "ifBlock".to_string(),
8644 FragmentList {
8645 fragments: vec![
8646 WebUIFragment::raw("<span>"),
8647 WebUIFragment::signal("item.label", false),
8648 WebUIFragment::raw("</span>"),
8649 ],
8650 contains_boundary: false,
8651 },
8652 );
8653 let protocol = WebUIProtocol::new(fragments);
8654 let state = test_json!({
8655 "item": {"flag": true},
8656 "items": [
8657 {"label": "A", "flag": false},
8658 {"label": "B", "flag": true},
8659 {"label": "C", "flag": false}
8660 ]
8661 });
8662 let mut writer = TestWriter::new();
8663 handle(
8664 &protocol,
8665 &state,
8666 &RenderOptions::new("index.html", "/"),
8667 &mut writer,
8668 )
8669 .unwrap();
8670 assert_eq!(
8671 writer.get_content(),
8672 "<div><div></div><div><span>B</span></div><div></div></div>"
8673 );
8674 }
8675
8676 #[test]
8679 fn test_recursive_template_refs() {
8680 let mut fragments = HashMap::new();
8681 fragments.insert(
8682 "index.html".to_string(),
8683 FragmentList {
8684 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8685 contains_boundary: false,
8686 },
8687 );
8688 fragments.insert(
8689 "static".to_string(),
8690 FragmentList {
8691 fragments: vec![
8692 WebUIFragment::raw("<div expanded=\""),
8693 WebUIFragment::signal("item.expanded", false),
8694 WebUIFragment::raw("\" class=\""),
8695 WebUIFragment::signal("testScenario", false),
8696 WebUIFragment::raw("\"><span>"),
8697 WebUIFragment::signal("item.name", false),
8698 WebUIFragment::raw("</span>"),
8699 WebUIFragment::for_loop("item", "item.children", "static"),
8700 WebUIFragment::raw("</div>"),
8701 ],
8702 contains_boundary: false,
8703 },
8704 );
8705 let protocol = WebUIProtocol::new(fragments);
8706 let state = test_json!({
8707 "testScenario": "RecursiveTemplatesWithGlobalState",
8708 "items": [
8709 {"name": "A", "expanded": "false", "children": []},
8710 {"name": "B", "expanded": "true", "children": [
8711 {"name": "C", "expanded": "false"},
8712 {"name": "D", "expanded": "false"}
8713 ]},
8714 {"name": "E", "expanded": "false"}
8715 ]
8716 });
8717 let mut writer = TestWriter::new();
8718 handle(
8719 &protocol,
8720 &state,
8721 &RenderOptions::new("index.html", "/"),
8722 &mut writer,
8723 )
8724 .unwrap();
8725 assert_eq!(
8726 writer.get_content(),
8727 "<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>"
8728 );
8729 }
8730
8731 #[test]
8734 fn test_component_in_for_no_local_access() {
8735 let mut fragments = HashMap::new();
8736 fragments.insert(
8737 "index.html".to_string(),
8738 FragmentList {
8739 fragments: vec![
8740 WebUIFragment::raw("<div>"),
8741 WebUIFragment::for_loop("item", "items", "templateComponent"),
8742 WebUIFragment::raw("</div>"),
8743 ],
8744 contains_boundary: false,
8745 },
8746 );
8747 fragments.insert(
8748 "templateComponent".to_string(),
8749 FragmentList {
8750 fragments: vec![
8751 WebUIFragment::raw("<component-tag>"),
8752 WebUIFragment::component("my-component"),
8753 WebUIFragment::raw("</component-tag>"),
8754 ],
8755 contains_boundary: false,
8756 },
8757 );
8758 fragments.insert(
8759 "my-component".to_string(),
8760 FragmentList {
8761 fragments: vec![
8762 WebUIFragment::raw("<span>"),
8763 WebUIFragment::signal("name", false),
8764 WebUIFragment::raw("</span>"),
8765 ],
8766 contains_boundary: false,
8767 },
8768 );
8769 let protocol = WebUIProtocol::new(fragments);
8770 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8771 let mut writer = TestWriter::new();
8772 handle(
8773 &protocol,
8774 &state,
8775 &RenderOptions::new("index.html", "/"),
8776 &mut writer,
8777 )
8778 .unwrap();
8779 assert_eq!(
8780 writer.get_content(),
8781 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8782 );
8783 }
8784
8785 #[test]
8786 fn test_nested_for_hierarchical_state() {
8787 let mut fragments = HashMap::new();
8788 fragments.insert(
8789 "index.html".to_string(),
8790 FragmentList {
8791 fragments: vec![
8792 WebUIFragment::raw("<div>"),
8793 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8794 WebUIFragment::raw("</div>"),
8795 ],
8796 contains_boundary: false,
8797 },
8798 );
8799 fragments.insert(
8800 "outerTemplate".to_string(),
8801 FragmentList {
8802 fragments: vec![
8803 WebUIFragment::raw("<section>"),
8804 WebUIFragment::signal("globalPrefix", false),
8805 WebUIFragment::signal("outerItem.outerLabel", false),
8806 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8807 WebUIFragment::raw("</section>"),
8808 ],
8809 contains_boundary: false,
8810 },
8811 );
8812 fragments.insert(
8813 "innerTemplate".to_string(),
8814 FragmentList {
8815 fragments: vec![
8816 WebUIFragment::raw("<p>"),
8817 WebUIFragment::signal("globalPrefix", false),
8818 WebUIFragment::signal("outerItem.outerLabel", false),
8819 WebUIFragment::raw(": "),
8820 WebUIFragment::signal("innerItem.innerLabel", false),
8821 WebUIFragment::raw("</p>"),
8822 ],
8823 contains_boundary: false,
8824 },
8825 );
8826 let protocol = WebUIProtocol::new(fragments);
8827 let state = test_json!({
8828 "globalPrefix": "Prefix: ",
8829 "outerItems": [
8830 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8831 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8832 ]
8833 });
8834 let mut writer = TestWriter::new();
8835 handle(
8836 &protocol,
8837 &state,
8838 &RenderOptions::new("index.html", "/"),
8839 &mut writer,
8840 )
8841 .unwrap();
8842 assert_eq!(
8843 writer.get_content(),
8844 "<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>"
8845 );
8846 }
8847
8848 #[test]
8849 fn test_component_in_for_global_only() {
8850 let mut fragments = HashMap::new();
8851 fragments.insert(
8852 "index.html".to_string(),
8853 FragmentList {
8854 fragments: vec![
8855 WebUIFragment::raw("<div>"),
8856 WebUIFragment::for_loop("item", "items", "templateComponent"),
8857 WebUIFragment::raw("</div>"),
8858 ],
8859 contains_boundary: false,
8860 },
8861 );
8862 fragments.insert(
8863 "templateComponent".to_string(),
8864 FragmentList {
8865 fragments: vec![
8866 WebUIFragment::raw("<component-tag>"),
8867 WebUIFragment::component("my-component"),
8868 WebUIFragment::raw("</component-tag>"),
8869 ],
8870 contains_boundary: false,
8871 },
8872 );
8873 fragments.insert(
8874 "my-component".to_string(),
8875 FragmentList {
8876 fragments: vec![
8877 WebUIFragment::raw("<span>"),
8878 WebUIFragment::signal("name", false),
8879 WebUIFragment::raw("-"),
8880 WebUIFragment::signal("globalSuffix", false),
8881 WebUIFragment::raw("</span>"),
8882 ],
8883 contains_boundary: false,
8884 },
8885 );
8886 let protocol = WebUIProtocol::new(fragments);
8887 let state =
8888 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8889 let mut writer = TestWriter::new();
8890 handle(
8891 &protocol,
8892 &state,
8893 &RenderOptions::new("index.html", "/"),
8894 &mut writer,
8895 )
8896 .unwrap();
8897 assert_eq!(
8898 writer.get_content(),
8899 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8900 );
8901 }
8902
8903 #[test]
8904 fn test_component_no_item_moniker() {
8905 let mut fragments = HashMap::new();
8906 fragments.insert(
8907 "index.html".to_string(),
8908 FragmentList {
8909 fragments: vec![
8910 WebUIFragment::raw("<div>"),
8911 WebUIFragment::for_loop("item", "items", "templateComponent"),
8912 WebUIFragment::raw("</div>"),
8913 ],
8914 contains_boundary: false,
8915 },
8916 );
8917 fragments.insert(
8918 "templateComponent".to_string(),
8919 FragmentList {
8920 fragments: vec![
8921 WebUIFragment::raw("<component-tag>"),
8922 WebUIFragment::component("my-component"),
8923 WebUIFragment::raw("</component-tag>"),
8924 ],
8925 contains_boundary: false,
8926 },
8927 );
8928 fragments.insert(
8929 "my-component".to_string(),
8930 FragmentList {
8931 fragments: vec![
8932 WebUIFragment::raw("<span>"),
8933 WebUIFragment::signal("item.name", false),
8934 WebUIFragment::raw("-"),
8935 WebUIFragment::signal("globalSuffix", false),
8936 WebUIFragment::raw("</span>"),
8937 ],
8938 contains_boundary: false,
8939 },
8940 );
8941 let protocol = WebUIProtocol::new(fragments);
8942 let state =
8943 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8944 let mut writer = TestWriter::new();
8945 handle(
8946 &protocol,
8947 &state,
8948 &RenderOptions::new("index.html", "/"),
8949 &mut writer,
8950 )
8951 .unwrap();
8952 assert_eq!(
8953 writer.get_content(),
8954 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8955 );
8956 }
8957
8958 #[test]
8959 fn test_for_nonqualified_uses_global() {
8960 let mut fragments = HashMap::new();
8961 fragments.insert(
8962 "index.html".to_string(),
8963 FragmentList {
8964 fragments: vec![
8965 WebUIFragment::raw("<div>"),
8966 WebUIFragment::for_loop("item", "items", "template1"),
8967 WebUIFragment::raw("</div>"),
8968 ],
8969 contains_boundary: false,
8970 },
8971 );
8972 fragments.insert(
8973 "template1".to_string(),
8974 FragmentList {
8975 fragments: vec![
8976 WebUIFragment::raw("<span>"),
8977 WebUIFragment::signal("name", false),
8978 WebUIFragment::raw("</span>"),
8979 ],
8980 contains_boundary: false,
8981 },
8982 );
8983 let protocol = WebUIProtocol::new(fragments);
8984 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8985 let mut writer = TestWriter::new();
8986 handle(
8987 &protocol,
8988 &state,
8989 &RenderOptions::new("index.html", "/"),
8990 &mut writer,
8991 )
8992 .unwrap();
8993 assert_eq!(
8994 writer.get_content(),
8995 "<div><span>GlobalName</span><span>GlobalName</span></div>"
8996 );
8997 }
8998
8999 #[test]
9000 fn test_nested_for_if_interleaved() {
9001 let mut fragments = HashMap::new();
9002 fragments.insert(
9003 "index.html".to_string(),
9004 FragmentList {
9005 fragments: vec![
9006 WebUIFragment::raw("<div>"),
9007 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9008 WebUIFragment::raw("</div>"),
9009 ],
9010 contains_boundary: false,
9011 },
9012 );
9013 fragments.insert(
9014 "outerTemplate".to_string(),
9015 FragmentList {
9016 fragments: vec![
9017 WebUIFragment::raw("<section>"),
9018 WebUIFragment::signal("globalPrefix", false),
9019 WebUIFragment::signal("outerItem.outerLabel", false),
9020 WebUIFragment::if_cond(
9021 ConditionExpr::identifier("outerItem.include"),
9022 "ifTemplate",
9023 ),
9024 WebUIFragment::raw("</section>"),
9025 ],
9026 contains_boundary: false,
9027 },
9028 );
9029 fragments.insert(
9030 "ifTemplate".to_string(),
9031 FragmentList {
9032 fragments: vec![
9033 WebUIFragment::raw("<div>"),
9034 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9035 WebUIFragment::raw("</div>"),
9036 ],
9037 contains_boundary: false,
9038 },
9039 );
9040 fragments.insert(
9041 "innerTemplate".to_string(),
9042 FragmentList {
9043 fragments: vec![
9044 WebUIFragment::raw("<p>"),
9045 WebUIFragment::signal("globalSuffix", false),
9046 WebUIFragment::raw(": "),
9047 WebUIFragment::signal("innerItem.innerLabel", false),
9048 WebUIFragment::raw("</p>"),
9049 ],
9050 contains_boundary: false,
9051 },
9052 );
9053 let protocol = WebUIProtocol::new(fragments);
9054 let state = test_json!({
9055 "globalPrefix": "Prefix: ",
9056 "globalSuffix": "Suffix",
9057 "outerItems": [
9058 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9059 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9060 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9061 ]
9062 });
9063 let mut writer = TestWriter::new();
9064 handle(
9065 &protocol,
9066 &state,
9067 &RenderOptions::new("index.html", "/"),
9068 &mut writer,
9069 )
9070 .unwrap();
9071 assert_eq!(
9072 writer.get_content(),
9073 "<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>"
9074 );
9075 }
9076
9077 #[test]
9078 fn test_nested_for_if_outer_state() {
9079 let mut fragments = HashMap::new();
9080 fragments.insert(
9081 "index.html".to_string(),
9082 FragmentList {
9083 fragments: vec![
9084 WebUIFragment::raw("<div>"),
9085 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9086 WebUIFragment::raw("</div>"),
9087 ],
9088 contains_boundary: false,
9089 },
9090 );
9091 fragments.insert(
9092 "outerTemplate".to_string(),
9093 FragmentList {
9094 fragments: vec![
9095 WebUIFragment::raw("<section>"),
9096 WebUIFragment::signal("globalPrefix", false),
9097 WebUIFragment::signal("outerItem.label", false),
9098 WebUIFragment::for_loop(
9099 "middleItem",
9100 "outerItem.middleItems",
9101 "middleTemplate",
9102 ),
9103 WebUIFragment::raw("</section>"),
9104 ],
9105 contains_boundary: false,
9106 },
9107 );
9108 fragments.insert(
9109 "middleTemplate".to_string(),
9110 FragmentList {
9111 fragments: vec![
9112 WebUIFragment::raw("<div>"),
9113 WebUIFragment::if_cond(
9114 ConditionExpr::identifier("outerItem.active"),
9115 "ifTemplate",
9116 ),
9117 WebUIFragment::raw("</div>"),
9118 ],
9119 contains_boundary: false,
9120 },
9121 );
9122 fragments.insert(
9123 "ifTemplate".to_string(),
9124 FragmentList {
9125 fragments: vec![
9126 WebUIFragment::raw("<p>"),
9127 WebUIFragment::signal("middleItem.value", false),
9128 WebUIFragment::raw("</p>"),
9129 ],
9130 contains_boundary: false,
9131 },
9132 );
9133 let protocol = WebUIProtocol::new(fragments);
9134 let state = test_json!({
9135 "globalPrefix": "GP-",
9136 "outerItems": [
9137 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9138 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9139 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
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><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>"
9153 );
9154 }
9155
9156 #[test]
9157 fn test_nested_for_if_inner_state() {
9158 let mut fragments = HashMap::new();
9159 fragments.insert(
9160 "index.html".to_string(),
9161 FragmentList {
9162 fragments: vec![
9163 WebUIFragment::raw("<div>"),
9164 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9165 WebUIFragment::raw("</div>"),
9166 ],
9167 contains_boundary: false,
9168 },
9169 );
9170 fragments.insert(
9171 "outerTemplate".to_string(),
9172 FragmentList {
9173 fragments: vec![
9174 WebUIFragment::raw("<section>"),
9175 WebUIFragment::signal("outerItem.label", false),
9176 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9177 WebUIFragment::raw("</section>"),
9178 ],
9179 contains_boundary: false,
9180 },
9181 );
9182 fragments.insert(
9183 "innerTemplate".to_string(),
9184 FragmentList {
9185 fragments: vec![
9186 WebUIFragment::raw("<article>"),
9187 WebUIFragment::if_cond(
9188 ConditionExpr::identifier("innerItem.show"),
9189 "ifTemplate",
9190 ),
9191 WebUIFragment::raw("</article>"),
9192 ],
9193 contains_boundary: false,
9194 },
9195 );
9196 fragments.insert(
9197 "ifTemplate".to_string(),
9198 FragmentList {
9199 fragments: vec![
9200 WebUIFragment::raw("<p>"),
9201 WebUIFragment::signal("innerItem.detail", false),
9202 WebUIFragment::raw("</p>"),
9203 ],
9204 contains_boundary: false,
9205 },
9206 );
9207 let protocol = WebUIProtocol::new(fragments);
9208 let state = test_json!({
9209 "outerItems": [
9210 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9211 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9212 ]
9213 });
9214 let mut writer = TestWriter::new();
9215 handle(
9216 &protocol,
9217 &state,
9218 &RenderOptions::new("index.html", "/"),
9219 &mut writer,
9220 )
9221 .unwrap();
9222 assert_eq!(
9223 writer.get_content(),
9224 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9225 );
9226 }
9227
9228 #[test]
9229 fn test_for_merge_local_global_monikers() {
9230 let mut fragments = HashMap::new();
9231 fragments.insert(
9232 "index.html".to_string(),
9233 FragmentList {
9234 fragments: vec![
9235 WebUIFragment::raw("<div>"),
9236 WebUIFragment::for_loop("item", "items", "template1"),
9237 WebUIFragment::raw("</div>"),
9238 ],
9239 contains_boundary: false,
9240 },
9241 );
9242 fragments.insert(
9243 "template1".to_string(),
9244 FragmentList {
9245 fragments: vec![
9246 WebUIFragment::raw("<span>"),
9247 WebUIFragment::signal("item.name", false),
9248 WebUIFragment::raw("-"),
9249 WebUIFragment::signal("item.globalValue", false),
9250 WebUIFragment::raw("-"),
9251 WebUIFragment::signal("item.localOnly", false),
9252 WebUIFragment::raw("-"),
9253 WebUIFragment::signal("item.otherVal", false),
9254 WebUIFragment::raw("</span>"),
9255 ],
9256 contains_boundary: false,
9257 },
9258 );
9259 let protocol = WebUIProtocol::new(fragments);
9260 let state = test_json!({
9261 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9262 "items": [
9263 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9264 {"name": "Local2", "localOnly": "Only2"}
9265 ]
9266 });
9267 let mut writer = TestWriter::new();
9268 handle(
9269 &protocol,
9270 &state,
9271 &RenderOptions::new("index.html", "/"),
9272 &mut writer,
9273 )
9274 .unwrap();
9275 assert_eq!(
9276 writer.get_content(),
9277 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9278 );
9279 }
9280
9281 #[test]
9282 fn test_component_in_for_global_moniker_shadow() {
9283 let mut fragments = HashMap::new();
9284 fragments.insert(
9285 "index.html".to_string(),
9286 FragmentList {
9287 fragments: vec![
9288 WebUIFragment::raw("<div>"),
9289 WebUIFragment::for_loop("item", "items", "templateComponent"),
9290 WebUIFragment::raw("</div>"),
9291 ],
9292 contains_boundary: false,
9293 },
9294 );
9295 fragments.insert(
9296 "templateComponent".to_string(),
9297 FragmentList {
9298 fragments: vec![
9299 WebUIFragment::raw("<component-tag>"),
9300 WebUIFragment::component("my-component"),
9301 WebUIFragment::raw("</component-tag>"),
9302 ],
9303 contains_boundary: false,
9304 },
9305 );
9306 fragments.insert(
9307 "my-component".to_string(),
9308 FragmentList {
9309 fragments: vec![
9310 WebUIFragment::raw("<span>"),
9311 WebUIFragment::signal("name", false),
9312 WebUIFragment::raw("-"),
9313 WebUIFragment::signal("item.globalValue", false),
9314 WebUIFragment::raw("-"),
9315 WebUIFragment::signal("localOnly", false),
9316 WebUIFragment::raw("-"),
9317 WebUIFragment::signal("item.otherVal", false),
9318 WebUIFragment::raw("</span>"),
9319 ],
9320 contains_boundary: false,
9321 },
9322 );
9323 let protocol = WebUIProtocol::new(fragments);
9324 let state = test_json!({
9325 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9326 "items": [
9327 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9328 {"name": "Local2", "localOnly": "Only2"}
9329 ]
9330 });
9331 let mut writer = TestWriter::new();
9332 handle(
9333 &protocol,
9334 &state,
9335 &RenderOptions::new("index.html", "/"),
9336 &mut writer,
9337 )
9338 .unwrap();
9339 assert_eq!(
9340 writer.get_content(),
9341 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9342 );
9343 }
9344
9345 #[test]
9346 fn test_if_in_nested_for_local_flag() {
9347 let mut fragments = HashMap::new();
9348 fragments.insert(
9349 "index.html".to_string(),
9350 FragmentList {
9351 fragments: vec![
9352 WebUIFragment::raw("<div>"),
9353 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9354 WebUIFragment::raw("</div>"),
9355 ],
9356 contains_boundary: false,
9357 },
9358 );
9359 fragments.insert(
9360 "outerTemplate".to_string(),
9361 FragmentList {
9362 fragments: vec![
9363 WebUIFragment::raw("<section>"),
9364 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9365 WebUIFragment::raw("</section>"),
9366 ],
9367 contains_boundary: false,
9368 },
9369 );
9370 fragments.insert(
9371 "innerTemplate".to_string(),
9372 FragmentList {
9373 fragments: vec![WebUIFragment::if_cond(
9374 ConditionExpr::identifier("inner_item.flag"),
9375 "ifInner",
9376 )],
9377 contains_boundary: false,
9378 },
9379 );
9380 fragments.insert(
9381 "ifInner".to_string(),
9382 FragmentList {
9383 fragments: vec![
9384 WebUIFragment::raw("<p>"),
9385 WebUIFragment::signal("inner_item.value", false),
9386 WebUIFragment::raw("</p>"),
9387 ],
9388 contains_boundary: false,
9389 },
9390 );
9391 let protocol = WebUIProtocol::new(fragments);
9392 let state = test_json!({
9393 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9394 "inner_item": {"flag": false}
9395 });
9396 let mut writer = TestWriter::new();
9397 handle(
9398 &protocol,
9399 &state,
9400 &RenderOptions::new("index.html", "/"),
9401 &mut writer,
9402 )
9403 .unwrap();
9404 assert_eq!(
9405 writer.get_content(),
9406 "<div><section><p>X</p></section></div>"
9407 );
9408 }
9409
9410 #[test]
9411 fn test_if_in_nested_for_global_fallback() {
9412 let mut fragments = HashMap::new();
9413 fragments.insert(
9414 "index.html".to_string(),
9415 FragmentList {
9416 fragments: vec![
9417 WebUIFragment::raw("<div>"),
9418 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9419 WebUIFragment::raw("</div>"),
9420 ],
9421 contains_boundary: false,
9422 },
9423 );
9424 fragments.insert(
9425 "outerTemplate".to_string(),
9426 FragmentList {
9427 fragments: vec![
9428 WebUIFragment::raw("<section>"),
9429 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9430 WebUIFragment::raw("</section>"),
9431 ],
9432 contains_boundary: false,
9433 },
9434 );
9435 fragments.insert(
9436 "innerTemplate".to_string(),
9437 FragmentList {
9438 fragments: vec![WebUIFragment::if_cond(
9439 ConditionExpr::identifier("inner_item.flag"),
9440 "ifInner",
9441 )],
9442 contains_boundary: false,
9443 },
9444 );
9445 fragments.insert(
9446 "ifInner".to_string(),
9447 FragmentList {
9448 fragments: vec![
9449 WebUIFragment::raw("<p>"),
9450 WebUIFragment::signal("inner_item.value", false),
9451 WebUIFragment::raw("</p>"),
9452 ],
9453 contains_boundary: false,
9454 },
9455 );
9456 let protocol = WebUIProtocol::new(fragments);
9457 let state = test_json!({
9458 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9459 "inner_item": {"flag": true}
9460 });
9461 let mut writer = TestWriter::new();
9462 handle(
9463 &protocol,
9464 &state,
9465 &RenderOptions::new("index.html", "/"),
9466 &mut writer,
9467 )
9468 .unwrap();
9469 assert_eq!(
9470 writer.get_content(),
9471 "<div><section><p>X</p><p>Y</p></section></div>"
9472 );
9473 }
9474
9475 #[test]
9476 fn test_if_mixed_for_monikers() {
9477 let mut fragments = HashMap::new();
9478 fragments.insert(
9479 "index.html".to_string(),
9480 FragmentList {
9481 fragments: vec![
9482 WebUIFragment::raw("<div>"),
9483 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9484 WebUIFragment::raw("</div>"),
9485 ],
9486 contains_boundary: false,
9487 },
9488 );
9489 fragments.insert(
9490 "outerTemplate".to_string(),
9491 FragmentList {
9492 fragments: vec![
9493 WebUIFragment::raw("<section>"),
9494 WebUIFragment::signal("outer.outerLabel", false),
9495 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9496 WebUIFragment::raw("</section>"),
9497 ],
9498 contains_boundary: false,
9499 },
9500 );
9501 fragments.insert(
9502 "innerTemplate".to_string(),
9503 FragmentList {
9504 fragments: vec![WebUIFragment::if_cond(
9505 ConditionExpr::compound(
9506 ConditionExpr::identifier("outer.active"),
9507 LogicalOperator::And,
9508 ConditionExpr::predicate(
9509 "inner.value",
9510 ComparisonOperator::GreaterThan,
9511 "globalLimit",
9512 ),
9513 ),
9514 "ifInner",
9515 )],
9516 contains_boundary: false,
9517 },
9518 );
9519 fragments.insert(
9520 "ifInner".to_string(),
9521 FragmentList {
9522 fragments: vec![
9523 WebUIFragment::raw("<p>"),
9524 WebUIFragment::signal("inner.value", false),
9525 WebUIFragment::raw("</p>"),
9526 ],
9527 contains_boundary: false,
9528 },
9529 );
9530 let protocol = WebUIProtocol::new(fragments);
9531 let state = test_json!({
9532 "globalLimit": 10,
9533 "list": {"outerItems": [
9534 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9535 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9536 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9537 ]}
9538 });
9539 let mut writer = TestWriter::new();
9540 handle(
9541 &protocol,
9542 &state,
9543 &RenderOptions::new("index.html", "/"),
9544 &mut writer,
9545 )
9546 .unwrap();
9547 assert_eq!(
9548 writer.get_content(),
9549 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9550 );
9551 }
9552
9553 fn make_route_protocol() -> WebUIProtocol {
9556 use webui_protocol::WebUiFragmentRoute;
9557
9558 let mut fragments = HashMap::new();
9559
9560 fragments.insert(
9562 "index.html".to_string(),
9563 FragmentList {
9564 fragments: vec![
9565 WebUIFragment::raw("<h1>Shell</h1>"),
9566 WebUIFragment::route_from(WebUiFragmentRoute {
9567 path: "/".into(),
9568 fragment_id: "dash-page".into(),
9569 exact: true,
9570 keep_alive: false,
9571 ..Default::default()
9572 }),
9573 WebUIFragment::route_from(WebUiFragmentRoute {
9574 path: "/contacts/:id".into(),
9575 fragment_id: "detail-page".into(),
9576 exact: true,
9577 keep_alive: true,
9578 ..Default::default()
9579 }),
9580 ],
9581
9582 contains_boundary: false,
9583 },
9584 );
9585
9586 fragments.insert(
9588 "dash-page".to_string(),
9589 FragmentList {
9590 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9591 contains_boundary: false,
9592 },
9593 );
9594
9595 fragments.insert(
9597 "detail-page".to_string(),
9598 FragmentList {
9599 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9600 contains_boundary: false,
9601 },
9602 );
9603
9604 WebUIProtocol::new(fragments)
9605 }
9606
9607 fn make_nested_route_protocol() -> WebUIProtocol {
9608 use webui_protocol::WebUiFragmentRoute;
9609
9610 let mut fragments = HashMap::new();
9611
9612 fragments.insert(
9613 "index.html".to_string(),
9614 FragmentList {
9615 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9616 path: "/".into(),
9617 fragment_id: "app-shell".into(),
9618 exact: false,
9619 children: vec![WebUiFragmentRoute {
9620 path: "sections/:id".into(),
9621 fragment_id: "section-comp".into(),
9622 exact: false,
9623 children: vec![WebUiFragmentRoute {
9624 path: "topics/:topicId".into(),
9625 fragment_id: "topic-comp".into(),
9626 exact: true,
9627 children: vec![],
9628 keep_alive: false,
9629 ..Default::default()
9630 }],
9631 keep_alive: false,
9632 ..Default::default()
9633 }],
9634 keep_alive: false,
9635 ..Default::default()
9636 })],
9637
9638 contains_boundary: false,
9639 },
9640 );
9641
9642 fragments.insert(
9643 "app-shell".to_string(),
9644 FragmentList {
9645 fragments: vec![
9646 WebUIFragment::raw("<h1>Shell</h1>"),
9647 WebUIFragment::outlet(),
9648 ],
9649 contains_boundary: false,
9650 },
9651 );
9652
9653 fragments.insert(
9654 "section-comp".to_string(),
9655 FragmentList {
9656 fragments: vec![
9657 WebUIFragment::raw("<h2>Section</h2>"),
9658 WebUIFragment::outlet(),
9659 ],
9660 contains_boundary: false,
9661 },
9662 );
9663
9664 fragments.insert(
9665 "topic-comp".to_string(),
9666 FragmentList {
9667 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9668 contains_boundary: false,
9669 },
9670 );
9671
9672 WebUIProtocol::new(fragments)
9673 }
9674
9675 #[test]
9676 fn test_route_renders_shell_always() {
9677 let protocol = make_route_protocol();
9678 let state = test_json!({});
9679 let mut writer = TestWriter::new();
9680 handle(
9681 &protocol,
9682 &state,
9683 &RenderOptions::new("index.html", "/"),
9684 &mut writer,
9685 )
9686 .unwrap();
9687 let html = writer.get_content();
9688
9689 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9691 assert!(html.contains(" active>"), "matched route should be active");
9693 assert!(
9695 html.contains("style=\"display:none\""),
9696 "non-matched routes should be hidden"
9697 );
9698 }
9699
9700 #[test]
9701 fn test_route_matched_renders_visible() {
9702 let protocol = make_route_protocol();
9703 let state = test_json!({});
9704 let mut writer = TestWriter::new();
9705 handle(
9706 &protocol,
9707 &state,
9708 &RenderOptions::new("index.html", "/"),
9709 &mut writer,
9710 )
9711 .unwrap();
9712 let html = writer.get_content();
9713
9714 assert!(
9716 html.contains("<webui-route path=\"/\""),
9717 "dashboard route should exist"
9718 );
9719 assert!(
9720 html.contains("active>") && html.contains("<dash-page"),
9721 "matched route should be active with component tag: {html}"
9722 );
9723 assert!(
9724 html.contains("<p>Dashboard</p>"),
9725 "matched route should have content"
9726 );
9727 }
9728
9729 #[test]
9730 fn test_route_non_matched_renders_hidden_empty() {
9731 let protocol = make_route_protocol();
9732 let state = test_json!({});
9733 let mut writer = TestWriter::new();
9734 handle(
9735 &protocol,
9736 &state,
9737 &RenderOptions::new("index.html", "/"),
9738 &mut writer,
9739 )
9740 .unwrap();
9741 let html = writer.get_content();
9742
9743 assert!(
9745 html.contains("<webui-route path=\"/contacts/:id\""),
9746 "detail route element should exist"
9747 );
9748 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9750 let after_detail = &html[detail_start..];
9751 assert!(
9752 after_detail.contains("style=\"display:none\">")
9753 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9754 "non-matched route should be hidden: {after_detail}"
9755 );
9756 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9758 let detail_body = &after_detail[..detail_end];
9759 assert!(
9760 !detail_body.contains("<detail-page"),
9761 "non-matched route should not render component content: {detail_body}"
9762 );
9763 }
9764
9765 #[test]
9766 fn test_route_parameterized_match() {
9767 let protocol = make_route_protocol();
9768 let state = test_json!({});
9769 let mut writer = TestWriter::new();
9770 handle(
9771 &protocol,
9772 &state,
9773 &RenderOptions::new("index.html", "/contacts/42"),
9774 &mut writer,
9775 )
9776 .unwrap();
9777 let html = writer.get_content();
9778
9779 assert!(
9781 html.contains("active>") && html.contains("<detail-page"),
9782 "detail route should be active: {html}"
9783 );
9784 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9785 let dash_start = html
9787 .find("component=\"dash-page\"")
9788 .expect("dashboard route");
9789 let after_dash = &html[dash_start..];
9790 assert!(
9791 after_dash.contains("style=\"display:none\">"),
9792 "dashboard should be hidden when detail matches: {after_dash}"
9793 );
9794 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9795 let dash_body = &after_dash[..dash_end];
9796 assert!(
9797 !dash_body.contains("<dash-page"),
9798 "dashboard should not render component content: {dash_body}"
9799 );
9800 }
9801
9802 #[test]
9803 fn test_route_no_match_all_hidden_empty() {
9804 let protocol = make_route_protocol();
9805 let state = test_json!({});
9806 let mut writer = TestWriter::new();
9807 handle(
9808 &protocol,
9809 &state,
9810 &RenderOptions::new("index.html", "/nonexistent"),
9811 &mut writer,
9812 )
9813 .unwrap();
9814 let html = writer.get_content();
9815
9816 assert!(html.contains("<h1>Shell</h1>"));
9818 assert!(
9820 !html.contains("<p>Dashboard</p>"),
9821 "no route content when nothing matches"
9822 );
9823 assert!(
9824 !html.contains("<p>Detail</p>"),
9825 "no route content when nothing matches"
9826 );
9827 }
9828
9829 #[test]
9830 fn test_route_component_attr_emitted() {
9831 let protocol = make_route_protocol();
9832 let state = test_json!({});
9833 let mut writer = TestWriter::new();
9834 handle(
9835 &protocol,
9836 &state,
9837 &RenderOptions::new("index.html", "/"),
9838 &mut writer,
9839 )
9840 .unwrap();
9841 let html = writer.get_content();
9842 assert!(
9844 html.contains("component=\"dash-page\""),
9845 "component attr should be on webui-route: {html}"
9846 );
9847 assert!(
9848 html.contains("component=\"detail-page\""),
9849 "component attr should be on webui-route: {html}"
9850 );
9851 assert!(
9852 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9853 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9854 );
9855 }
9856
9857 #[test]
9858 fn test_no_plugin_no_state_attributes() {
9859 let protocol = make_route_protocol();
9860 let state = test_json!({
9861 "title": "Fish & Chips",
9862 "cartOpen": true,
9863 "items": [{"name": "A&B"}]
9864 });
9865 let mut writer = TestWriter::new();
9866
9867 handle(
9868 &protocol,
9869 &state,
9870 &RenderOptions::new("index.html", "/"),
9871 &mut writer,
9872 )
9873 .unwrap();
9874
9875 let html = writer.get_content();
9876 assert!(
9878 !html.contains("data-state"),
9879 "no data-state without plugin: {html}"
9880 );
9881 assert!(
9882 !html.contains(r#"title="Fish"#),
9883 "no scalar attrs without plugin: {html}"
9884 );
9885 }
9886
9887 #[test]
9888 fn test_nested_routes_render_webui_route_as_light_dom() {
9889 let protocol = make_nested_route_protocol();
9890 let state = test_json!({"title": "Test"});
9891 let handler = WebUIHandler::new();
9892 let mut writer = TestWriter::new();
9893
9894 handler
9895 .handle(
9896 &protocol,
9897 &state,
9898 &RenderOptions::new("index.html", "/sections/frontend"),
9899 &mut writer,
9900 )
9901 .expect("render failed");
9902
9903 let html = writer.get_content();
9904
9905 assert!(
9906 html.contains("component=\"app-shell\"") && html.contains("active>"),
9907 "root route should be active: {html}"
9908 );
9909 assert!(
9911 !html.contains("<template shadowrootmode"),
9912 "webui-route should be light DOM (no shadow template): {html}"
9913 );
9914 }
9915
9916 #[test]
9917 fn test_nested_routes_render_outlet_as_light_dom() {
9918 let protocol = make_nested_route_protocol();
9919 let state = test_json!({"title": "Test"});
9920 let handler = WebUIHandler::new();
9921 let mut writer = TestWriter::new();
9922
9923 handler
9924 .handle(
9925 &protocol,
9926 &state,
9927 &RenderOptions::new("index.html", "/sections/frontend"),
9928 &mut writer,
9929 )
9930 .expect("render failed");
9931
9932 let html = writer.get_content();
9933
9934 assert!(
9936 !html.contains("<webui-outlet>"),
9937 "should not contain webui-outlet wrapper: {html}"
9938 );
9939 assert!(
9941 html.contains("<webui-route"),
9942 "should contain webui-route elements: {html}"
9943 );
9944 }
9945
9946 #[test]
9947 fn test_nested_routes_match_child_at_outlet() {
9948 let protocol = make_nested_route_protocol();
9949 let state = test_json!({"title": "Test"});
9950 let handler = WebUIHandler::new();
9951 let mut writer = TestWriter::new();
9952
9953 handler
9954 .handle(
9955 &protocol,
9956 &state,
9957 &RenderOptions::new("index.html", "/sections/frontend"),
9958 &mut writer,
9959 )
9960 .expect("render failed");
9961
9962 let html = writer.get_content();
9963
9964 assert!(
9965 html.contains("component=\"section-comp\"") && html.contains("active>"),
9966 "section route should be active: {html}"
9967 );
9968 assert!(
9969 html.contains("<h2>Section</h2>"),
9970 "section content should be present: {html}"
9971 );
9972 }
9973
9974 #[test]
9975 fn test_nested_routes_three_levels_deep() {
9976 let protocol = make_nested_route_protocol();
9977 let state = test_json!({"title": "Test"});
9978 let handler = WebUIHandler::new();
9979 let mut writer = TestWriter::new();
9980
9981 handler
9982 .handle(
9983 &protocol,
9984 &state,
9985 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9986 &mut writer,
9987 )
9988 .expect("render failed");
9989
9990 let html = writer.get_content();
9991
9992 assert!(
9993 html.contains("component=\"app-shell\"") && html.contains("active>"),
9994 "root active: {html}"
9995 );
9996 assert!(
9997 html.contains("component=\"section-comp\"") && html.contains("active>"),
9998 "section active: {html}"
9999 );
10000 assert!(
10001 html.contains("component=\"topic-comp\"")
10002 && html.contains("exact")
10003 && html.contains("active>"),
10004 "topic active: {html}"
10005 );
10006 assert!(
10007 html.contains("<p>Topic content</p>"),
10008 "leaf content present: {html}"
10009 );
10010 }
10011
10012 #[test]
10013 fn test_nested_routes_nonmatched_siblings_hidden() {
10014 let protocol = make_nested_route_protocol();
10015 let state = test_json!({"title": "Test"});
10016 let handler = WebUIHandler::new();
10017 let mut writer = TestWriter::new();
10018
10019 handler
10020 .handle(
10021 &protocol,
10022 &state,
10023 &RenderOptions::new("index.html", "/sections/frontend"),
10024 &mut writer,
10025 )
10026 .expect("render failed");
10027
10028 let html = writer.get_content();
10029
10030 assert!(
10031 html.contains(r#"component="topic-comp" exact style="display:none">"#),
10032 "topic should be hidden: {html}"
10033 );
10034 }
10035
10036 #[test]
10037 fn test_nested_routes_preserve_sibling_declaration_order() {
10038 let mut protocol = make_nested_route_protocol();
10039 let Some(Fragment::Route(root)) = protocol
10040 .fragments
10041 .get_mut("index.html")
10042 .and_then(|entry| entry.fragments.first_mut())
10043 .and_then(|fragment| fragment.fragment.as_mut())
10044 else {
10045 panic!("nested route fixture must have a root route");
10046 };
10047 root.children.insert(
10048 0,
10049 webui_protocol::WebUiFragmentRoute {
10050 path: String::new(),
10051 fragment_id: "topic-comp".into(),
10052 exact: true,
10053 ..Default::default()
10054 },
10055 );
10056 root.children.push(webui_protocol::WebUiFragmentRoute {
10057 path: "*rest".into(),
10058 fragment_id: "topic-comp".into(),
10059 exact: true,
10060 ..Default::default()
10061 });
10062
10063 for path in ["/", "/sections/frontend", "/missing"] {
10064 let mut writer = TestWriter::new();
10065 WebUIHandler::new()
10066 .handle(
10067 &protocol,
10068 &test_json!({}),
10069 &RenderOptions::new("index.html", path),
10070 &mut writer,
10071 )
10072 .expect("render failed");
10073 let html = writer.get_content();
10074 let first = html.find(r#"<webui-route path="""#).expect("empty route");
10075 let middle = html
10076 .find(r#"<webui-route path="sections/:id""#)
10077 .expect("section route");
10078 let last = html
10079 .find(r#"<webui-route path="*rest""#)
10080 .expect("catch-all route");
10081 assert!(first < middle && middle < last, "{path}: {html}");
10082 }
10083 }
10084
10085 #[test]
10086 fn test_nested_routes_root_only() {
10087 let protocol = make_nested_route_protocol();
10088 let state = test_json!({"title": "Test"});
10089 let handler = WebUIHandler::new();
10090 let mut writer = TestWriter::new();
10091
10092 handler
10093 .handle(
10094 &protocol,
10095 &state,
10096 &RenderOptions::new("index.html", "/"),
10097 &mut writer,
10098 )
10099 .expect("render failed");
10100
10101 let html = writer.get_content();
10102
10103 assert!(
10104 html.contains("component=\"app-shell\"") && html.contains("active>"),
10105 "root active at /: {html}"
10106 );
10107 assert!(
10108 html.contains("<h1>Shell</h1>"),
10109 "shell renders at /: {html}"
10110 );
10111 assert!(
10112 html.contains(r#"component="section-comp" style="display:none">"#),
10113 "section hidden at /: {html}"
10114 );
10115 }
10116
10117 #[test]
10120 fn test_css_module_emitted_once_inline_in_component() {
10121 let template = r#"<p><slot></slot></p>"#;
10124
10125 let mut fragments = HashMap::new();
10126 fragments.insert(
10127 "index.html".to_string(),
10128 FragmentList {
10129 fragments: vec![
10130 WebUIFragment::raw("<html><head>".to_string()),
10131 structural_fragment("head_end"),
10132 WebUIFragment::raw("</head><body><div>".to_string()),
10133 WebUIFragment::component("my-card"),
10134 WebUIFragment::raw("A".to_string()),
10135 WebUIFragment::component("my-card"),
10136 WebUIFragment::raw("B</div>".to_string()),
10137 structural_fragment("body_end"),
10138 WebUIFragment::raw("</body></html>".to_string()),
10139 ],
10140 contains_boundary: false,
10141 },
10142 );
10143 fragments.insert(
10144 "my-card".to_string(),
10145 FragmentList {
10146 fragments: vec![WebUIFragment::raw(template.to_string())],
10147 contains_boundary: false,
10148 },
10149 );
10150
10151 let mut protocol = WebUIProtocol::new(fragments);
10152 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10153 protocol
10154 .components
10155 .entry("my-card".to_string())
10156 .or_default()
10157 .css = "p{color:red}".to_string();
10158 protocol.populate_style_closures(&["index.html"]);
10159 let state = test_json!({});
10160 let mut writer = TestWriter::new();
10161
10162 handle(
10163 &protocol,
10164 &state,
10165 &RenderOptions::new("index.html", "/"),
10166 &mut writer,
10167 )
10168 .unwrap();
10169
10170 let html = writer.get_content();
10171
10172 let count = html.matches(r#"<script type="importmap""#).count();
10174 assert_eq!(
10175 count, 1,
10176 "CSS module importmap should be emitted once, got {count} in: {html}"
10177 );
10178 assert!(
10179 html.contains(r#""my-card":"data:text/css,"#),
10180 "Importmap must register my-card under a data: URI: {html}"
10181 );
10182
10183 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10185 assert_eq!(
10186 tmpl_count, 2,
10187 "Template should render twice, got {tmpl_count} in: {html}"
10188 );
10189
10190 let css_pos = html
10192 .find(r#"<script type="importmap""#)
10193 .expect("CSS module importmap missing");
10194 let body_pos = html.find("<body>").expect("<body> missing");
10195 assert!(
10196 css_pos > body_pos,
10197 "CSS module should be inline in component, not in <head>: {html}"
10198 );
10199 }
10200
10201 #[test]
10202 fn test_component_without_css_renders_normally() {
10203 let mut fragments = HashMap::new();
10205 fragments.insert(
10206 "index.html".to_string(),
10207 FragmentList {
10208 fragments: vec![WebUIFragment::component("my-card")],
10209 contains_boundary: false,
10210 },
10211 );
10212 fragments.insert(
10213 "my-card".to_string(),
10214 FragmentList {
10215 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10216 contains_boundary: false,
10217 },
10218 );
10219
10220 let protocol = WebUIProtocol::new(fragments);
10221 let state = test_json!({});
10222 let mut writer = TestWriter::new();
10223
10224 handle(
10225 &protocol,
10226 &state,
10227 &RenderOptions::new("index.html", "/"),
10228 &mut writer,
10229 )
10230 .unwrap();
10231
10232 let html = writer.get_content();
10233 assert!(
10234 html.contains("<p>hello</p>"),
10235 "Non-module component should render normally: {html}"
10236 );
10237 }
10238
10239 #[test]
10240 fn test_non_module_strategy_no_css_in_head() {
10241 let template = r#"<p>hello</p>"#;
10244
10245 let mut fragments = HashMap::new();
10246 fragments.insert(
10247 "index.html".to_string(),
10248 FragmentList {
10249 fragments: vec![
10250 WebUIFragment::raw("<html><head>".to_string()),
10251 structural_fragment("head_end"),
10252 WebUIFragment::raw("</head><body>".to_string()),
10253 WebUIFragment::component("my-card"),
10254 WebUIFragment::raw("</body></html>".to_string()),
10255 ],
10256 contains_boundary: false,
10257 },
10258 );
10259 fragments.insert(
10260 "my-card".to_string(),
10261 FragmentList {
10262 fragments: vec![WebUIFragment::raw(template.to_string())],
10263 contains_boundary: false,
10264 },
10265 );
10266
10267 let protocol = WebUIProtocol::new(fragments);
10269 let state = test_json!({});
10270 let mut writer = TestWriter::new();
10271
10272 handle(
10273 &protocol,
10274 &state,
10275 &RenderOptions::new("index.html", "/"),
10276 &mut writer,
10277 )
10278 .unwrap();
10279
10280 let html = writer.get_content();
10281
10282 assert!(
10283 !html.contains(r#"<style type="module""#),
10284 "Non-module strategy should not emit CSS module tags in <head>: {html}"
10285 );
10286 assert!(
10287 !html.contains(r#"<script type="importmap""#),
10288 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10289 );
10290 assert!(
10291 html.contains("<p>hello</p>"),
10292 "Component should still render: {html}"
10293 );
10294 }
10295
10296 #[test]
10297 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10298 let mut fragments = HashMap::new();
10299 fragments.insert(
10300 "index.html".to_string(),
10301 FragmentList {
10302 fragments: vec![
10303 WebUIFragment::raw("<html><head>".to_string()),
10304 structural_fragment("head_end"),
10305 WebUIFragment::raw("</head><body><my-card>".to_string()),
10306 WebUIFragment::component("my-card"),
10307 WebUIFragment::raw("</my-card></body></html>".to_string()),
10308 ],
10309 contains_boundary: false,
10310 },
10311 );
10312 fragments.insert(
10313 "my-card".to_string(),
10314 FragmentList {
10315 fragments: vec![WebUIFragment::raw(
10316 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10317 .to_string(),
10318 )],
10319 contains_boundary: false,
10320},
10321 );
10322
10323 let protocol = WebUIProtocol::new(fragments);
10324 let state = test_json!({});
10325 let mut writer = TestWriter::new();
10326
10327 handle(
10328 &protocol,
10329 &state,
10330 &RenderOptions::new("index.html", "/"),
10331 &mut writer,
10332 )
10333 .unwrap();
10334
10335 let html = writer.get_content();
10336
10337 assert!(
10338 html.contains("<style>.card{color:red}</style>"),
10339 "Style strategy should embed inline CSS in shadow template: {html}"
10340 );
10341 assert!(
10342 !html.contains(r#"<style type="module""#),
10343 "Style strategy should not emit legacy module CSS in <head>: {html}"
10344 );
10345 assert!(
10346 !html.contains(r#"<script type="importmap""#),
10347 "Style strategy should not emit CSS module importmaps in <head>: {html}"
10348 );
10349 }
10350
10351 #[test]
10352 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10353 let mut fragments = HashMap::new();
10356 fragments.insert(
10357 "index.html".to_string(),
10358 FragmentList {
10359 fragments: vec![
10360 WebUIFragment::raw("<html><head>".to_string()),
10361 structural_fragment("head_end"),
10362 WebUIFragment::raw("</head><body><my-card>".to_string()),
10363 WebUIFragment::component("my-card"),
10364 WebUIFragment::raw("</my-card>".to_string()),
10365 structural_fragment("body_end"),
10366 WebUIFragment::raw("</body></html>".to_string()),
10367 ],
10368 contains_boundary: false,
10369 },
10370 );
10371 fragments.insert(
10372 "my-card".to_string(),
10373 FragmentList {
10374 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10375 contains_boundary: false,
10376 },
10377 );
10378
10379 let mut protocol = WebUIProtocol::new(fragments);
10380 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10381
10382 let comp = protocol
10383 .components
10384 .entry("my-card".to_string())
10385 .or_default();
10386 comp.css_href = "my-card.css".to_string();
10387 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10388 protocol.populate_style_closures(&["index.html"]);
10389
10390 let state = test_json!({});
10391 let mut writer = TestWriter::new();
10392
10393 handle(
10394 &protocol,
10395 &state,
10396 &RenderOptions::new("index.html", "/"),
10397 &mut writer,
10398 )
10399 .unwrap();
10400
10401 let html = writer.get_content();
10402
10403 let head_end = html.find("</head>").expect("</head> missing");
10404 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10405 assert!(
10406 link_pos.is_some_and(|p| p < head_end),
10407 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10408 );
10409 assert!(
10410 !html.contains(r#"<link rel="preload""#),
10411 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10412 );
10413 assert!(
10414 !html.contains(r#"<style type="module""#),
10415 "Link strategy should not emit legacy module CSS: {html}"
10416 );
10417 assert!(
10418 !html.contains(r#"<script type="importmap""#),
10419 "Link strategy should not emit CSS module importmaps: {html}"
10420 );
10421 }
10422
10423 #[test]
10424 fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10425 let mut fragments = HashMap::new();
10432 fragments.insert(
10433 "index.html".to_string(),
10434 FragmentList {
10435 fragments: vec![
10436 WebUIFragment::raw("<html><head>".to_string()),
10437 structural_fragment("head_end"),
10438 WebUIFragment::raw(
10439 "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10440 .to_string(),
10441 ),
10442 WebUIFragment::component("comp-a"),
10443 WebUIFragment::component("comp-b"),
10444 WebUIFragment::component("comp-c"),
10445 structural_fragment("body_end"),
10446 WebUIFragment::raw("</body></html>".to_string()),
10447 ],
10448 contains_boundary: false,
10449 },
10450 );
10451 for tag in ["comp-a", "comp-b", "comp-c"] {
10452 fragments.insert(
10453 tag.to_string(),
10454 FragmentList {
10455 fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10456 contains_boundary: false,
10457 },
10458 );
10459 }
10460
10461 let mut protocol = WebUIProtocol::new(fragments);
10462 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10463 for tag in ["comp-a", "comp-b", "comp-c"] {
10464 let comp = protocol.components.entry(tag.to_string()).or_default();
10465 comp.css_href = format!("{tag}.css");
10466 comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10467 }
10468 protocol.populate_style_closures(&["index.html"]);
10469
10470 let state = test_json!({});
10471 let mut writer = TestWriter::new();
10472 handle(
10473 &protocol,
10474 &state,
10475 &RenderOptions::new("index.html", "/"),
10476 &mut writer,
10477 )
10478 .unwrap();
10479 let html = writer.get_content();
10480
10481 let pos_a = html
10482 .find(r#"href="comp-a.css""#)
10483 .expect("comp-a.css link missing");
10484 let pos_b = html
10485 .find(r#"href="comp-b.css""#)
10486 .expect("comp-b.css link missing");
10487 let pos_c = html
10488 .find(r#"href="comp-c.css""#)
10489 .expect("comp-c.css link missing");
10490 assert!(
10491 pos_a < pos_b && pos_b < pos_c,
10492 "stylesheet <link> tags must follow document/traversal order \
10493 (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10494 );
10495 }
10496
10497 #[test]
10498 fn link_strategy_preloads_static_shadow_css_in_head() {
10499 let mut fragments = HashMap::new();
10500 fragments.insert(
10501 "index.html".to_string(),
10502 FragmentList {
10503 fragments: vec![
10504 WebUIFragment::raw("<html><head>".to_string()),
10505 structural_fragment("head_end"),
10506 WebUIFragment::raw("</head><body><my-card>".to_string()),
10507 WebUIFragment::component("my-card"),
10508 WebUIFragment::raw("</my-card></body></html>".to_string()),
10509 ],
10510 contains_boundary: false,
10511 },
10512 );
10513 fragments.insert(
10514 "o-loading-state".to_string(),
10515 FragmentList {
10516 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10517 contains_boundary: false,
10518 },
10519 );
10520 fragments.insert(
10521 "my-card".to_string(),
10522 FragmentList {
10523 fragments: vec![
10524 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10525 structural_fragment("shadow_styles:my-card"),
10526 WebUIFragment::raw("<div>card</div></template>".to_string()),
10527 ],
10528 contains_boundary: false,
10529 },
10530 );
10531
10532 let mut protocol = WebUIProtocol::new(fragments);
10533 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10534 let component = protocol
10535 .components
10536 .entry("my-card".to_string())
10537 .or_default();
10538 component.css_href = "my-card.css".to_string();
10539 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10540 component.uses_shadow_dom = true;
10541 protocol.populate_style_closures(&["index.html"]);
10542
10543 let mut writer = TestWriter::new();
10544 handle(
10545 &protocol,
10546 &test_json!({}),
10547 &RenderOptions::new("index.html", "/"),
10548 &mut writer,
10549 )
10550 .unwrap();
10551
10552 let html = writer.get_content();
10553 let head_end = html.find("</head>").expect("</head> missing");
10554 let preload = html
10555 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10556 .expect("Shadow preload missing");
10557 let stylesheet = html
10558 .find(r#"<link rel="stylesheet" href="my-card.css""#)
10559 .expect("Shadow stylesheet missing");
10560 assert!(preload < head_end);
10561 assert!(stylesheet > head_end);
10562 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10563 }
10564
10565 #[test]
10566 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10567 let fragments = HashMap::from([
10568 (
10569 "index.html".to_string(),
10570 FragmentList {
10571 fragments: vec![
10572 WebUIFragment::raw("<html><head>".to_string()),
10573 structural_fragment("head_end"),
10574 WebUIFragment::raw("</head><body><outer-box>".to_string()),
10575 WebUIFragment::component("outer-box"),
10576 WebUIFragment::raw("</outer-box></body></html>".to_string()),
10577 ],
10578 contains_boundary: false,
10579 },
10580 ),
10581 (
10582 "outer-box".to_string(),
10583 FragmentList {
10584 fragments: vec![
10585 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10586 structural_fragment("shadow_styles:outer-box"),
10587 WebUIFragment::raw("<light-card>".to_string()),
10588 WebUIFragment::component("light-card"),
10589 WebUIFragment::raw("</light-card><light-card>".to_string()),
10590 WebUIFragment::component("light-card"),
10591 WebUIFragment::raw("</light-card><inner-box>".to_string()),
10592 WebUIFragment::component("inner-box"),
10593 WebUIFragment::raw("</inner-box><light-card>".to_string()),
10594 WebUIFragment::component("light-card"),
10595 WebUIFragment::raw("</light-card></template>".to_string()),
10596 ],
10597 contains_boundary: false,
10598 },
10599 ),
10600 (
10601 "inner-box".to_string(),
10602 FragmentList {
10603 fragments: vec![
10604 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10605 structural_fragment("shadow_styles:inner-box"),
10606 WebUIFragment::raw("<light-card>".to_string()),
10607 WebUIFragment::component("light-card"),
10608 WebUIFragment::raw("</light-card></template>".to_string()),
10609 ],
10610 contains_boundary: false,
10611 },
10612 ),
10613 (
10614 "light-card".to_string(),
10615 FragmentList {
10616 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10617 contains_boundary: false,
10618 },
10619 ),
10620 ]);
10621 let mut protocol = WebUIProtocol::new(fragments);
10622 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10623 for (tag, css, uses_shadow_dom) in [
10624 ("outer-box", ".outer{}", true),
10625 ("inner-box", ".inner{}", true),
10626 ("light-card", ".card{}", false),
10627 ] {
10628 let component = protocol.components.entry(tag.to_string()).or_default();
10629 component.css = css.to_string();
10630 component.uses_shadow_dom = uses_shadow_dom;
10631 }
10632 protocol.populate_style_closures(&["index.html"]);
10633
10634 let mut writer = TestWriter::new();
10635 handle(
10636 &protocol,
10637 &test_json!({}),
10638 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10639 &mut writer,
10640 )
10641 .unwrap();
10642 let html = writer.get_content();
10643
10644 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10645 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10646 assert_eq!(
10647 html.matches("data-webui-resource=\"light-card\"").count(),
10648 2,
10649 "the Light resource installs once in each Shadow tree: {html}"
10650 );
10651 assert_eq!(
10652 html.matches("data-webui-resource=").count(),
10653 4,
10654 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10655 );
10656 assert_eq!(
10657 html.matches("nonce=\"css-nonce\"").count(),
10658 4,
10659 "each inline style resource carries the nonce"
10660 );
10661 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10662 let inner_end = html.find("</inner-box>").expect("inner host close");
10663 let caller_continuation = &html[inner_end..];
10664 let trailing_card = caller_continuation
10665 .find("<p>card</p>")
10666 .expect("caller continues after nested Shadow root");
10667 assert!(trailing_card > 0);
10668 assert!(
10669 !caller_continuation.contains("data-webui-resource=\"light-card\""),
10670 "returning from the nested Shadow root must restore the outer frame's delivered set"
10671 );
10672 }
10673
10674 #[test]
10675 fn routed_document_styles_are_hoisted_for_the_active_chain() {
10676 let route = WebUiFragmentRoute {
10677 path: "/".to_string(),
10678 fragment_id: "app-shell".to_string(),
10679 children: vec![
10680 WebUiFragmentRoute {
10681 path: String::new(),
10682 fragment_id: "dashboard-page".to_string(),
10683 exact: true,
10684 ..Default::default()
10685 },
10686 WebUiFragmentRoute {
10687 path: "details".to_string(),
10688 fragment_id: "detail-page".to_string(),
10689 exact: true,
10690 ..Default::default()
10691 },
10692 ],
10693 ..Default::default()
10694 };
10695 let fragments = HashMap::from([
10696 (
10697 "index.html".to_string(),
10698 FragmentList {
10699 fragments: vec![
10700 WebUIFragment::raw("<html><head>"),
10701 structural_fragment("head_start"),
10702 structural_fragment("head_end"),
10703 WebUIFragment::raw("</head><body>"),
10704 structural_fragment("body_start"),
10705 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10706 WebUIFragment::route_from(route),
10707 WebUIFragment::boundary_end(0),
10708 structural_fragment("body_end"),
10709 WebUIFragment::raw("</body></html>"),
10710 ],
10711 contains_boundary: true,
10712 },
10713 ),
10714 (
10715 "app-shell".to_string(),
10716 FragmentList {
10717 fragments: vec![
10718 WebUIFragment::raw("<header>App</header>"),
10719 WebUIFragment::outlet(),
10720 ],
10721 contains_boundary: false,
10722 },
10723 ),
10724 (
10725 "dashboard-page".to_string(),
10726 FragmentList {
10727 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10728 contains_boundary: false,
10729 },
10730 ),
10731 (
10732 "detail-page".to_string(),
10733 FragmentList {
10734 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10735 contains_boundary: false,
10736 },
10737 ),
10738 ]);
10739 let mut protocol = WebUIProtocol::new(fragments);
10740 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10741 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10742 let component = protocol.components.entry(tag.to_string()).or_default();
10743 component.css_href = format!("/{tag}.css");
10744 component.template_json = r#"{"h":""}"#.to_string();
10745 }
10746 protocol.populate_style_closures(&["index.html"]);
10747
10748 let mut writer = TestWriter::new();
10749 handle(
10750 &protocol,
10751 &test_json!({}),
10752 &RenderOptions::new("index.html", "/"),
10753 &mut writer,
10754 )
10755 .unwrap();
10756 let html = writer.get_content();
10757
10758 assert_eq!(
10759 html.matches(r#"data-webui-resource="app-shell""#).count(),
10760 1,
10761 "the matched route shell must install once: {html}"
10762 );
10763 assert_eq!(
10764 html.matches(r#"data-webui-resource="dashboard-page""#)
10765 .count(),
10766 1,
10767 "the matched child route must install once: {html}"
10768 );
10769 assert!(
10770 !html.contains(r#"data-webui-resource="detail-page""#),
10771 "inactive route CSS must not be delivered: {html}"
10772 );
10773 let head_end = html.find("</head>").expect("head close");
10774 let head = &html[..head_end];
10775 for (resource, href) in [
10776 ("app-shell", "/app-shell.css"),
10777 ("dashboard-page", "/dashboard-page.css"),
10778 ] {
10779 let encoded_href = crate::html_encode::encode_safe(href);
10780 assert!(
10781 head.contains(&format!(
10782 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10783 )),
10784 "Document-targeted route CSS must be render-blocking in head: {html}"
10785 );
10786 assert!(
10787 !head.contains(&format!(
10788 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10789 )),
10790 "a hoisted stylesheet must not also be preloaded: {html}"
10791 );
10792 }
10793 assert!(
10794 !head.contains("detail-page.css"),
10795 "inactive route CSS must not be preloaded: {html}"
10796 );
10797 let app_style = html
10798 .find(r#"data-webui-resource="app-shell""#)
10799 .expect("app style");
10800 let dashboard_style = html
10801 .find(r#"data-webui-resource="dashboard-page""#)
10802 .expect("dashboard style");
10803 let app_host = html.find("<app-shell").expect("app route host");
10804 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10805 assert!(
10806 app_style < dashboard_style
10807 && dashboard_style < head_end
10808 && head_end < app_host
10809 && app_host < dashboard_host,
10810 "matched Document route closures must be hoisted in chain order: {html}"
10811 );
10812
10813 let protocol = Protocol::new(protocol);
10814 let mut streamed = FlushTestWriter::default();
10815 WebUIHandler::new()
10816 .render_streaming(
10817 &protocol,
10818 &test_json!({}),
10819 &RenderOptions::new("index.html", "/"),
10820 &mut streamed,
10821 )
10822 .unwrap();
10823 let streamed_head_end = streamed
10824 .output
10825 .find("</head>")
10826 .expect("streamed head close");
10827 for resource in ["app-shell", "dashboard-page"] {
10828 let marker = format!(r#"data-webui-resource="{resource}""#);
10829 let position = streamed.output.find(&marker).expect("streamed route style");
10830 assert!(
10831 position < streamed_head_end,
10832 "streaming must hoist {resource} into head: {}",
10833 streamed.output
10834 );
10835 assert_eq!(
10836 streamed.output.matches(&marker).count(),
10837 1,
10838 "streaming must not emit {resource} twice: {}",
10839 streamed.output
10840 );
10841 }
10842 }
10843
10844 #[test]
10845 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10846 let route = WebUiFragmentRoute {
10847 path: "/".to_string(),
10848 fragment_id: "dashboard-page".to_string(),
10849 exact: true,
10850 ..Default::default()
10851 };
10852 let mut protocol = WebUIProtocol::new(HashMap::from([
10853 (
10854 "index.html".to_string(),
10855 FragmentList {
10856 fragments: vec![
10857 WebUIFragment::raw("<html><head>"),
10858 structural_fragment("head_start"),
10859 structural_fragment("head_end"),
10860 WebUIFragment::raw("</head><body>"),
10861 WebUIFragment::route_from(route),
10862 structural_fragment("body_end"),
10863 WebUIFragment::raw("</body></html>"),
10864 ],
10865 contains_boundary: false,
10866 },
10867 ),
10868 (
10869 "dashboard-page".to_string(),
10870 FragmentList {
10871 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10872 contains_boundary: false,
10873 },
10874 ),
10875 ]));
10876 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10877 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10878 protocol
10879 .components
10880 .entry(tag.to_string())
10881 .or_default()
10882 .css_href = format!("/{tag}.css");
10883 }
10884 protocol.style_closures.insert(
10885 "index.html".to_string(),
10886 webui_protocol::ComponentStyleClosure::default(),
10887 );
10888 protocol.style_closures.insert(
10889 "dashboard-page".to_string(),
10890 webui_protocol::ComponentStyleClosure {
10891 component_tags: vec![
10892 "dashboard-page".to_string(),
10893 "summary-card".to_string(),
10894 "activity-list".to_string(),
10895 ],
10896 style_chunks: vec![0],
10897 },
10898 );
10899 protocol.style_chunks.push(webui_protocol::StyleChunk {
10900 name: "_chunk-dashboard-page-3".to_string(),
10901 css: String::new(),
10902 css_href: "/_chunk-dashboard-page-3.css".to_string(),
10903 component_tags: vec![
10904 "dashboard-page".to_string(),
10905 "summary-card".to_string(),
10906 "activity-list".to_string(),
10907 ],
10908 });
10909
10910 let mut writer = TestWriter::new();
10911 handle(
10912 &protocol,
10913 &test_json!({}),
10914 &RenderOptions::new("index.html", "/"),
10915 &mut writer,
10916 )
10917 .unwrap();
10918 let html = writer.get_content();
10919 let head_end = html.find("</head>").expect("head close");
10920 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10921 let style = html.find(marker).expect("bundled route stylesheet");
10922
10923 assert!(
10924 style < head_end,
10925 "bundled route CSS must be in head: {html}"
10926 );
10927 assert_eq!(
10928 html.matches(marker).count(),
10929 1,
10930 "the route host must not emit the hoisted chunk again: {html}"
10931 );
10932 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10933 assert!(
10934 !html.contains(&format!(
10935 r#"<link rel="preload" as="style" href="{bundled_href}">"#
10936 )),
10937 "the applied bundled stylesheet must not also be preloaded: {html}"
10938 );
10939 }
10940
10941 #[test]
10942 fn routed_document_inline_styles_are_hoisted() {
10943 for strategy in [
10944 webui_protocol::CssStrategy::Style,
10945 webui_protocol::CssStrategy::Module,
10946 ] {
10947 let route = WebUiFragmentRoute {
10948 path: "/".to_string(),
10949 fragment_id: "dashboard-page".to_string(),
10950 exact: true,
10951 ..Default::default()
10952 };
10953 let mut protocol = WebUIProtocol::new(HashMap::from([
10954 (
10955 "index.html".to_string(),
10956 FragmentList {
10957 fragments: vec![
10958 WebUIFragment::raw("<html><head>"),
10959 structural_fragment("head_start"),
10960 structural_fragment("head_end"),
10961 WebUIFragment::raw("</head><body>"),
10962 WebUIFragment::route_from(route),
10963 structural_fragment("body_end"),
10964 WebUIFragment::raw("</body></html>"),
10965 ],
10966 contains_boundary: false,
10967 },
10968 ),
10969 (
10970 "dashboard-page".to_string(),
10971 FragmentList {
10972 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10973 contains_boundary: false,
10974 },
10975 ),
10976 ]));
10977 protocol.set_css_strategy(strategy);
10978 protocol
10979 .components
10980 .entry("dashboard-page".to_string())
10981 .or_default()
10982 .css = ".dashboard{display:grid}".to_string();
10983 protocol.populate_style_closures(&["index.html"]);
10984
10985 let mut writer = TestWriter::new();
10986 handle(
10987 &protocol,
10988 &test_json!({}),
10989 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10990 &mut writer,
10991 )
10992 .unwrap();
10993 let html = writer.get_content();
10994 let head_end = html.find("</head>").expect("head close");
10995 let marker = r#"data-webui-resource="dashboard-page""#;
10996 let style = html.find(marker).expect("route style fallback");
10997 let host = html.find("<dashboard-page").expect("dashboard route host");
10998 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10999 "module"
11000 } else {
11001 "style"
11002 };
11003 let style_marker = format!(
11004 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11005 );
11006
11007 assert!(
11008 style < head_end && head_end < host,
11009 "{strategy:?} route CSS must be applied in head before its host: {html}"
11010 );
11011 assert_eq!(
11012 html.matches(&style_marker).count(),
11013 1,
11014 "{strategy:?} route CSS must not be emitted twice: {html}"
11015 );
11016 }
11017 }
11018
11019 #[test]
11020 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11021 let route = WebUiFragmentRoute {
11022 path: "/".to_string(),
11023 fragment_id: "dashboard-page".to_string(),
11024 exact: true,
11025 ..Default::default()
11026 };
11027 let mut protocol = WebUIProtocol::new(HashMap::from([
11028 (
11029 "index.html".to_string(),
11030 FragmentList {
11031 fragments: vec![
11032 WebUIFragment::raw("<html><head>"),
11033 structural_fragment("head_start"),
11034 structural_fragment("head_end"),
11035 WebUIFragment::raw("</head><body><outer-box>"),
11036 WebUIFragment::component("outer-box"),
11037 WebUIFragment::raw("</outer-box></body></html>"),
11038 ],
11039 contains_boundary: false,
11040 },
11041 ),
11042 (
11043 "outer-box".to_string(),
11044 FragmentList {
11045 fragments: vec![
11046 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11047 structural_fragment("shadow_styles:outer-box"),
11048 WebUIFragment::route_from(route),
11049 WebUIFragment::raw("</template>"),
11050 ],
11051 contains_boundary: false,
11052 },
11053 ),
11054 (
11055 "dashboard-page".to_string(),
11056 FragmentList {
11057 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11058 contains_boundary: false,
11059 },
11060 ),
11061 ]));
11062 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11063 for tag in ["outer-box", "dashboard-page"] {
11064 protocol
11065 .components
11066 .entry(tag.to_string())
11067 .or_default()
11068 .css_href = format!("/{tag}.css");
11069 }
11070 protocol
11071 .components
11072 .get_mut("outer-box")
11073 .expect("outer-box component")
11074 .uses_shadow_dom = true;
11075 protocol.populate_style_closures(&["index.html"]);
11076
11077 let mut writer = TestWriter::new();
11078 handle(
11079 &protocol,
11080 &test_json!({}),
11081 &RenderOptions::new("index.html", "/"),
11082 &mut writer,
11083 )
11084 .unwrap();
11085 let html = writer.get_content();
11086 let head_end = html.find("</head>").expect("head close");
11087 let head = &html[..head_end];
11088 let dashboard_style = html
11089 .find(r#"data-webui-resource="dashboard-page""#)
11090 .expect("tree-local dashboard stylesheet");
11091 let shadow_start = html
11092 .find("<template shadowrootmode=\"open\">")
11093 .expect("Shadow template");
11094 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11095 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11096
11097 assert!(
11098 head.contains(&format!(
11099 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11100 )),
11101 "Shadow-targeted route CSS must start loading from head: {html}"
11102 );
11103 assert!(
11104 !head.contains(r#"data-webui-resource="dashboard-page""#),
11105 "Shadow-targeted route CSS must not be applied to Document: {html}"
11106 );
11107 assert!(
11108 shadow_start < dashboard_style && dashboard_style < dashboard_host,
11109 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11110 );
11111 }
11112
11113 #[test]
11114 fn isolated_shadow_entry_does_not_emit_document_preloads() {
11115 let route = WebUiFragmentRoute {
11116 path: "/".to_string(),
11117 fragment_id: "dashboard-page".to_string(),
11118 exact: true,
11119 ..Default::default()
11120 };
11121 let mut protocol = WebUIProtocol::new(HashMap::from([
11122 (
11123 "outer-box".to_string(),
11124 FragmentList {
11125 fragments: vec![
11126 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11127 structural_fragment("shadow_styles:outer-box"),
11128 WebUIFragment::route_from(route),
11129 WebUIFragment::raw("</template>"),
11130 ],
11131 contains_boundary: false,
11132 },
11133 ),
11134 (
11135 "dashboard-page".to_string(),
11136 FragmentList {
11137 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11138 contains_boundary: false,
11139 },
11140 ),
11141 ]));
11142 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11143 for tag in ["outer-box", "dashboard-page"] {
11144 protocol
11145 .components
11146 .entry(tag.to_string())
11147 .or_default()
11148 .css_href = format!("/{tag}.css");
11149 }
11150 protocol
11151 .components
11152 .get_mut("outer-box")
11153 .expect("outer-box component")
11154 .uses_shadow_dom = true;
11155 protocol.populate_style_closures(&["outer-box"]);
11156
11157 let mut writer = TestWriter::new();
11158 handle(
11159 &protocol,
11160 &test_json!({}),
11161 &RenderOptions::new("outer-box", "/"),
11162 &mut writer,
11163 )
11164 .unwrap();
11165 let html = writer.get_content();
11166
11167 assert!(
11168 html.starts_with("<template shadowrootmode=\"open\">"),
11169 "isolated component output must not be prefixed with document metadata: {html}"
11170 );
11171 assert!(
11172 !html.contains(r#"<link rel="preload""#),
11173 "an isolated Shadow entry has no Document head for preloads: {html}"
11174 );
11175 assert!(
11176 html.contains(r#"data-webui-resource="dashboard-page""#),
11177 "the route stylesheet must still install inside the ShadowRoot: {html}"
11178 );
11179 }
11180
11181 #[test]
11182 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11183 let route = WebUiFragmentRoute {
11184 path: "/".to_string(),
11185 fragment_id: "app-shell".to_string(),
11186 children: vec![
11187 WebUiFragmentRoute {
11188 path: String::new(),
11189 fragment_id: "dashboard-page".to_string(),
11190 exact: true,
11191 ..Default::default()
11192 },
11193 WebUiFragmentRoute {
11194 path: "details".to_string(),
11195 fragment_id: "detail-page".to_string(),
11196 exact: true,
11197 ..Default::default()
11198 },
11199 ],
11200 ..Default::default()
11201 };
11202 let fragments = HashMap::from([
11203 (
11204 "index.html".to_string(),
11205 FragmentList {
11206 fragments: vec![
11207 WebUIFragment::raw("<html><head>"),
11208 structural_fragment("head_start"),
11209 structural_fragment("head_end"),
11210 WebUIFragment::raw("</head><body>"),
11211 structural_fragment("body_start"),
11212 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11213 WebUIFragment::route_from(route),
11214 WebUIFragment::boundary_end(0),
11215 structural_fragment("body_end"),
11216 WebUIFragment::raw("</body></html>"),
11217 ],
11218 contains_boundary: true,
11219 },
11220 ),
11221 (
11222 "app-shell".to_string(),
11223 FragmentList {
11224 fragments: vec![
11225 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11226 structural_fragment("shadow_styles:app-shell"),
11227 WebUIFragment::raw("<shared-card"),
11228 structural_fragment("streaming_root:shared-card"),
11229 WebUIFragment::raw(">"),
11230 WebUIFragment::component("shared-card"),
11231 WebUIFragment::raw("</shared-card>"),
11232 WebUIFragment::outlet(),
11233 WebUIFragment::raw("</template>"),
11234 ],
11235 contains_boundary: false,
11236 },
11237 ),
11238 (
11239 "dashboard-page".to_string(),
11240 FragmentList {
11241 fragments: vec![
11242 WebUIFragment::raw("<shared-card"),
11243 structural_fragment("streaming_root:shared-card"),
11244 WebUIFragment::raw(">"),
11245 WebUIFragment::component("shared-card"),
11246 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11247 ],
11248 contains_boundary: false,
11249 },
11250 ),
11251 (
11252 "detail-page".to_string(),
11253 FragmentList {
11254 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11255 contains_boundary: false,
11256 },
11257 ),
11258 (
11259 "shared-card".to_string(),
11260 FragmentList {
11261 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11262 contains_boundary: false,
11263 },
11264 ),
11265 ]);
11266 let mut protocol = WebUIProtocol::new(fragments);
11267 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11268 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11269 let component = protocol.components.entry(tag.to_string()).or_default();
11270 component.css = format!(".{tag}{{display:block}}");
11271 component.template_json = r#"{"h":""}"#.to_string();
11272 }
11273 protocol
11274 .components
11275 .get_mut("app-shell")
11276 .expect("app shell component")
11277 .uses_shadow_dom = true;
11278 protocol.populate_style_closures(&["index.html"]);
11279
11280 let mut writer = TestWriter::new();
11281 handle(
11282 &protocol,
11283 &test_json!({}),
11284 &RenderOptions::new("index.html", "/"),
11285 &mut writer,
11286 )
11287 .unwrap();
11288 let html = writer.get_content();
11289
11290 for resource in ["app-shell", "shared-card", "dashboard-page"] {
11291 assert_eq!(
11292 html.matches(&format!(r#"data-webui-resource="{resource}""#))
11293 .count(),
11294 1,
11295 "{resource} must install once in the active ShadowRoot: {html}"
11296 );
11297 }
11298 assert!(
11299 !html.contains(r#"data-webui-resource="detail-page""#),
11300 "inactive route CSS must not enter the ShadowRoot: {html}"
11301 );
11302 let shadow_start = html
11303 .find("<template shadowrootmode=\"open\">")
11304 .expect("Shadow template");
11305 let dashboard_style = html
11306 .find(r#"data-webui-resource="dashboard-page""#)
11307 .expect("dashboard style");
11308 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11309 let shadow_end = html.find("</template>").expect("Shadow template close");
11310 assert!(
11311 shadow_start < dashboard_style
11312 && dashboard_style < dashboard_host
11313 && dashboard_host < shadow_end,
11314 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11315 );
11316
11317 let protocol = Protocol::new(protocol);
11318 let mut streamed = FlushTestWriter::default();
11319 WebUIHandler::new()
11320 .render_streaming(
11321 &protocol,
11322 &test_json!({}),
11323 &RenderOptions::new("index.html", "/"),
11324 &mut streamed,
11325 )
11326 .unwrap();
11327 for resource in ["app-shell", "shared-card"] {
11328 assert_eq!(
11329 streamed
11330 .output
11331 .matches(&format!(r#"data-webui-resource="{resource}""#))
11332 .count(),
11333 1,
11334 "streaming must preserve tree-local delivery for {resource}: {}",
11335 streamed.output
11336 );
11337 }
11338 assert!(
11339 !streamed
11340 .output
11341 .contains(r#"data-webui-resource="dashboard-page""#),
11342 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11343 streamed.output
11344 );
11345 assert!(
11346 streamed
11347 .output
11348 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11349 && streamed
11350 .output
11351 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11352 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11353 streamed.output
11354 );
11355 assert!(
11356 !streamed
11357 .output
11358 .contains(r#"data-webui-resource="detail-page""#),
11359 "streaming must omit inactive route CSS: {}",
11360 streamed.output
11361 );
11362 }
11363
11364 #[test]
11365 fn document_styles_follow_doctype_before_headless_document_content() {
11366 for entry_fragments in [
11367 vec![
11368 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11369 structural_fragment("body_start"),
11370 WebUIFragment::raw("<my-card>"),
11371 WebUIFragment::component("my-card"),
11372 WebUIFragment::raw("</my-card></body></html>"),
11373 ],
11374 vec![
11375 WebUIFragment::raw("<!doctype html><html><my-card>"),
11376 WebUIFragment::component("my-card"),
11377 WebUIFragment::raw("</my-card></html>"),
11378 ],
11379 ] {
11380 let mut protocol = WebUIProtocol::new(HashMap::from([
11381 (
11382 "index.html".to_string(),
11383 FragmentList {
11384 fragments: entry_fragments,
11385 contains_boundary: false,
11386 },
11387 ),
11388 (
11389 "my-card".to_string(),
11390 FragmentList {
11391 fragments: vec![WebUIFragment::raw("<p>card</p>")],
11392 contains_boundary: false,
11393 },
11394 ),
11395 ]));
11396 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11397 let component = protocol
11398 .components
11399 .entry("my-card".to_string())
11400 .or_default();
11401 component.css = ".card{color:red}".to_string();
11402 component.uses_shadow_dom = false;
11403 protocol.populate_style_closures(&["index.html"]);
11404
11405 let mut writer = TestWriter::new();
11406 handle(
11407 &protocol,
11408 &test_json!({}),
11409 &RenderOptions::new("index.html", "/"),
11410 &mut writer,
11411 )
11412 .expect("render");
11413 let html = writer.get_content();
11414
11415 assert!(
11416 html.to_ascii_lowercase().starts_with("<!doctype html>"),
11417 "the doctype must remain the first document token: {html}"
11418 );
11419 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11420 let document = html.find("<html").expect("document root");
11421 let component = html.find("<my-card>").expect("component host");
11422 assert!(
11423 style < document && style < component,
11424 "headless document styles must precede document content: {html}"
11425 );
11426 }
11427 }
11428
11429 #[test]
11430 fn authored_shadow_component_can_render_as_entry_fragment() {
11431 let mut protocol = WebUIProtocol::new(HashMap::from([(
11432 "my-card".to_string(),
11433 FragmentList {
11434 fragments: vec![
11435 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11436 structural_fragment("shadow_styles:my-card"),
11437 WebUIFragment::raw("<p>card</p></template>"),
11438 ],
11439 contains_boundary: false,
11440 },
11441 )]));
11442 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11443 let component = protocol
11444 .components
11445 .entry("my-card".to_string())
11446 .or_default();
11447 component.css = ".card{color:red}".to_string();
11448 component.uses_shadow_dom = true;
11449 protocol.populate_style_closures(&["my-card"]);
11450
11451 let mut writer = TestWriter::new();
11452 handle(
11453 &protocol,
11454 &test_json!({}),
11455 &RenderOptions::new("my-card", "/"),
11456 &mut writer,
11457 )
11458 .expect("render");
11459 let html = writer.get_content();
11460
11461 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11462 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11463 assert!(html.ends_with("<p>card</p></template>"));
11464 }
11465
11466 #[test]
11467 fn component_style_closures_escape_mixed_case_style_end_tags() {
11468 for strategy in [
11469 webui_protocol::CssStrategy::Style,
11470 webui_protocol::CssStrategy::Module,
11471 ] {
11472 let mut protocol = WebUIProtocol::new(HashMap::from([
11473 (
11474 "index.html".to_string(),
11475 FragmentList {
11476 fragments: vec![
11477 WebUIFragment::raw("<html><head>"),
11478 structural_fragment("head_end"),
11479 WebUIFragment::raw("</head><body><safe-card>"),
11480 WebUIFragment::component("safe-card"),
11481 WebUIFragment::raw("</safe-card></body></html>"),
11482 ],
11483 contains_boundary: false,
11484 },
11485 ),
11486 (
11487 "safe-card".to_string(),
11488 FragmentList {
11489 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11490 contains_boundary: false,
11491 },
11492 ),
11493 ]));
11494 protocol.set_css_strategy(strategy);
11495 protocol.components.insert(
11496 "safe-card".to_string(),
11497 webui_protocol::ComponentData {
11498 css: ".safe{content:'</StYlE>'}".to_string(),
11499 ..Default::default()
11500 },
11501 );
11502 protocol.populate_style_closures(&["index.html"]);
11503 let mut writer = TestWriter::new();
11504
11505 handle(
11506 &protocol,
11507 &test_json!({}),
11508 &RenderOptions::new("index.html", "/"),
11509 &mut writer,
11510 )
11511 .unwrap();
11512
11513 let html = writer.get_content();
11514 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11515 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11516 }
11517 }
11518
11519 #[test]
11520 fn shadow_closure_supports_link_and_module_ssr_paths() {
11521 for strategy in [
11522 webui_protocol::CssStrategy::Link,
11523 webui_protocol::CssStrategy::Module,
11524 ] {
11525 let mut protocol = WebUIProtocol::new(HashMap::from([
11526 (
11527 "index.html".to_string(),
11528 FragmentList {
11529 fragments: vec![
11530 WebUIFragment::raw("<html><head>".to_string()),
11531 structural_fragment("head_end"),
11532 WebUIFragment::raw("</head><body><my-card>".to_string()),
11533 WebUIFragment::component("my-card"),
11534 WebUIFragment::raw("</my-card></body></html>".to_string()),
11535 ],
11536 contains_boundary: false,
11537 },
11538 ),
11539 (
11540 "my-card".to_string(),
11541 FragmentList {
11542 fragments: vec![
11543 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11544 structural_fragment("shadow_styles:my-card"),
11545 WebUIFragment::raw("<p>card</p></template>".to_string()),
11546 ],
11547 contains_boundary: false,
11548 },
11549 ),
11550 ]));
11551 protocol.set_css_strategy(strategy);
11552 let component = protocol
11553 .components
11554 .entry("my-card".to_string())
11555 .or_default();
11556 component.css = ".card{color:red}".to_string();
11557 component.css_href = "/my-card.css".to_string();
11558 component.uses_shadow_dom = true;
11559 protocol.populate_style_closures(&["index.html"]);
11560
11561 let mut writer = TestWriter::new();
11562 handle(
11563 &protocol,
11564 &test_json!({}),
11565 &RenderOptions::new("index.html", "/"),
11566 &mut writer,
11567 )
11568 .unwrap();
11569 let html = writer.get_content();
11570 match strategy {
11571 webui_protocol::CssStrategy::Link => {
11572 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11573 assert!(html.contains(&format!(
11574 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11575 )));
11576 }
11577 webui_protocol::CssStrategy::Module => {
11578 assert!(html.contains(
11579 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11580 ));
11581 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11582 }
11583 webui_protocol::CssStrategy::Style => unreachable!(),
11584 }
11585 }
11586 }
11587
11588 #[test]
11589 fn test_module_preloads_emit_in_head_in_compiler_order() {
11590 let mut fragments = HashMap::new();
11594 fragments.insert(
11595 "index.html".to_string(),
11596 FragmentList {
11597 fragments: vec![
11598 WebUIFragment::raw(
11599 r#"<html><head><script type="module" async src="/index.js"></script>"#
11600 .to_string(),
11601 ),
11602 structural_fragment("head_end"),
11603 WebUIFragment::raw("</head><body>".to_string()),
11604 structural_fragment("body_end"),
11605 WebUIFragment::raw("</body></html>".to_string()),
11606 ],
11607 contains_boundary: false,
11608 },
11609 );
11610
11611 let mut protocol = WebUIProtocol::new(fragments);
11612 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11613
11614 let state = test_json!({});
11615 let mut writer = TestWriter::new();
11616 handle(
11617 &protocol,
11618 &state,
11619 &RenderOptions::new("index.html", "/"),
11620 &mut writer,
11621 )
11622 .unwrap();
11623
11624 let html = writer.get_content();
11625 let head_end = html.find("</head>").expect("</head> missing");
11626 let head = &html[..head_end];
11627 assert!(
11628 head.contains(
11629 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11630 ),
11631 "hints must appear in <head> in the compiler's order: {html}"
11632 );
11633 }
11634
11635 #[test]
11636 fn test_no_module_preloads_emits_nothing() {
11637 let mut fragments = HashMap::new();
11638 fragments.insert(
11639 "index.html".to_string(),
11640 FragmentList {
11641 fragments: vec![
11642 WebUIFragment::raw("<html><head>".to_string()),
11643 structural_fragment("head_end"),
11644 WebUIFragment::raw("</head><body>".to_string()),
11645 structural_fragment("body_end"),
11646 WebUIFragment::raw("</body></html>".to_string()),
11647 ],
11648 contains_boundary: false,
11649 },
11650 );
11651 let protocol = WebUIProtocol::new(fragments);
11652
11653 let state = test_json!({});
11654 let mut writer = TestWriter::new();
11655 handle(
11656 &protocol,
11657 &state,
11658 &RenderOptions::new("index.html", "/"),
11659 &mut writer,
11660 )
11661 .unwrap();
11662
11663 assert!(
11664 !writer.get_content().contains("modulepreload"),
11665 "a build without hints must be byte-identical to before"
11666 );
11667 }
11668
11669 #[test]
11670 fn test_link_strategy_head_links_follow_document_order() {
11671 let mut fragments = HashMap::new();
11676 fragments.insert(
11677 "index.html".to_string(),
11678 FragmentList {
11679 fragments: vec![
11680 WebUIFragment::raw("<html><head>".to_string()),
11681 structural_fragment("head_end"),
11682 WebUIFragment::raw("</head><body><z-widget>".to_string()),
11683 WebUIFragment::component("z-widget"),
11684 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11685 WebUIFragment::component("a-widget"),
11686 WebUIFragment::raw("</a-widget>".to_string()),
11687 structural_fragment("body_end"),
11688 WebUIFragment::raw("</body></html>".to_string()),
11689 ],
11690 contains_boundary: false,
11691 },
11692 );
11693 fragments.insert(
11694 "z-widget".to_string(),
11695 FragmentList {
11696 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11697 contains_boundary: false,
11698 },
11699 );
11700 fragments.insert(
11701 "a-widget".to_string(),
11702 FragmentList {
11703 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11704 contains_boundary: false,
11705 },
11706 );
11707
11708 let mut protocol = WebUIProtocol::new(fragments);
11709 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11710
11711 let z = protocol
11712 .components
11713 .entry("z-widget".to_string())
11714 .or_default();
11715 z.css_href = "z-widget.css".to_string();
11716 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11717
11718 let a = protocol
11719 .components
11720 .entry("a-widget".to_string())
11721 .or_default();
11722 a.css_href = "a-widget.css".to_string();
11723 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11724 protocol.populate_style_closures(&["index.html"]);
11725
11726 let state = test_json!({});
11727 let mut writer = TestWriter::new();
11728
11729 handle(
11730 &protocol,
11731 &state,
11732 &RenderOptions::new("index.html", "/"),
11733 &mut writer,
11734 )
11735 .unwrap();
11736
11737 let html = writer.get_content();
11738 let head_end = html.find("</head>").expect("</head> missing");
11739 let head_section = &html[..head_end];
11740
11741 let z_pos = head_section
11742 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11743 .expect("z-widget stylesheet link missing from <head>");
11744 let a_pos = head_section
11745 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11746 .expect("a-widget stylesheet link missing from <head>");
11747
11748 assert!(
11749 z_pos < a_pos,
11750 "CSS <link> tags must follow document order (z-widget before \
11751 a-widget), not alphabetical order: {html}"
11752 );
11753 }
11754
11755 #[test]
11756 fn test_css_module_emitted_in_component_light_dom() {
11757 let mut fragments = HashMap::new();
11761 fragments.insert(
11762 "index.html".to_string(),
11763 FragmentList {
11764 fragments: vec![
11765 WebUIFragment::raw("<html><head>".to_string()),
11766 structural_fragment("head_end"),
11767 WebUIFragment::raw("</head><body><my-card>".to_string()),
11768 WebUIFragment::component("my-card"),
11769 WebUIFragment::raw("</my-card>".to_string()),
11770 WebUIFragment::raw("</body></html>".to_string()),
11771 ],
11772 contains_boundary: false,
11773 },
11774 );
11775 fragments.insert(
11776 "my-card".to_string(),
11777 FragmentList {
11778 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11779 contains_boundary: false,
11780 },
11781 );
11782
11783 let mut protocol = WebUIProtocol::new(fragments);
11784 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11785 protocol
11786 .components
11787 .entry("my-card".to_string())
11788 .or_default()
11789 .css = "p{color:red}".to_string();
11790 protocol.populate_style_closures(&["index.html"]);
11791 let state = test_json!({});
11792 let mut writer = TestWriter::new();
11793
11794 handle(
11795 &protocol,
11796 &state,
11797 &RenderOptions::new("index.html", "/"),
11798 &mut writer,
11799 )
11800 .unwrap();
11801
11802 let html = writer.get_content();
11803
11804 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11806 let css_pos = html
11807 .find(r#"<script type="importmap""#)
11808 .expect("CSS module importmap missing");
11809 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11810 assert!(
11811 css_pos > tag_open && css_pos < tag_close,
11812 "CSS module should be inside component light DOM: {html}"
11813 );
11814
11815 let head_end = html.find("</head>").expect("</head> missing");
11817 assert!(
11818 css_pos > head_end,
11819 "CSS module should not be in <head>: {html}"
11820 );
11821 }
11822
11823 #[test]
11824 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11825 let mut protocol = WebUIProtocol::default();
11826 protocol.fragments = HashMap::from([
11827 (
11828 "index.html".to_string(),
11829 FragmentList {
11830 fragments: vec![
11831 WebUIFragment::raw("<legacy-card>"),
11832 WebUIFragment::component("legacy-card"),
11833 WebUIFragment::raw("</legacy-card>"),
11834 ],
11835 contains_boundary: false,
11836 },
11837 ),
11838 (
11839 "legacy-card".to_string(),
11840 FragmentList {
11841 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11842 contains_boundary: false,
11843 },
11844 ),
11845 ]);
11846 protocol.components.insert(
11847 "legacy-card".to_string(),
11848 webui_protocol::ComponentData {
11849 css: ".legacy{color:red}".to_string(),
11850 ..Default::default()
11851 },
11852 );
11853 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11854 let mut writer = TestWriter::new();
11855
11856 let error = handle(
11857 &protocol,
11858 &test_json!({}),
11859 &RenderOptions::new("index.html", "/"),
11860 &mut writer,
11861 )
11862 .expect_err("styled protocols require closure metadata");
11863 assert!(error
11864 .to_string()
11865 .contains("component style closure metadata is required"));
11866 }
11867
11868 #[test]
11869 fn test_css_module_emitted_for_route_components() {
11870 let template = r#"<h1>Dashboard</h1>"#;
11872
11873 let mut fragments = HashMap::new();
11874 fragments.insert(
11875 "index.html".to_string(),
11876 FragmentList {
11877 fragments: vec![
11878 WebUIFragment::raw("<html><head>".to_string()),
11879 structural_fragment("head_end"),
11880 WebUIFragment::raw("</head><body>".to_string()),
11881 WebUIFragment::route("/", "dash-page"),
11882 structural_fragment("body_end"),
11883 WebUIFragment::raw("</body></html>".to_string()),
11884 ],
11885 contains_boundary: false,
11886 },
11887 );
11888 fragments.insert(
11889 "dash-page".to_string(),
11890 FragmentList {
11891 fragments: vec![WebUIFragment::raw(template.to_string())],
11892 contains_boundary: false,
11893 },
11894 );
11895
11896 let mut protocol = WebUIProtocol::new(fragments);
11897 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11898 let comp = protocol
11899 .components
11900 .entry("dash-page".to_string())
11901 .or_default();
11902 comp.css = "h1{font-size:2rem}".to_string();
11903 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11904 protocol.populate_style_closures(&["index.html"]);
11905 let state = test_json!({});
11906 let mut writer = TestWriter::new();
11907
11908 handle(
11909 &protocol,
11910 &state,
11911 &RenderOptions::new("index.html", "/"),
11912 &mut writer,
11913 )
11914 .unwrap();
11915
11916 let html = writer.get_content();
11917
11918 assert!(
11919 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11920 "Route component should have CSS module importmap with data: URI: {html}"
11921 );
11922 assert!(
11923 html.contains("<h1>Dashboard</h1>"),
11924 "Route component should render content: {html}"
11925 );
11926 let fallback = html
11927 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11928 .expect("route Module fallback");
11929 let host_open = html.find("<dash-page>").expect("route host");
11930 let content = html.find("<h1>Dashboard</h1>").expect("route content");
11931 assert!(
11932 fallback < host_open && host_open < content,
11933 "the Module fallback must precede the generated Light route host: {html}"
11934 );
11935 let importmap = html
11941 .find(r#"<script type="importmap""#)
11942 .expect("route CSS module importmap");
11943 assert!(
11944 host_open < importmap,
11945 "the CSS module importmap must live inside the route host, not as a route child: {html}"
11946 );
11947 }
11948
11949 #[test]
11950 fn test_head_css_link_skipped_for_components_without_css() {
11951 let mut fragments = HashMap::new();
11954 fragments.insert(
11955 "index.html".to_string(),
11956 FragmentList {
11957 fragments: vec![
11958 WebUIFragment::raw("<html><head>".to_string()),
11959 structural_fragment("head_end"),
11960 WebUIFragment::raw("</head><body>".to_string()),
11961 WebUIFragment::component("has-css"),
11962 WebUIFragment::component("no-css"),
11963 structural_fragment("body_end"),
11964 WebUIFragment::raw("</body></html>".to_string()),
11965 ],
11966 contains_boundary: false,
11967 },
11968 );
11969 fragments.insert(
11970 "has-css".to_string(),
11971 FragmentList {
11972 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11973 contains_boundary: false,
11974 },
11975 );
11976 fragments.insert(
11977 "no-css".to_string(),
11978 FragmentList {
11979 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11980 contains_boundary: false,
11981 },
11982 );
11983
11984 let mut protocol = WebUIProtocol::new(fragments);
11985 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11986
11987 protocol
11989 .components
11990 .entry("has-css".to_string())
11991 .or_default()
11992 .css_href = "has-css.css".to_string();
11993 protocol
11994 .components
11995 .entry("no-css".to_string())
11996 .or_default()
11997 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11998 protocol.populate_style_closures(&["index.html"]);
11999
12000 let state = test_json!({});
12001 let mut writer = TestWriter::new();
12002
12003 let handler = WebUIHandler::with_plugin(|| {
12004 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12005 });
12006 handler
12007 .handle(
12008 &protocol,
12009 &state,
12010 &RenderOptions::new("index.html", "/"),
12011 &mut writer,
12012 )
12013 .unwrap();
12014
12015 let html = writer.get_content();
12016 assert!(
12017 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12018 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12019 );
12020 assert!(
12021 !html.contains("no-css.css"),
12022 "Component without CSS must NOT get a <link> in <head>: {html}"
12023 );
12024 }
12025
12026 #[test]
12027 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12028 let mut fragments = HashMap::new();
12036 fragments.insert(
12037 "index.html".to_string(),
12038 FragmentList {
12039 fragments: vec![
12040 WebUIFragment::raw("<html><head>".to_string()),
12041 structural_fragment("head_end"),
12042 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12043 WebUIFragment::component("app-shell"),
12044 WebUIFragment::raw("</app-shell>".to_string()),
12045 structural_fragment("body_end"),
12046 WebUIFragment::raw("</body></html>".to_string()),
12047 ],
12048 contains_boundary: false,
12049 },
12050 );
12051 fragments.insert(
12053 "app-shell".to_string(),
12054 FragmentList {
12055 fragments: vec![
12056 WebUIFragment::raw("<div>Shell</div>".to_string()),
12057 WebUIFragment::component("cart-panel"),
12058 ],
12059 contains_boundary: false,
12060 },
12061 );
12062 fragments.insert(
12064 "cart-panel".to_string(),
12065 FragmentList {
12066 fragments: vec![
12067 WebUIFragment::raw("<aside>".to_string()),
12068 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12069 WebUIFragment::raw("</aside>".to_string()),
12070 ],
12071 contains_boundary: false,
12072 },
12073 );
12074 fragments.insert(
12076 "cart-items".to_string(),
12077 FragmentList {
12078 fragments: vec![WebUIFragment::component("product-card")],
12079 contains_boundary: false,
12080 },
12081 );
12082 fragments.insert(
12083 "product-card".to_string(),
12084 FragmentList {
12085 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12086 contains_boundary: false,
12087 },
12088 );
12089
12090 let mut protocol = WebUIProtocol::new(fragments);
12091 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12092 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12093 for name in ["app-shell", "cart-panel", "product-card"] {
12094 let comp = protocol.components.entry(name.to_string()).or_default();
12095 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12096 comp.css = format!(".{name}{{display:block}}");
12097 if name == "cart-panel" {
12098 comp.hydration_mode = StateProjectionMode::Keys as i32;
12099 comp.hydration_keys = vec!["hasItems".to_string()];
12100 }
12101 if name == "product-card" {
12102 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12103 }
12104 }
12105 protocol.populate_style_closures(&["index.html"]);
12106
12107 let state = test_json!({ "hasItems": false });
12109 let mut writer = TestWriter::new();
12110
12111 let handler = WebUIHandler::with_plugin(|| {
12112 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12113 });
12114 handler
12115 .handle(
12116 &protocol,
12117 &state,
12118 &RenderOptions::new("index.html", "/"),
12119 &mut writer,
12120 )
12121 .unwrap();
12122
12123 let html = writer.get_content();
12124
12125 assert!(
12126 html.contains(r#"<script type="application/json" id="webui-data">"#),
12127 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12128 );
12129 assert!(
12130 html.contains(r#""state":{"hasItems":false}"#),
12131 "SSR state should live in the JSON data block: {html}"
12132 );
12133 assert!(
12134 html.contains(r#""inventory":"#),
12135 "SSR inventory should live in the JSON data block: {html}"
12136 );
12137 assert!(
12138 !html.contains("window.__webui={\""),
12139 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12140 );
12141 assert!(
12142 !html.contains(r#"document.getElementById("webui-data")"#),
12143 "SSR must not parse webui-data; client packages own that lazy load: {html}"
12144 );
12145 assert!(
12146 !html.contains("window.__webui=w;"),
12147 "executable bootstrap must not replace existing window.__webui registrations: {html}"
12148 );
12149 assert!(
12150 !html.contains("w.templateFns={\""),
12151 "template function emission must not replace existing templateFns registrations: {html}"
12152 );
12153 assert!(
12154 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12155 "template functions should merge into the flat templateFns registry: {html}"
12156 );
12157
12158 assert!(
12161 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12162 "product-card template should be emitted even when unrendered: {html}"
12163 );
12164
12165 assert!(
12168 html.contains(r#""product-card":"data:text/css,"#),
12169 "reachable product-card CSS module importmap should be emitted: {html}"
12170 );
12171
12172 assert!(
12174 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12175 "rendered app-shell template should be emitted: {html}"
12176 );
12177 assert!(
12178 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12179 "rendered cart-panel template should be emitted: {html}"
12180 );
12181 }
12182
12183 #[test]
12184 fn component_asset_manifest_falls_back_to_body_start() {
12185 let fragments = HashMap::from([(
12186 "index.html".to_string(),
12187 FragmentList {
12188 fragments: vec![
12189 WebUIFragment::raw("<body>".to_string()),
12190 structural_fragment("body_start"),
12191 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12192 ],
12193 contains_boundary: false,
12194 },
12195 )]);
12196 let mut protocol = WebUIProtocol::new(fragments);
12197 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12198 root: "lazy-panel".to_string(),
12199 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12200 }];
12201 let mut writer = TestWriter::new();
12202
12203 handle(
12204 &protocol,
12205 &Value::Null,
12206 &RenderOptions::new("index.html", "/"),
12207 &mut writer,
12208 )
12209 .unwrap();
12210
12211 assert_eq!(
12212 writer.get_content(),
12213 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12214 );
12215 }
12216
12217 #[test]
12218 fn component_asset_styles_emit_once_when_body_precedes_head() {
12219 let fragments = HashMap::from([(
12220 "index.html".to_string(),
12221 FragmentList {
12222 fragments: vec![
12223 WebUIFragment::raw("<body>".to_string()),
12224 structural_fragment("body_start"),
12225 WebUIFragment::raw("</body><head>".to_string()),
12226 structural_fragment("head_end"),
12227 WebUIFragment::raw("</head>".to_string()),
12228 ],
12229 contains_boundary: false,
12230 },
12231 )]);
12232 let mut protocol = WebUIProtocol::new(fragments);
12233 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12234 root: "lazy-panel".to_string(),
12235 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12236 }];
12237 let mut writer = TestWriter::new();
12238
12239 handle(
12240 &protocol,
12241 &Value::Null,
12242 &RenderOptions::new("index.html", "/"),
12243 &mut writer,
12244 )
12245 .unwrap();
12246
12247 assert_eq!(
12248 writer
12249 .get_content()
12250 .matches(r#"id="webui-component-assets""#)
12251 .count(),
12252 1
12253 );
12254 }
12255
12256 #[test]
12257 fn light_component_asset_styles_apply_at_head_end() {
12258 let fragments = HashMap::from([(
12259 "index.html".to_string(),
12260 FragmentList {
12261 fragments: vec![
12262 WebUIFragment::raw("<html><head>".to_string()),
12263 structural_fragment("head_end"),
12264 WebUIFragment::raw("</head><body></body></html>".to_string()),
12265 ],
12266 contains_boundary: false,
12267 },
12268 )]);
12269 let mut protocol = WebUIProtocol::new(fragments);
12270 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12271 for root in ["lazy-panel", "secondary-panel"] {
12272 protocol.components.insert(
12273 root.to_string(),
12274 webui_protocol::ComponentData {
12275 uses_shadow_dom: false,
12276 ..Default::default()
12277 },
12278 );
12279 }
12280 protocol.component_asset_style_preloads = vec![
12281 ComponentAssetStylePreload {
12282 root: "lazy-panel".to_string(),
12283 style_hrefs: vec![
12284 "/assets/lazy-panel.css".to_string(),
12285 "/assets/shared.css".to_string(),
12286 ],
12287 },
12288 ComponentAssetStylePreload {
12289 root: "secondary-panel".to_string(),
12290 style_hrefs: vec![
12291 "/assets/secondary-panel.css".to_string(),
12292 "/assets/shared.css".to_string(),
12293 ],
12294 },
12295 ];
12296 let mut writer = TestWriter::new();
12297
12298 handle(
12299 &protocol,
12300 &Value::Null,
12301 &RenderOptions::new("index.html", "/"),
12302 &mut writer,
12303 )
12304 .unwrap();
12305
12306 let html = writer.get_content();
12307 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12308 assert_eq!(html.matches("/assets/shared.css").count(), 1);
12309 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12310 let links_end = html
12311 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12312 .expect("asset stylesheet must be emitted");
12313 let head_end = html.find("</head>").expect("head must close");
12314 assert!(links_end < head_end);
12315 }
12316
12317 #[test]
12327 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12328 let template = r#"<h1>Dashboard</h1>"#;
12330
12331 let mut fragments = HashMap::new();
12332 fragments.insert(
12333 "index.html".to_string(),
12334 FragmentList {
12335 fragments: vec![
12336 WebUIFragment::raw("<html><head>".to_string()),
12337 structural_fragment("head_end"),
12338 WebUIFragment::raw("</head><body>".to_string()),
12339 WebUIFragment::route("/", "dash-page"),
12340 structural_fragment("body_end"),
12341 WebUIFragment::raw("</body></html>".to_string()),
12342 ],
12343 contains_boundary: false,
12344 },
12345 );
12346 fragments.insert(
12347 "dash-page".to_string(),
12348 FragmentList {
12349 fragments: vec![WebUIFragment::raw(template.to_string())],
12350 contains_boundary: false,
12351 },
12352 );
12353
12354 let mut protocol = WebUIProtocol::new(fragments);
12355 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12356 let comp = protocol
12357 .components
12358 .entry("dash-page".to_string())
12359 .or_default();
12360 comp.css = "h1{font-size:2rem}".to_string();
12361 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12362 protocol.populate_style_closures(&["index.html"]);
12363 let state = test_json!({});
12364 let mut writer = TestWriter::new();
12365
12366 handle(
12367 &protocol,
12368 &state,
12369 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12370 &mut writer,
12371 )
12372 .unwrap();
12373
12374 let html = writer.get_content();
12375
12376 assert!(
12377 html.contains(
12378 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12379 ),
12380 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12381 );
12382 }
12383
12384 #[test]
12385 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12386 let mut fragments = HashMap::new();
12391 fragments.insert(
12392 "index.html".to_string(),
12393 FragmentList {
12394 fragments: vec![
12395 WebUIFragment::raw("<html><head>".to_string()),
12396 structural_fragment("head_end"),
12397 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12398 WebUIFragment::component("app-shell"),
12399 WebUIFragment::raw("</app-shell>".to_string()),
12400 structural_fragment("body_end"),
12401 WebUIFragment::raw("</body></html>".to_string()),
12402 ],
12403 contains_boundary: false,
12404 },
12405 );
12406 fragments.insert(
12407 "app-shell".to_string(),
12408 FragmentList {
12409 fragments: vec![WebUIFragment::if_cond(
12410 ConditionExpr::identifier("hasItems"),
12411 "cart-items",
12412 )],
12413 contains_boundary: false,
12414 },
12415 );
12416 fragments.insert(
12417 "cart-items".to_string(),
12418 FragmentList {
12419 fragments: vec![WebUIFragment::component("product-card")],
12420 contains_boundary: false,
12421 },
12422 );
12423 fragments.insert(
12424 "product-card".to_string(),
12425 FragmentList {
12426 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12427 contains_boundary: false,
12428 },
12429 );
12430
12431 let mut protocol = WebUIProtocol::new(fragments);
12432 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12433 for name in ["app-shell", "product-card"] {
12434 let comp = protocol.components.entry(name.to_string()).or_default();
12435 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12436 comp.css = format!(".{name}{{display:block}}");
12437 }
12438 protocol.populate_style_closures(&["index.html"]);
12439
12440 let state = test_json!({ "hasItems": false });
12443 let mut writer = TestWriter::new();
12444
12445 let handler = WebUIHandler::with_plugin(|| {
12446 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12447 });
12448 handler
12449 .handle(
12450 &protocol,
12451 &state,
12452 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12453 &mut writer,
12454 )
12455 .unwrap();
12456
12457 let html = writer.get_content();
12458
12459 assert!(
12460 html.contains(
12461 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12462 ),
12463 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12464 );
12465 }
12466
12467 #[test]
12468 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12469 let mut fragments = HashMap::new();
12470 fragments.insert(
12471 "index.html".to_string(),
12472 FragmentList {
12473 fragments: vec![
12474 WebUIFragment::raw("<html><body><style>".to_string()),
12475 WebUIFragment::signal("tokens.light", true),
12476 WebUIFragment::raw("</style>".to_string()),
12477 structural_fragment("body_end"),
12478 WebUIFragment::raw("</body></html>".to_string()),
12479 ],
12480 contains_boundary: false,
12481 },
12482 );
12483 fragments.insert(
12484 "app-shell".to_string(),
12485 FragmentList {
12486 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12487 contains_boundary: false,
12488 },
12489 );
12490 let index_fragments = fragments
12491 .get_mut("index.html")
12492 .expect("index fixture should exist");
12493 index_fragments
12494 .fragments
12495 .insert(1, WebUIFragment::component("app-shell"));
12496 let mut protocol = WebUIProtocol::new(fragments);
12497 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12498 protocol.components.insert(
12503 "app-shell".to_string(),
12504 webui_protocol::ComponentData {
12505 hydration_mode: StateProjectionMode::Keys as i32,
12506 hydration_keys: vec!["name".to_string()],
12507 ..Default::default()
12508 },
12509 );
12510 let state = test_json!({
12511 "name": "Alice",
12512 "tokens": {
12513 "light": "--color-brand: red;"
12514 }
12515 });
12516 let mut writer = TestWriter::new();
12517 let handler = WebUIHandler::with_plugin(|| {
12518 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12519 });
12520 handler.handle(
12521 &protocol,
12522 &state,
12523 &RenderOptions::new("index.html", "/"),
12524 &mut writer,
12525 )?;
12526 let output = writer.get_content();
12527
12528 assert!(output.contains("--color-brand: red;"));
12530 assert!(output.contains(r#""name":"Alice""#));
12532 assert!(!output.contains(r#""tokens""#));
12533 Ok(())
12534 }
12535
12536 #[test]
12537 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12538 let mut fragments = HashMap::new();
12539 fragments.insert(
12540 "index.html".to_string(),
12541 FragmentList {
12542 fragments: vec![
12543 WebUIFragment::raw("<html><body>"),
12544 structural_fragment("body_end"),
12545 WebUIFragment::raw("</body></html>"),
12546 ],
12547 contains_boundary: false,
12548 },
12549 );
12550 let protocol = WebUIProtocol::new(fragments);
12551 let state = test_json!({
12552 "client": "visible",
12553 "serverOnly": "also preserved",
12554 });
12555 let handler = WebUIHandler::with_plugin(|| {
12556 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12557 });
12558 let mut writer = TestWriter::new();
12559 handler.handle(
12560 &protocol,
12561 &state,
12562 &RenderOptions::new("index.html", "/"),
12563 &mut writer,
12564 )?;
12565 let output = writer.get_content();
12566 assert!(output.contains(r#""client":"visible""#));
12567 assert!(output.contains(r#""serverOnly":"also preserved""#));
12568 Ok(())
12569 }
12570
12571 #[test]
12572 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12573 let mut fragments = HashMap::new();
12574 fragments.insert(
12575 "index.html".to_string(),
12576 FragmentList {
12577 fragments: vec![
12578 WebUIFragment::raw("<html><body>"),
12579 WebUIFragment::component("app-shell"),
12580 structural_fragment("body_end"),
12581 WebUIFragment::raw("</body></html>"),
12582 ],
12583 contains_boundary: false,
12584 },
12585 );
12586 fragments.insert(
12587 "app-shell".to_string(),
12588 FragmentList {
12589 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12590 contains_boundary: false,
12591 },
12592 );
12593 let mut protocol = WebUIProtocol::new(fragments);
12594 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12595 protocol.components.insert(
12596 "app-shell".to_string(),
12597 webui_protocol::ComponentData {
12598 hydration_mode: StateProjectionMode::All as i32,
12599 ..Default::default()
12600 },
12601 );
12602 let state = test_json!({
12603 "known": "value",
12604 "possiblyInherited": "must not be dropped",
12605 });
12606 let handler = WebUIHandler::with_plugin(|| {
12607 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12608 });
12609 let mut writer = TestWriter::new();
12610 handler.handle(
12611 &protocol,
12612 &state,
12613 &RenderOptions::new("index.html", "/"),
12614 &mut writer,
12615 )?;
12616 let output = writer.get_content();
12617 assert!(output.contains(r#""known":"value""#));
12618 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12619 Ok(())
12620 }
12621
12622 #[test]
12623 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12624 let mut fragments = HashMap::new();
12625 fragments.insert(
12626 "index.html".to_string(),
12627 FragmentList {
12628 fragments: vec![
12629 WebUIFragment::raw("<html><body>"),
12630 WebUIFragment::component("app-shell"),
12631 structural_fragment("body_end"),
12632 WebUIFragment::raw("</body></html>"),
12633 ],
12634 contains_boundary: false,
12635 },
12636 );
12637 fragments.insert(
12638 "app-shell".to_string(),
12639 FragmentList {
12640 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12641 contains_boundary: false,
12642 },
12643 );
12644 let mut protocol = WebUIProtocol::new(fragments);
12645 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12646 let state = test_json!({
12647 "known": "value",
12648 "serverOnly": "must not be dropped",
12649 });
12650 let handler = WebUIHandler::with_plugin(|| {
12651 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12652 });
12653 let mut writer = TestWriter::new();
12654 handler.handle(
12655 &protocol,
12656 &state,
12657 &RenderOptions::new("index.html", "/"),
12658 &mut writer,
12659 )?;
12660 let output = writer.get_content();
12661 assert!(output.contains(r#""known":"value""#));
12662 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12663 Ok(())
12664 }
12665
12666 #[test]
12667 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12668 let mut fragments = HashMap::new();
12669 fragments.insert(
12670 "index.html".to_string(),
12671 FragmentList {
12672 fragments: vec![
12673 WebUIFragment::raw("<html><body>"),
12674 WebUIFragment::component("app-shell"),
12675 structural_fragment("body_end"),
12676 WebUIFragment::raw("</body></html>"),
12677 ],
12678 contains_boundary: false,
12679 },
12680 );
12681 fragments.insert(
12682 "app-shell".to_string(),
12683 FragmentList {
12684 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12685 contains_boundary: false,
12686 },
12687 );
12688 let mut protocol = WebUIProtocol::new(fragments);
12689 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12690 protocol.components.insert(
12691 "app-shell".to_string(),
12692 webui_protocol::ComponentData {
12693 hydration_mode: i32::MAX,
12694 ..Default::default()
12695 },
12696 );
12697 let state = test_json!({
12698 "known": "value",
12699 "serverOnly": "must not be dropped",
12700 });
12701 let handler = WebUIHandler::with_plugin(|| {
12702 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12703 });
12704 let mut writer = TestWriter::new();
12705 handler.handle(
12706 &protocol,
12707 &state,
12708 &RenderOptions::new("index.html", "/"),
12709 &mut writer,
12710 )?;
12711 let output = writer.get_content();
12712 assert!(output.contains(r#""known":"value""#));
12713 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12714 Ok(())
12715 }
12716
12717 #[test]
12718 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12719 let mut protocol = WebUIProtocol::new(HashMap::new());
12720 protocol.components.insert(
12721 "app-shell".to_string(),
12722 webui_protocol::ComponentData {
12723 navigation_keys: vec!["selected".to_string()],
12724 ..Default::default()
12725 },
12726 );
12727
12728 match collect_navigation_state(&protocol, ["app-shell"]) {
12729 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12730 StateSelection::Full
12731 | StateSelection::KeyIds(_)
12732 | StateSelection::FullExceptKeyIds(_) => {
12733 panic!("legacy navigation keys should remain owned and projected")
12734 }
12735 }
12736 }
12737
12738 #[test]
12739 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12740 let mut protocol = WebUIProtocol::new(HashMap::new());
12741 protocol.components.insert(
12742 "app-shell".to_string(),
12743 webui_protocol::ComponentData::default(),
12744 );
12745
12746 assert!(matches!(
12747 collect_navigation_state(&protocol, ["app-shell"]),
12748 StateSelection::Full
12749 ));
12750 }
12751
12752 #[test]
12753 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12754 let mut fragments = HashMap::new();
12755 fragments.insert(
12756 "index.html".to_string(),
12757 FragmentList {
12758 fragments: vec![
12759 WebUIFragment::raw("<html><body>".to_string()),
12760 structural_fragment("body_end"),
12761 WebUIFragment::raw("</body></html>".to_string()),
12762 ],
12763 contains_boundary: false,
12764 },
12765 );
12766 let mut protocol = WebUIProtocol::new(fragments);
12767 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12768 let state = test_json!({
12769 "title": "Legacy state",
12770 "serverOnly": "preserved",
12771 });
12772 let handler = WebUIHandler::with_plugin(|| {
12773 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12774 });
12775
12776 let mut writer = TestWriter::new();
12777 handler.handle(
12778 &protocol,
12779 &state,
12780 &RenderOptions::new("index.html", "/"),
12781 &mut writer,
12782 )?;
12783 assert!(writer.get_content().contains(r#""state":{}"#));
12784 assert!(!writer.get_content().contains("Legacy state"));
12785 assert!(!writer.get_content().contains("preserved"));
12786 Ok(())
12787 }
12788
12789 #[test]
12790 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12791 let mut fragments = HashMap::new();
12792 fragments.insert(
12793 "index.html".to_string(),
12794 FragmentList {
12795 fragments: vec![
12796 WebUIFragment::raw("<html><body>"),
12797 WebUIFragment::component("items-page"),
12798 structural_fragment("body_end"),
12799 WebUIFragment::raw("</body></html>"),
12800 ],
12801 contains_boundary: false,
12802 },
12803 );
12804 fragments.insert(
12805 "items-page".to_string(),
12806 FragmentList {
12807 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12808 contains_boundary: false,
12809 },
12810 );
12811 let mut protocol = WebUIProtocol::new(fragments);
12812 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12813 protocol.components.insert(
12814 "items-page".to_string(),
12815 webui_protocol::ComponentData {
12816 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12817 navigation_mode: Some(StateProjectionMode::Keys as i32),
12818 navigation_keys: vec!["items".into()],
12819 ..Default::default()
12820 },
12821 );
12822 let state = test_json!({
12823 "items": ["STATE_SENTINEL"],
12824 "serverOnly": "SECRET_SENTINEL",
12825 });
12826 let handler = WebUIHandler::with_plugin(|| {
12827 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12828 });
12829 let mut writer = TestWriter::new();
12830
12831 handler.handle(
12832 &protocol,
12833 &state,
12834 &RenderOptions::new("index.html", "/"),
12835 &mut writer,
12836 )?;
12837
12838 let output = writer.get_content();
12839 assert!(output.contains(r#""state":{}"#));
12840 assert!(!output.contains("STATE_SENTINEL"));
12841 assert!(!output.contains("SECRET_SENTINEL"));
12842 Ok(())
12843 }
12844
12845 #[test]
12846 fn write_selected_state_projects_and_escapes() {
12847 let state = test_json!({
12850 "drop": "secret",
12851 "keep": "</script><b>"
12852 });
12853 let keys = ["keep"];
12854 let selection = StateSelection::Keys(keys.to_vec());
12855 let mut sink = TestWriter::new();
12856 let mut scratch = Vec::new();
12857 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12858 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12859 }
12860
12861 #[test]
12862 fn write_selected_state_non_object_projection_emits_empty_object() {
12863 let state = test_json!("scalar state has nothing hydratable");
12864 let keys: [&str; 0] = [];
12865 let selection = StateSelection::Keys(keys.to_vec());
12866 let mut sink = TestWriter::new();
12867 let mut scratch = Vec::new();
12868 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12869 assert_eq!(sink.get_content(), "{}");
12870 }
12871
12872 #[test]
12873 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12874 let state = test_json!({
12875 "keptA": 1,
12876 "keptB": 2,
12877 "serverOnlyA": 3,
12878 "serverOnlyB": 4,
12879 });
12880 let keys = ["keptA", "keptA", "keptB", "missing"];
12881 let selection = StateSelection::Keys(keys.to_vec());
12882 let mut sink = TestWriter::new();
12883 let mut scratch = Vec::new();
12884 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12885 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12886 }
12887
12888 #[test]
12889 fn write_selected_state_map_first_matches_schema_first_output() {
12890 let state = test_json!({
12891 "keptA": 1,
12892 "keptB": 2,
12893 });
12894 let keys = ["keptA", "keptB", "missingA", "missingB"];
12895 let selection = StateSelection::Keys(keys.to_vec());
12896 let mut sink = TestWriter::new();
12897 let mut scratch = Vec::new();
12898 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12899 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12900 }
12901
12902 #[test]
12903 fn write_selected_state_full_preserves_and_escapes_state() {
12904 let state = test_json!({
12905 "serverOnly": "</script><b>",
12906 "value": 42,
12907 });
12908 let mut sink = TestWriter::new();
12909 let mut scratch = Vec::new();
12910 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12911 assert_eq!(
12912 sink.get_content(),
12913 r#"{"serverOnly":"<\/script><b>","value":42}"#
12914 );
12915 }
12916
12917 #[test]
12918 fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
12919 let cases = [
12920 ("", ""),
12921 ("</", "<\\/"),
12922 ("</a</b", "<\\/a<\\/b"),
12923 ("<<//", "<<\\//"),
12924 ("a<b>c<", "a<b>c<"),
12925 ("x</", "x<\\/"),
12926 ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
12927 ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
12928 ];
12929 for (json, expected) in cases {
12930 let mut sink = TestWriter::new();
12931 write_script_safe_json_str(&mut sink, json)?;
12932 assert_eq!(sink.get_content(), expected, "input {json:?}");
12933 }
12934 Ok(())
12935 }
12936
12937 #[test]
12938 fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
12939 let mut json = String::with_capacity(512);
12942 for gap in 0..130 {
12943 json.clear();
12944 json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
12945 json.push_str(&"x".repeat(gap));
12946 json.push_str("</p><br/>");
12947 json.push_str(&"y".repeat(gap % 37));
12948 json.push_str("</");
12949 let mut sink = TestWriter::new();
12950 write_script_safe_json_str(&mut sink, &json)?;
12951 assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
12952 }
12953 Ok(())
12954 }
12955
12956 #[test]
12957 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12958 let preloads = vec![ComponentAssetStylePreload {
12959 root: "lazy-panel".to_string(),
12960 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12961 }];
12962
12963 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12964
12965 assert_eq!(
12966 manifest,
12967 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12968 );
12969 }
12970
12971 #[test]
12972 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12973 let mut sink = TestWriter::new();
12979 let mut scratch: Vec<u8> = Vec::new();
12980 assert_eq!(scratch.capacity(), 0);
12982
12983 let mut large = serde_json::Map::new();
12985 for index in 0..64 {
12986 large.insert(
12987 format!("component_{index:04}"),
12988 Value::String("x".repeat(48)),
12989 );
12990 }
12991 let large = Value::Object(large);
12992 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12993 let high_water = scratch.capacity();
12994 assert!(high_water > 0);
12995
12996 for seq in 0..8 {
12999 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13000 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13001 assert_eq!(
13002 scratch.capacity(),
13003 high_water,
13004 "scratch reallocated on checkpoint {seq}"
13005 );
13006 }
13007 }
13008
13009 #[test]
13010 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13011 let mut fragments = HashMap::new();
13012 fragments.insert(
13013 "index.html".to_string(),
13014 FragmentList {
13015 fragments: vec![
13016 WebUIFragment::raw("<html><body>"),
13017 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13018 path: "/".to_string(),
13019 fragment_id: "home-page".to_string(),
13020 exact: true,
13021 ..Default::default()
13022 }),
13023 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13024 path: "/admin".to_string(),
13025 fragment_id: "admin-page".to_string(),
13026 exact: true,
13027 ..Default::default()
13028 }),
13029 structural_fragment("body_end"),
13030 WebUIFragment::raw("</body></html>"),
13031 ],
13032
13033 contains_boundary: false,
13034 },
13035 );
13036 fragments.insert(
13037 "home-page".to_string(),
13038 FragmentList {
13039 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13040 contains_boundary: false,
13041 },
13042 );
13043 fragments.insert(
13044 "admin-page".to_string(),
13045 FragmentList {
13046 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13047 contains_boundary: false,
13048 },
13049 );
13050
13051 let mut protocol = WebUIProtocol::new(fragments);
13052 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13053 protocol.components.insert(
13054 "home-page".to_string(),
13055 webui_protocol::ComponentData {
13056 template_json: "{}".to_string(),
13057 hydration_mode: StateProjectionMode::Keys as i32,
13058 hydration_keys: vec!["homeTitle".to_string()],
13059 ..Default::default()
13060 },
13061 );
13062 protocol.components.insert(
13063 "admin-page".to_string(),
13064 webui_protocol::ComponentData {
13065 template_json: "{}".to_string(),
13066 hydration_mode: StateProjectionMode::Keys as i32,
13067 hydration_keys: vec!["adminToken".to_string()],
13068 ..Default::default()
13069 },
13070 );
13071 let state = test_json!({
13072 "homeTitle": "Welcome",
13073 "adminToken": "TOP_SECRET_SENTINEL",
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#""homeTitle":"Welcome""#));
13087 assert!(!output.contains("TOP_SECRET_SENTINEL"));
13088 assert!(!output.contains(r#""adminToken""#));
13089 Ok(())
13090 }
13091
13092 #[test]
13093 fn test_component_attr_name_aria() {
13094 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13096 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13097 assert_eq!(
13098 component_attr_name("aria-activedescendant"),
13099 "ariaActiveDescendant"
13100 );
13101 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13102 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13103 }
13104
13105 #[test]
13106 fn test_component_attr_name_html_global() {
13107 assert_eq!(component_attr_name("readonly"), "readOnly");
13108 assert_eq!(component_attr_name("tabindex"), "tabIndex");
13109 assert_eq!(component_attr_name("accesskey"), "accessKey");
13110 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13111 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13112 assert_eq!(component_attr_name("inputmode"), "inputMode");
13113 assert_eq!(component_attr_name("maxlength"), "maxLength");
13114 assert_eq!(component_attr_name("minlength"), "minLength");
13115 assert_eq!(component_attr_name("novalidate"), "noValidate");
13116 assert_eq!(component_attr_name("formaction"), "formAction");
13117 assert_eq!(component_attr_name("ismap"), "isMap");
13118 assert_eq!(component_attr_name("usemap"), "useMap");
13119 }
13120
13121 #[test]
13122 fn test_component_attr_name_strips_colon() {
13123 assert_eq!(component_attr_name(":readonly"), "readOnly");
13124 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13125 assert_eq!(component_attr_name(":data-title"), "dataTitle");
13126 }
13127
13128 #[test]
13129 fn test_component_attr_name_regular() {
13130 assert_eq!(component_attr_name("data-title"), "dataTitle");
13131 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13132 assert_eq!(component_attr_name("simple"), "simple");
13133 }
13134
13135 fn make_query_route_protocol() -> WebUIProtocol {
13138 use webui_protocol::WebUiFragmentRoute;
13139
13140 let mut fragments = HashMap::new();
13141
13142 fragments.insert(
13143 "index.html".to_string(),
13144 FragmentList {
13145 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13146 path: "/".into(),
13147 fragment_id: "app-shell".into(),
13148 exact: false,
13149 children: vec![
13150 WebUiFragmentRoute {
13151 path: "compose".into(),
13152 fragment_id: "compose-page".into(),
13153 exact: true,
13154 allowed_query: "action,to,subject".into(),
13155 keep_alive: false,
13156 ..Default::default()
13157 },
13158 WebUiFragmentRoute {
13159 path: "settings".into(),
13160 fragment_id: "settings-page".into(),
13161 exact: true,
13162 keep_alive: false,
13163 ..Default::default()
13164 },
13165 ],
13166 keep_alive: false,
13167 ..Default::default()
13168 })],
13169
13170 contains_boundary: false,
13171 },
13172 );
13173
13174 fragments.insert(
13175 "app-shell".to_string(),
13176 FragmentList {
13177 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13178 contains_boundary: false,
13179 },
13180 );
13181 fragments.insert(
13182 "compose-page".to_string(),
13183 FragmentList {
13184 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13185 contains_boundary: false,
13186 },
13187 );
13188 fragments.insert(
13189 "settings-page".to_string(),
13190 FragmentList {
13191 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13192 contains_boundary: false,
13193 },
13194 );
13195
13196 WebUIProtocol::new(fragments)
13197 }
13198
13199 #[test]
13200 fn test_matched_route_omits_query_attr_from_dom() {
13201 let protocol = make_query_route_protocol();
13202 let state = test_json!({});
13203 let handler = WebUIHandler::new();
13204 let mut writer = TestWriter::new();
13205
13206 handler
13207 .handle(
13208 &protocol,
13209 &state,
13210 &RenderOptions::new("index.html", "/compose"),
13211 &mut writer,
13212 )
13213 .expect("render failed");
13214
13215 let html = writer.get_content();
13216 assert!(
13218 !html.contains(r#"query="action,to,subject""#),
13219 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13220 );
13221 }
13222
13223 #[test]
13224 fn test_nonmatched_route_omits_query_attr_from_dom() {
13225 let protocol = make_query_route_protocol();
13226 let state = test_json!({});
13227 let handler = WebUIHandler::new();
13228 let mut writer = TestWriter::new();
13229
13230 handler
13231 .handle(
13232 &protocol,
13233 &state,
13234 &RenderOptions::new("index.html", "/settings"),
13235 &mut writer,
13236 )
13237 .expect("render failed");
13238
13239 let html = writer.get_content();
13240 assert!(
13242 !html.contains(r#"query="#),
13243 "hidden route should not have query attr: {html}"
13244 );
13245 }
13246
13247 #[test]
13248 fn test_route_without_query_has_no_query_attr() {
13249 let protocol = make_query_route_protocol();
13250 let state = test_json!({});
13251 let handler = WebUIHandler::new();
13252 let mut writer = TestWriter::new();
13253
13254 handler
13255 .handle(
13256 &protocol,
13257 &state,
13258 &RenderOptions::new("index.html", "/settings"),
13259 &mut writer,
13260 )
13261 .expect("render failed");
13262
13263 let html = writer.get_content();
13264 let settings_idx = html
13266 .find(r#"component="settings-page""#)
13267 .expect("settings route should exist");
13268 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13269 assert!(
13270 !settings_tag.contains("query="),
13271 "route without allowed_query should not emit query attr: {settings_tag}"
13272 );
13273 }
13274
13275 fn build_head_body_protocol() -> WebUIProtocol {
13280 let mut fragments = HashMap::new();
13281 fragments.insert(
13282 "index.html".to_string(),
13283 FragmentList {
13284 fragments: vec![
13285 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13286 structural_fragment("head_end"),
13287 WebUIFragment::raw("</head><body>hello".to_string()),
13288 structural_fragment("body_end"),
13289 WebUIFragment::raw("</body></html>".to_string()),
13290 ],
13291 contains_boundary: false,
13292 },
13293 );
13294 WebUIProtocol::new(fragments)
13295 }
13296
13297 #[test]
13298 fn head_inject_emits_at_head_end_boundary() {
13299 let protocol = build_head_body_protocol();
13300 let state = test_json!({});
13301 let mut writer = TestWriter::new();
13302 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13303 handle(&protocol, &state, &opts, &mut writer).unwrap();
13304 let html = writer.get_content();
13305 let inject_idx = html
13307 .find("<link rel=preload>")
13308 .expect("inject HTML missing");
13309 let head_close_idx = html.find("</head>").expect("</head> missing");
13310 assert!(
13311 inject_idx < head_close_idx,
13312 "head_inject must appear before </head>: {html}"
13313 );
13314 assert_eq!(html.matches("<link rel=preload>").count(), 1);
13316 }
13317
13318 #[test]
13319 fn component_render_policy_css_emits_once_in_head_with_nonce() {
13320 let mut protocol = build_head_body_protocol();
13321 protocol.component_render_css = concat!(
13322 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13323 "contain-intrinsic-block-size:auto 18rem;}"
13324 )
13325 .to_string();
13326 let state = test_json!({});
13327 let mut writer = TestWriter::new();
13328 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13329 handle(&protocol, &state, &opts, &mut writer).unwrap();
13330 let html = writer.get_content();
13331
13332 let style = concat!(
13333 r#"<style data-webui-render-policy nonce="test-nonce">"#,
13334 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13335 "contain-intrinsic-block-size:auto 18rem;}</style>"
13336 );
13337 let style_index = html.find(style).expect("render policy style missing");
13338 let head_close = html.find("</head>").expect("</head> missing");
13339 assert!(style_index < head_close);
13340 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13341 }
13342
13343 #[test]
13344 fn body_inject_emits_at_body_end_boundary() {
13345 let protocol = build_head_body_protocol();
13346 let state = test_json!({});
13347 let mut writer = TestWriter::new();
13348 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13349 handle(&protocol, &state, &opts, &mut writer).unwrap();
13350 let html = writer.get_content();
13351 let inject_idx = html
13352 .find("<script>lr</script>")
13353 .expect("inject HTML missing");
13354 let body_close_idx = html.find("</body>").expect("</body> missing");
13355 assert!(
13356 inject_idx < body_close_idx,
13357 "body_inject must appear before </body>: {html}"
13358 );
13359 assert_eq!(html.matches("<script>lr</script>").count(), 1);
13360 }
13361
13362 fn build_all_boundaries_protocol() -> WebUIProtocol {
13367 let mut fragments = HashMap::new();
13368 fragments.insert(
13369 "index.html".to_string(),
13370 FragmentList {
13371 fragments: vec![
13372 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13373 structural_fragment("head_end"),
13374 WebUIFragment::raw("</head><body>".to_string()),
13375 structural_fragment("body_start"),
13376 WebUIFragment::raw("hello".to_string()),
13377 structural_fragment("body_end"),
13378 WebUIFragment::raw("</body></html>".to_string()),
13379 ],
13380 contains_boundary: false,
13381 },
13382 );
13383 WebUIProtocol::new(fragments)
13384 }
13385
13386 fn state_inject_options<'a>() -> RenderOptions<'a> {
13387 RenderOptions::new("index.html", "/")
13388 }
13389
13390 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13391 let mut writer = TestWriter::new();
13392 handle(protocol, state, options, &mut writer).unwrap();
13393 writer.get_content().to_string()
13394 }
13395
13396 #[test]
13397 fn state_inject_emits_at_every_structural_boundary() {
13398 let protocol = build_all_boundaries_protocol();
13399 let state = test_json!({
13400 "$webui": {
13401 "headEnd": "<meta name=he>",
13402 "bodyStart": "<span id=bs></span>",
13403 "bodyEnd": "<script>be</script>",
13404 }
13405 });
13406 let html = render_with(&protocol, &state, &state_inject_options());
13407
13408 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13409 let head_close = html.find("</head>").expect("</head> missing");
13410 let body_open = html.find("<body>").expect("<body> missing");
13411 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13412 let hello = html.find("hello").expect("body content missing");
13413 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13414 let body_close = html.find("</body>").expect("</body> missing");
13415
13416 assert!(
13417 head_end < head_close,
13418 "headEnd must precede </head>: {html}"
13419 );
13420 assert!(
13421 body_open < body_start && body_start < hello,
13422 "bodyStart must sit immediately after <body>: {html}"
13423 );
13424 assert!(
13425 hello < body_end && body_end < body_close,
13426 "bodyEnd must precede </body>: {html}"
13427 );
13428
13429 for needle in [
13430 "<meta name=he>",
13431 "<span id=bs></span>",
13432 "<script>be</script>",
13433 ] {
13434 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13435 }
13436 }
13437
13438 #[test]
13439 fn state_inject_follows_render_options_inject() {
13440 let protocol = build_all_boundaries_protocol();
13441 let state = test_json!({
13442 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13443 });
13444 let options = RenderOptions::new("index.html", "/")
13445 .with_head_inject("<!--opt-he-->")
13446 .with_body_inject("<!--opt-be-->");
13447 let html = render_with(&protocol, &state, &options);
13448
13449 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13450 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13451 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13452 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13453
13454 assert!(
13455 opt_he < state_he,
13456 "RenderOptions head_inject must precede the state-supplied value: {html}"
13457 );
13458 assert!(
13459 opt_be < state_be,
13460 "RenderOptions body_inject must precede the state-supplied value: {html}"
13461 );
13462 }
13463
13464 #[test]
13465 fn malformed_state_inject_values_are_inert() {
13466 let protocol = build_all_boundaries_protocol();
13467 for state in [
13470 test_json!({}),
13471 test_json!({ "$webui": "not-an-object" }),
13472 test_json!({ "$webui": [] }),
13473 test_json!({ "$webui": null }),
13474 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13475 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13476 ] {
13477 let html = render_with(&protocol, &state, &state_inject_options());
13478 assert!(
13479 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13480 "malformed reserved state must render the document unchanged: {html}"
13481 );
13482 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13483 assert!(!html.contains("42"), "non-string member leaked: {html}");
13484 }
13485 }
13486
13487 #[test]
13488 fn state_inject_never_reaches_the_hydration_payload() {
13489 let protocol = build_all_boundaries_protocol();
13490 let state = test_json!({
13491 "visible": "keep",
13492 "$webui": { "bodyEnd": "<script>secret</script>" }
13493 });
13494 let handler = WebUIHandler::with_plugin(|| {
13495 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13496 });
13497 let runtime = Protocol::new(protocol.clone());
13498 let mut writer = TestWriter::new();
13499 handler
13500 .render(&runtime, &state, &state_inject_options(), &mut writer)
13501 .unwrap();
13502 let html = writer.get_content();
13503
13504 let data_start = html
13505 .find(r#"<script type="application/json" id="webui-data""#)
13506 .expect("hydration block missing");
13507 let data_end = html[data_start..]
13508 .find("</script>")
13509 .map(|offset| data_start + offset)
13510 .expect("hydration block never closes");
13511 let payload = &html[data_start..data_end];
13512 assert!(
13513 !payload.contains("$webui"),
13514 "reserved key must be stripped from the hydration payload: {payload}"
13515 );
13516 assert!(
13517 payload.contains("visible"),
13518 "ordinary state must survive the filter: {payload}"
13519 );
13520 let inject = html
13523 .find("<script>secret</script>")
13524 .expect("inject missing");
13525 let body_close = html.find("</body>").expect("</body> missing");
13526 assert!(
13527 data_end < inject && inject < body_close,
13528 "state bodyEnd must follow the hydration block and precede </body>: {html}"
13529 );
13530 }
13531
13532 #[test]
13533 fn projected_hydration_strips_reserved_state_inject_key() {
13534 let mut fragments = HashMap::new();
13535 fragments.insert(
13536 "index.html".to_string(),
13537 FragmentList {
13538 fragments: vec![
13539 WebUIFragment::raw("<html><body>"),
13540 WebUIFragment::component("app-shell"),
13541 structural_fragment("body_end"),
13542 WebUIFragment::raw("</body></html>"),
13543 ],
13544 contains_boundary: false,
13545 },
13546 );
13547 fragments.insert(
13548 "app-shell".to_string(),
13549 FragmentList {
13550 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13551 contains_boundary: false,
13552 },
13553 );
13554 let mut document = WebUIProtocol::new(fragments);
13555 document.initial_state_strategy = InitialStateStrategy::Components as i32;
13556 document.components.insert(
13557 "app-shell".to_string(),
13558 webui_protocol::ComponentData {
13559 hydration_mode: StateProjectionMode::Keys as i32,
13560 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13561 ..Default::default()
13562 },
13563 );
13564 let state = test_json!({
13565 "$webui": { "bodyEnd": "<script>secret</script>" },
13566 "serverOnly": "drop",
13567 "visible": "keep",
13568 });
13569 let handler = WebUIHandler::with_plugin(|| {
13570 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13571 });
13572 let mut writer = TestWriter::new();
13573
13574 handler
13575 .render(
13576 &Protocol::new(document),
13577 &state,
13578 &RenderOptions::new("index.html", "/"),
13579 &mut writer,
13580 )
13581 .unwrap();
13582 let html = writer.get_content();
13583 let data_start = html
13584 .find(r#"<script type="application/json" id="webui-data""#)
13585 .expect("hydration block missing");
13586 let data_end = html[data_start..]
13587 .find("</script>")
13588 .map(|offset| data_start + offset)
13589 .expect("hydration block never closes");
13590 let payload = &html[data_start..data_end];
13591
13592 assert!(
13593 !payload.contains(STATE_INJECT_KEY),
13594 "reserved key leaked: {payload}"
13595 );
13596 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13597 assert!(!payload.contains("serverOnly"), "{payload}");
13598 assert!(html.contains("<script>secret</script>"), "{html}");
13599 }
13600
13601 #[test]
13602 fn write_selected_state_strips_reserved_key_from_full_state() {
13603 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13604 let mut sink = TestWriter::new();
13605 let mut scratch = Vec::new();
13606 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13607 let json = sink.get_content();
13608 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13609 assert!(
13610 json.contains("\"a\":1") && json.contains("\"z\":2"),
13611 "{json}"
13612 );
13613 }
13614
13615 #[test]
13616 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13617 let mut protocol = WebUIProtocol::new(HashMap::new());
13620 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13621 protocol.fragments.insert(
13622 "keep-card".to_string(),
13623 webui_protocol::FragmentList::default(),
13624 );
13625 protocol.components.insert(
13626 "keep-card".to_string(),
13627 webui_protocol::ComponentData {
13628 hydration_mode: StateProjectionMode::Keys as i32,
13629 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13630 ..Default::default()
13631 },
13632 );
13633 let protocol = Protocol::new(protocol);
13634 let index = protocol.component_reachability();
13635 let component = protocol.component_index()["keep-card"];
13636 let mut ids = Vec::new();
13637 assert!(!collect_hydration_key_ids_into(
13638 protocol.protocol(),
13639 index,
13640 [component],
13641 &mut ids
13642 ));
13643
13644 let state = test_json!({
13645 "$webui": { "bodyEnd": "<b>x</b>" },
13646 "keep": 1,
13647 });
13648 let mut sink = TestWriter::new();
13649 let mut scratch = Vec::new();
13650
13651 write_selected_state(
13652 &mut sink,
13653 &mut scratch,
13654 &state,
13655 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13656 )
13657 .unwrap();
13658
13659 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13660 }
13661
13662 #[test]
13663 fn write_selected_state_full_is_unchanged_without_reserved_key() {
13664 let state = test_json!({ "a": 1, "z": 2 });
13665 let mut sink = TestWriter::new();
13666 let mut scratch = Vec::new();
13667 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13668 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13669 }
13670
13671 #[test]
13672 fn body_start_hook_dedupes_on_malformed_protocol() {
13673 let mut fragments = HashMap::new();
13674 fragments.insert(
13675 "index.html".to_string(),
13676 FragmentList {
13677 fragments: vec![
13678 WebUIFragment::raw("<html><head></head><body>".to_string()),
13679 structural_fragment("body_start"),
13680 structural_fragment("body_start"),
13681 WebUIFragment::raw("</body></html>".to_string()),
13682 ],
13683 contains_boundary: false,
13684 },
13685 );
13686 let protocol = WebUIProtocol::new(fragments);
13687 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13688 let html = render_with(&protocol, &state, &state_inject_options());
13689 assert_eq!(
13690 html.matches("<i>once</i>").count(),
13691 1,
13692 "duplicate body_start signals must not duplicate the inject: {html}"
13693 );
13694 }
13695
13696 #[test]
13697 fn injects_are_no_op_when_unset() {
13698 let protocol = build_head_body_protocol();
13699 let state = test_json!({});
13700 let mut writer = TestWriter::new();
13701 handle(
13702 &protocol,
13703 &state,
13704 &RenderOptions::new("index.html", "/"),
13705 &mut writer,
13706 )
13707 .unwrap();
13708 let html = writer.get_content();
13709 assert!(!html.contains("<link rel=preload>"));
13710 assert!(!html.contains("<script>lr</script>"));
13711 }
13712
13713 #[test]
13714 fn empty_inject_string_treated_as_unset() {
13715 let protocol = build_head_body_protocol();
13716 let state = test_json!({});
13717 let mut writer = TestWriter::new();
13718 let opts = RenderOptions::new("index.html", "/")
13719 .with_head_inject("")
13720 .with_body_inject("");
13721 handle(&protocol, &state, &opts, &mut writer).unwrap();
13722 let html = writer.get_content();
13725 assert!(html.contains("</head>"));
13726 assert!(html.contains("</body>"));
13727 }
13728
13729 #[test]
13730 fn inject_html_is_passed_through_verbatim() {
13731 let protocol = build_head_body_protocol();
13735 let state = test_json!({});
13736 let mut writer = TestWriter::new();
13737 let opts =
13738 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13739 handle(&protocol, &state, &opts, &mut writer).unwrap();
13740 assert!(writer.get_content().contains("<script>var x=1;</script>"));
13741 }
13742
13743 #[test]
13750 fn injects_robust_against_marker_literals_in_content() {
13751 let mut fragments = HashMap::new();
13752 fragments.insert(
13756 "index.html".to_string(),
13757 FragmentList {
13758 fragments: vec![
13759 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13760 structural_fragment("head_end"),
13761 WebUIFragment::raw(
13762 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13763 ),
13764 structural_fragment("body_end"),
13765 WebUIFragment::raw("</body></html>".to_string()),
13766 ],
13767 contains_boundary: false,
13768 },
13769 );
13770 let protocol = WebUIProtocol::new(fragments);
13771 let state = test_json!({});
13772 let mut writer = TestWriter::new();
13773 let opts = RenderOptions::new("index.html", "/")
13774 .with_head_inject("<HEAD-INJ>")
13775 .with_body_inject("<BODY-INJ>");
13776 handle(&protocol, &state, &opts, &mut writer).unwrap();
13777 let html = writer.get_content();
13778 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13781 let head_close_idx = html.find("</head>").expect("</head> missing");
13782 assert!(head_inj_idx < head_close_idx);
13783 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13787 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13789 assert!(
13790 body_inj_idx < body_close_idx,
13791 "body_inject must precede the structural </body>: {html}"
13792 );
13793 assert!(html.contains("<!-- </body> </head> -->"));
13795 }
13796
13797 #[test]
13801 fn both_injects_fire_at_correct_boundaries() {
13802 let protocol = build_head_body_protocol();
13803 let state = test_json!({});
13804 let mut writer = TestWriter::new();
13805 let opts = RenderOptions::new("index.html", "/")
13806 .with_head_inject("<META-HEAD>")
13807 .with_body_inject("<SCRIPT-BODY>");
13808 handle(&protocol, &state, &opts, &mut writer).unwrap();
13809 let html = writer.get_content();
13810 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13811 let head_close = html.find("</head>").expect("</head> missing");
13812 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13813 let body_close = html.find("</body>").expect("</body> missing");
13814 assert!(head_idx < head_close, "head_inject before </head>");
13815 assert!(head_close < body_idx, "body_inject after </head>");
13816 assert!(body_idx < body_close, "body_inject before </body>");
13817 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13818 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13819 }
13820
13821 #[test]
13827 fn injects_dedupe_against_duplicate_signals() {
13828 let mut fragments = HashMap::new();
13829 fragments.insert(
13830 "index.html".to_string(),
13831 FragmentList {
13832 fragments: vec![
13833 WebUIFragment::raw("<html><head>".to_string()),
13834 structural_fragment("head_end"),
13835 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13838 structural_fragment("body_end"),
13839 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13841 ],
13842 contains_boundary: false,
13843 },
13844 );
13845 let protocol = WebUIProtocol::new(fragments);
13846 let state = test_json!({});
13847 let mut writer = TestWriter::new();
13848 let opts = RenderOptions::new("index.html", "/")
13849 .with_head_inject("<HINJ>")
13850 .with_body_inject("<BINJ>");
13851 handle(&protocol, &state, &opts, &mut writer).unwrap();
13852 let html = writer.get_content();
13853 assert_eq!(
13854 html.matches("<HINJ>").count(),
13855 1,
13856 "head_inject must emit exactly once even with duplicate head_end signals"
13857 );
13858 assert_eq!(
13859 html.matches("<BINJ>").count(),
13860 1,
13861 "body_inject must emit exactly once even with duplicate body_end signals"
13862 );
13863 }
13864
13865 #[test]
13869 fn injects_no_op_when_no_head_or_body_signals() {
13870 let mut fragments = HashMap::new();
13871 fragments.insert(
13872 "index.html".to_string(),
13873 FragmentList {
13874 fragments: vec![WebUIFragment::raw(
13875 "<my-component>hi</my-component>".to_string(),
13876 )],
13877 contains_boundary: false,
13878 },
13879 );
13880 let protocol = WebUIProtocol::new(fragments);
13881 let state = test_json!({});
13882 let mut writer = TestWriter::new();
13883 let opts = RenderOptions::new("index.html", "/")
13884 .with_head_inject("<HINJ>")
13885 .with_body_inject("<BINJ>");
13886 handle(&protocol, &state, &opts, &mut writer).unwrap();
13887 let html = writer.get_content();
13888 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13889 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13890 assert!(html.contains("<my-component>"));
13891 }
13892
13893 #[test]
13899 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13900 let protocol = std::sync::Arc::new(build_head_body_protocol());
13901 let state = std::sync::Arc::new(test_json!({}));
13902 let handler = std::sync::Arc::new(WebUIHandler::new());
13903
13904 const N_THREADS: usize = 16;
13905 let mut handles = Vec::with_capacity(N_THREADS);
13906 for tid in 0..N_THREADS {
13907 let h = std::sync::Arc::clone(&handler);
13908 let p = std::sync::Arc::clone(&protocol);
13909 let s = std::sync::Arc::clone(&state);
13910 handles.push(std::thread::spawn(move || {
13911 let head = format!("<HEAD-T{tid}>");
13912 let body = format!("<BODY-T{tid}>");
13913 let mut writer = TestWriter::new();
13914 let opts = RenderOptions::new("index.html", "/")
13915 .with_head_inject(&head)
13916 .with_body_inject(&body);
13917 h.handle(&p, &s, &opts, &mut writer).unwrap();
13918 let html = writer.get_content();
13919 assert_eq!(html.matches(&head).count(), 1);
13921 assert_eq!(html.matches(&body).count(), 1);
13922 for other in 0..N_THREADS {
13924 if other == tid {
13925 continue;
13926 }
13927 let other_head = format!("<HEAD-T{other}>");
13928 let other_body = format!("<BODY-T{other}>");
13929 assert!(
13930 !html.contains(&other_head),
13931 "tid {tid} saw {other}'s head_inject"
13932 );
13933 assert!(
13934 !html.contains(&other_body),
13935 "tid {tid} saw {other}'s body_inject"
13936 );
13937 }
13938 }));
13939 }
13940 for h in handles {
13941 h.join().expect("worker panicked");
13942 }
13943 }
13944
13945 #[test]
13950 fn large_inject_roundtrips_without_truncation() {
13951 let protocol = build_head_body_protocol();
13952 let state = test_json!({});
13953 let mut writer = TestWriter::new();
13954 let big = "x".repeat(1024 * 1024);
13955 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13956 handle(&protocol, &state, &opts, &mut writer).unwrap();
13957 let html = writer.get_content();
13958 assert!(
13959 html.contains(&big),
13960 "large head_inject must be present verbatim ({} bytes)",
13961 big.len()
13962 );
13963 assert_eq!(html.matches(&big).count(), 1);
13965 }
13966
13967 #[test]
13972 fn empty_nonce_treated_as_unset() {
13973 let protocol = build_head_body_protocol();
13974 let state = test_json!({});
13975 let mut writer = TestWriter::new();
13976 let opts = RenderOptions::new("index.html", "/").with_nonce("");
13977 handle(&protocol, &state, &opts, &mut writer).unwrap();
13978 assert!(
13979 !writer.get_content().contains("webui-nonce"),
13980 "empty nonce must not emit <meta name=\"webui-nonce\">"
13981 );
13982 }
13983
13984 #[test]
14002 fn empty_field_bypass_is_normalised_at_handler_init() {
14003 let protocol = build_head_body_protocol();
14004 let state = test_json!({});
14005
14006 let opts_with_empty_nonce = RenderOptions {
14008 nonce: Some(""),
14009 ..RenderOptions::new("index.html", "/")
14010 };
14011 let mut writer = TestWriter::new();
14012 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14013 let html = writer.get_content();
14014 assert!(
14015 !html.contains("webui-nonce"),
14016 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14017 );
14018 assert!(
14019 !html.contains("nonce=\"\""),
14020 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14021 );
14022
14023 let opts_with_empty_injects = RenderOptions {
14025 head_inject: Some(""),
14026 body_inject: Some(""),
14027 ..RenderOptions::new("index.html", "/")
14028 };
14029 let mut writer = TestWriter::new();
14030 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14031 }
14035
14036 #[test]
14069 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14070 let mut fragments = HashMap::new();
14071 fragments.insert(
14072 "index.html".to_string(),
14073 FragmentList {
14074 fragments: vec![
14075 WebUIFragment::raw("["),
14076 WebUIFragment::for_loop("item", "outer", "outer_body"),
14077 WebUIFragment::raw("]"),
14078 ],
14079 contains_boundary: false,
14080 },
14081 );
14082 fragments.insert(
14083 "outer_body".to_string(),
14084 FragmentList {
14085 fragments: vec![
14086 WebUIFragment::raw("(O="),
14087 WebUIFragment::signal("item.tag", false),
14088 WebUIFragment::for_loop("item", "inner", "inner_body"),
14089 WebUIFragment::raw(",O="),
14090 WebUIFragment::signal("item.tag", false),
14091 WebUIFragment::raw(")"),
14092 ],
14093 contains_boundary: false,
14094 },
14095 );
14096 fragments.insert(
14097 "inner_body".to_string(),
14098 FragmentList {
14099 fragments: vec![
14100 WebUIFragment::raw("[I="),
14101 WebUIFragment::signal("item.tag", false),
14102 WebUIFragment::raw("]"),
14103 ],
14104 contains_boundary: false,
14105 },
14106 );
14107 let protocol = WebUIProtocol::new(fragments);
14108 let state = test_json!({
14109 "outer": [{"tag": "A"}, {"tag": "B"}],
14110 "inner": [{"tag": "X"}, {"tag": "Y"}],
14111 });
14112 let mut writer = TestWriter::new();
14113 handle(
14114 &protocol,
14115 &state,
14116 &RenderOptions::new("index.html", "/"),
14117 &mut writer,
14118 )
14119 .unwrap();
14120 assert_eq!(
14132 writer.get_content(),
14133 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14134 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14135 );
14136 }
14137 #[derive(Default)]
14138 struct FlushTestWriter {
14139 output: String,
14140 flushes: Vec<usize>,
14141 fail_flush: bool,
14142 fail_flush_at: Option<usize>,
14143 flush_attempts: usize,
14144 ended: bool,
14145 }
14146
14147 impl ResponseWriter for FlushTestWriter {
14148 fn write(&mut self, content: &str) -> Result<()> {
14149 self.output.push_str(content);
14150 Ok(())
14151 }
14152
14153 fn end(&mut self) -> Result<()> {
14154 self.ended = true;
14155 Ok(())
14156 }
14157 }
14158
14159 impl FlushWriter for FlushTestWriter {
14160 fn flush(&mut self) -> Result<()> {
14161 let attempt = self.flush_attempts;
14162 self.flush_attempts += 1;
14163 if self.fail_flush || self.fail_flush_at == Some(attempt) {
14164 return Err(HandlerError::ClientDisconnected);
14165 }
14166 self.flushes.push(self.output.len());
14167 Ok(())
14168 }
14169 }
14170
14171 fn streaming_protocol(with_boundaries: bool) -> Protocol {
14172 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14173 }
14174
14175 fn streaming_protocol_with_state_strategy(
14176 with_boundaries: bool,
14177 state_strategy: InitialStateStrategy,
14178 ) -> Protocol {
14179 let mut fragments = HashMap::new();
14180 let mut entry = vec![
14181 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14182 structural_fragment("head_start"),
14183 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14184 structural_fragment("head_end"),
14185 WebUIFragment::raw("</HEAD><body>"),
14186 structural_fragment("body_start"),
14187 ];
14188 if with_boundaries {
14189 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14190 }
14191 entry.extend([
14192 WebUIFragment::raw("<my-counter"),
14193 structural_fragment("streaming_root:my-counter"),
14194 WebUIFragment::raw(">"),
14195 WebUIFragment::component("my-counter"),
14196 WebUIFragment::raw("</my-counter>"),
14197 ]);
14198 if with_boundaries {
14199 entry.push(WebUIFragment::boundary_end(0));
14200 }
14201 entry.extend([
14202 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14203 structural_fragment("body_end"),
14204 WebUIFragment::raw("</body></html>"),
14205 ]);
14206 fragments.insert(
14207 "index.html".to_string(),
14208 FragmentList {
14209 fragments: entry,
14210 contains_boundary: with_boundaries,
14211 },
14212 );
14213 fragments.insert(
14214 "my-counter".to_string(),
14215 FragmentList {
14216 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14217 contains_boundary: false,
14218 },
14219 );
14220
14221 let mut document = WebUIProtocol::new(fragments);
14222 document.initial_state_strategy = state_strategy as i32;
14223 document.components.insert(
14224 "my-counter".to_string(),
14225 webui_protocol::ComponentData {
14226 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14227 template_functions: "[function(){return true}]".to_string(),
14228 hydration_mode: StateProjectionMode::Keys as i32,
14229 hydration_keys: vec!["count".to_string()],
14230 ..Default::default()
14231 },
14232 );
14233 Protocol::new(document)
14234 }
14235
14236 #[test]
14237 fn handler_error_stays_small() {
14238 assert!(
14245 std::mem::size_of::<HandlerError>()
14246 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14247 "HandlerError grew to {} bytes",
14248 std::mem::size_of::<HandlerError>()
14249 );
14250 }
14251
14252 #[test]
14253 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14254 let protocol = streaming_protocol(true);
14255 let handler = WebUIHandler::with_plugin(|| {
14256 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14257 });
14258 let state = test_json!({ "count": 1, "serverOnly": "secret" });
14259 let mut writer = FlushTestWriter::default();
14260
14261 handler
14262 .render_streaming(
14263 &protocol,
14264 &state,
14265 &RenderOptions::new("index.html", "/"),
14266 &mut writer,
14267 )
14268 .unwrap();
14269
14270 assert!(writer.ended);
14271 assert_eq!(
14272 writer.flushes.len(),
14273 3,
14274 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14275 );
14276 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14277 assert!(boundary_flush.contains("<!--wb:0-->"));
14278 assert!(boundary_flush.contains("<!--/wb:0-->"));
14279 assert!(
14280 boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14281 "boundary flush: {boundary_flush}"
14282 );
14283 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14284 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14285 assert!(!boundary_flush.contains("slow tail"));
14286 assert!(!writer.output.contains("id=\"webui-data\""));
14287 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14290 assert!(terminal_flush.contains("slow tail"));
14291 assert!(writer.output.contains("[1,4,0,{}]"));
14292 assert!(!writer.output.contains("[1,0,1,"));
14293 assert!(!writer.output.contains("[2,4,0,{}]"));
14294
14295 let marker = writer
14296 .output
14297 .find(STREAMING_MARKER)
14298 .expect("streaming marker");
14299 let attributed_head = writer
14300 .output
14301 .find("<HEAD data-shell=\"main\">")
14302 .expect("attributed mixed-case head");
14303 let authored_script = writer
14304 .output
14305 .find("src=\"/index.js\"")
14306 .expect("entry script");
14307 assert!(attributed_head < marker && marker < authored_script);
14308 }
14309
14310 #[test]
14311 fn streaming_terminal_tail_never_resends_full_state() {
14312 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14313 let handler = WebUIHandler::with_plugin(|| {
14314 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14315 });
14316 let mut writer = FlushTestWriter::default();
14317
14318 handler
14319 .render_streaming(
14320 &protocol,
14321 &test_json!({ "count": 1, "serverOnly": "secret" }),
14322 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14323 &mut writer,
14324 )
14325 .unwrap();
14326
14327 assert_eq!(
14328 writer.output.matches("serverOnly").count(),
14329 1,
14330 "full state belongs only to the interactive boundary"
14331 );
14332 assert!(writer.output.contains("[1,4,0,{}]"));
14333 }
14334
14335 #[test]
14336 fn streaming_terminal_record_emits_before_body_close() {
14337 let fragments = HashMap::from([(
14338 "index.html".to_string(),
14339 FragmentList {
14340 fragments: vec![
14341 WebUIFragment::raw("<html><head>"),
14342 structural_fragment("head_start"),
14343 structural_fragment("head_end"),
14344 WebUIFragment::raw("</head><body>"),
14345 structural_fragment("body_start"),
14346 WebUIFragment::raw("<main>static</main>"),
14347 structural_fragment("body_end"),
14348 WebUIFragment::raw("</body></html>"),
14349 ],
14350 contains_boundary: false,
14351 },
14352 )]);
14353 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14354 let mut writer = FlushTestWriter::default();
14355
14356 WebUIHandler::new()
14357 .render_streaming(
14358 &protocol,
14359 &test_json!({}),
14360 &RenderOptions::new("index.html", "/"),
14361 &mut writer,
14362 )
14363 .unwrap();
14364
14365 let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14366 let body_close = writer.output.find("</body>").expect("body close");
14367 assert!(
14368 terminal < body_close,
14369 "terminal boundary record must stay inside <body>: {}",
14370 writer.output
14371 );
14372 }
14373
14374 #[test]
14378 fn state_inject_placement_matches_between_render_modes() {
14379 let entry = vec![
14380 WebUIFragment::raw("<html><head>"),
14381 structural_fragment("head_start"),
14382 structural_fragment("head_end"),
14383 WebUIFragment::raw("</head><body>"),
14384 structural_fragment("body_start"),
14385 WebUIFragment::raw("<main>static</main>"),
14386 structural_fragment("body_end"),
14387 WebUIFragment::raw("</body></html>"),
14388 ];
14389 let fragments = HashMap::from([(
14390 "index.html".to_string(),
14391 FragmentList {
14392 fragments: entry,
14393 contains_boundary: false,
14394 },
14395 )]);
14396 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14397 let state = test_json!({
14398 "$webui": {
14399 "headEnd": "<meta name=he>",
14400 "bodyStart": "<span id=bs></span>",
14401 "bodyEnd": "<script>be</script>",
14402 }
14403 });
14404 let options = RenderOptions::new("index.html", "/");
14405
14406 let mut ordinary = TestWriter::new();
14407 WebUIHandler::new()
14408 .render(&protocol, &state, &options, &mut ordinary)
14409 .unwrap();
14410 let ordinary_html = ordinary.get_content().to_string();
14411
14412 let mut streamed = FlushTestWriter::default();
14413 WebUIHandler::new()
14414 .render_streaming(&protocol, &state, &options, &mut streamed)
14415 .unwrap();
14416 let streamed_html = &streamed.output;
14417
14418 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14419 let head_end = html.find("<meta name=he>").expect("headEnd missing");
14420 let head_close = html.find("</head>").expect("</head> missing");
14421 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14422 let main = html.find("<main>static</main>").expect("content missing");
14423 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14424 let body_close = html.find("</body>").expect("</body> missing");
14425 assert!(head_end < head_close, "headEnd misplaced: {html}");
14426 assert!(body_start < main, "bodyStart misplaced: {html}");
14427 assert!(
14428 main < body_end && body_end < body_close,
14429 "bodyEnd misplaced: {html}"
14430 );
14431 }
14432
14433 assert!(
14436 streamed_html.contains(",4,0,{}]"),
14437 "streaming must still end in one empty terminal record: {streamed_html}"
14438 );
14439 }
14440
14441 #[test]
14442 fn streaming_state_inject_emits_and_strips_reserved_key() {
14443 let entry = vec![
14444 WebUIFragment::raw("<html><head>"),
14445 structural_fragment("head_start"),
14446 structural_fragment("head_end"),
14447 WebUIFragment::raw("</head><body>"),
14448 structural_fragment("body_start"),
14449 structural_fragment("body_end"),
14450 WebUIFragment::raw("</body></html>"),
14451 ];
14452 let fragments = HashMap::from([(
14453 "index.html".to_string(),
14454 FragmentList {
14455 fragments: entry,
14456 contains_boundary: false,
14457 },
14458 )]);
14459 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14460 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14461
14462 let mut writer = FlushTestWriter::default();
14463 WebUIHandler::new()
14464 .render_streaming(
14465 &protocol,
14466 &state,
14467 &RenderOptions::new("index.html", "/"),
14468 &mut writer,
14469 )
14470 .unwrap();
14471 assert!(writer.output.contains("<script>be</script>"));
14472 assert!(!writer.output.contains("$webui"));
14473 }
14474
14475 #[test]
14476 fn static_streaming_document_uses_one_empty_terminal_record() {
14477 let fragments = HashMap::from([(
14478 "index.html".to_string(),
14479 FragmentList {
14480 fragments: vec![
14481 WebUIFragment::raw("<html><head>"),
14482 structural_fragment("head_start"),
14483 structural_fragment("head_end"),
14484 WebUIFragment::raw("</head><body>"),
14485 structural_fragment("body_start"),
14486 WebUIFragment::raw("<main>static</main>"),
14487 structural_fragment("body_end"),
14488 WebUIFragment::raw("</body></html>"),
14489 ],
14490 contains_boundary: false,
14491 },
14492 )]);
14493 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14494 let mut writer = FlushTestWriter::default();
14495
14496 WebUIHandler::new()
14497 .render_streaming(
14498 &protocol,
14499 &test_json!({ "serverOnly": "secret" }),
14500 &RenderOptions::new("index.html", "/"),
14501 &mut writer,
14502 )
14503 .unwrap();
14504
14505 assert_eq!(writer.flushes.len(), 1);
14506 assert!(writer.output.contains(STREAMING_MARKER));
14507 assert!(writer.output.contains("[0,4,0,{}]"));
14508 assert!(!writer.output.contains("serverOnly"));
14509 assert!(!writer.output.contains("id=\"webui-data\""));
14510 assert!(!writer.output.contains("<!--wb:"));
14511 }
14512
14513 #[test]
14514 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14515 let protocol = streaming_protocol(true);
14516 let handler = WebUIHandler::with_plugin(|| {
14517 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14518 });
14519 let mut writer = FlushTestWriter::default();
14520 handler
14521 .render_streaming(
14522 &protocol,
14523 &test_json!({ "count": 1 }),
14524 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14525 &mut writer,
14526 )
14527 .unwrap();
14528
14529 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14530 let envelope = writer.output[boundary_end..]
14531 .find("data-webui-boundary nonce=\"test-nonce-123\"")
14532 .map(|index| index + boundary_end)
14533 .expect("nonce-bearing envelope");
14534 let functions = writer
14535 .output
14536 .find("templateFns")
14537 .expect("function side channel");
14538 let sentinel = writer
14539 .output
14540 .find("<webui-hydrate>")
14541 .expect("hydration sentinel");
14542 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14543 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14544 }
14545
14546 #[test]
14547 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14548 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14549 let mut fragments = vec![structural_fragment("head_start")];
14550 fragments.extend(boundaries);
14551 let fragments = HashMap::from([(
14552 "index.html".to_string(),
14553 FragmentList {
14554 fragments,
14555 contains_boundary: true,
14556 },
14557 )]);
14558 Protocol::new(WebUIProtocol::new(fragments))
14559 }
14560
14561 let handler = WebUIHandler::new();
14562 let mut writer = FlushTestWriter::default();
14563 let result = handler.render_streaming(
14564 &protocol(vec![WebUIFragment::boundary_end(0)]),
14565 &test_json!({}),
14566 &RenderOptions::new("index.html", "/"),
14567 &mut writer,
14568 );
14569 match result {
14570 Err(HandlerError::StreamingBoundary(err)) => {
14571 assert_eq!(err.signal, "boundary");
14572 assert!(err.reason.contains("no active occurrence"));
14573 }
14574 other => panic!("expected ordered-boundary error, got {other:?}"),
14575 }
14576
14577 let mut writer = FlushTestWriter::default();
14578 let result = handler.render_streaming(
14579 &protocol(vec![
14580 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14581 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14582 ]),
14583 &test_json!({}),
14584 &RenderOptions::new("index.html", "/"),
14585 &mut writer,
14586 );
14587 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14588 }
14589
14590 #[test]
14591 fn streaming_render_propagates_boundary_flush_disconnect() {
14592 let protocol = streaming_protocol(true);
14593 let handler = WebUIHandler::new();
14594 let mut writer = FlushTestWriter {
14595 fail_flush: true,
14596 ..FlushTestWriter::default()
14597 };
14598 let result = handler.render_streaming(
14599 &protocol,
14600 &test_json!({}),
14601 &RenderOptions::new("index.html", "/"),
14602 &mut writer,
14603 );
14604 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14605 assert!(!writer.ended);
14606 }
14607
14608 #[test]
14609 fn streaming_render_requires_structural_head_start_before_writing() {
14610 let fragments = HashMap::from([(
14611 "index.html".to_string(),
14612 FragmentList {
14613 fragments: vec![
14614 WebUIFragment::raw(
14615 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14616 ),
14617 structural_fragment("head_end"),
14618 WebUIFragment::raw("</HEAD><body>"),
14619 structural_fragment("body_start"),
14620 structural_fragment("body_end"),
14621 ],
14622 contains_boundary: false,
14623 },
14624 )]);
14625 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14626 let mut writer = FlushTestWriter::default();
14627
14628 let result = WebUIHandler::new().render_streaming(
14629 &protocol,
14630 &test_json!({}),
14631 &RenderOptions::new("index.html", "/"),
14632 &mut writer,
14633 );
14634
14635 assert!(matches!(
14636 result,
14637 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14638 ));
14639 assert_eq!(
14640 writer.output,
14641 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14642 );
14643 assert!(writer.flushes.is_empty());
14644 assert!(!writer.ended);
14645 }
14646
14647 #[test]
14648 fn streaming_render_rejects_duplicate_head_start() {
14649 let fragments = HashMap::from([(
14650 "index.html".to_string(),
14651 FragmentList {
14652 fragments: vec![
14653 WebUIFragment::raw("<html><head>"),
14654 structural_fragment("head_start"),
14655 structural_fragment("head_start"),
14656 structural_fragment("head_end"),
14657 WebUIFragment::raw("</head><body>"),
14658 structural_fragment("body_start"),
14659 structural_fragment("body_end"),
14660 ],
14661 contains_boundary: false,
14662 },
14663 )]);
14664 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14665 let mut writer = FlushTestWriter::default();
14666
14667 let result = WebUIHandler::new().render_streaming(
14668 &protocol,
14669 &test_json!({}),
14670 &RenderOptions::new("index.html", "/"),
14671 &mut writer,
14672 );
14673
14674 assert!(matches!(
14675 result,
14676 Err(HandlerError::DuplicateStreamingHeadStart)
14677 ));
14678 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14679 assert!(writer.flushes.is_empty());
14680 }
14681
14682 #[test]
14683 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14684 let handler = WebUIHandler::with_plugin(|| {
14685 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14686 });
14687 let state = test_json!({
14688 "count": 1,
14689 "head_start": "must not render",
14690 "boundary_start:0": "must not render",
14691 "boundary_end:0": "must not render",
14692 });
14693 let mut with_boundaries = TestWriter::new();
14694 let mut without_boundaries = TestWriter::new();
14695 handler
14696 .render(
14697 &streaming_protocol(true),
14698 &state,
14699 &RenderOptions::new("index.html", "/"),
14700 &mut with_boundaries,
14701 )
14702 .unwrap();
14703 handler
14704 .render(
14705 &streaming_protocol(false),
14706 &state,
14707 &RenderOptions::new("index.html", "/"),
14708 &mut without_boundaries,
14709 )
14710 .unwrap();
14711 assert_eq!(
14712 with_boundaries.get_content(),
14713 without_boundaries.get_content()
14714 );
14715 }
14716
14717 #[test]
14718 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14719 for source in [
14720 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14721 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14722 ] {
14723 let mut parser = HtmlParser::new();
14724 parser
14725 .parse("index.html", source)
14726 .expect("parse head fixture");
14727 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14728 let mut writer = TestWriter::new();
14729 WebUIHandler::new()
14730 .render(
14731 &protocol,
14732 &test_json!({ "theme": "light" }),
14733 &RenderOptions::new("index.html", "/"),
14734 &mut writer,
14735 )
14736 .unwrap();
14737
14738 assert_eq!(
14739 writer.get_content(),
14740 "<html><head><title>T</title></head><body>x</body></html>"
14741 );
14742 }
14743 }
14744
14745 #[test]
14746 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14747 let source =
14748 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14749 let mut parser = HtmlParser::new();
14750 parser.parse("index.html", source).expect("parse fixture");
14751 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14752
14753 let mut ordinary = TestWriter::new();
14754 WebUIHandler::new()
14755 .render(
14756 &protocol,
14757 &test_json!({}),
14758 &RenderOptions::new("index.html", "/"),
14759 &mut ordinary,
14760 )
14761 .unwrap();
14762 assert_eq!(ordinary.get_content(), source);
14763
14764 let mut streaming = FlushTestWriter::default();
14765 WebUIHandler::new()
14766 .render_streaming(
14767 &protocol,
14768 &test_json!({}),
14769 &RenderOptions::new("index.html", "/"),
14770 &mut streaming,
14771 )
14772 .unwrap();
14773 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14774 assert!(streaming.output.contains("</HEAD><BODY>x"));
14775 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14776 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14777 let title = streaming.output.find("<title>").expect("title");
14778 assert!(opening < marker && marker < title);
14779 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14780 }
14781
14782 #[test]
14785 fn unnamespaced_signal_is_ordinary_content() {
14786 let fragments = HashMap::from([(
14787 "index.html".to_string(),
14788 FragmentList {
14789 fragments: vec![
14790 WebUIFragment::raw("<html><head>"),
14791 structural_fragment("head_end"),
14792 WebUIFragment::raw("</head><body>"),
14793 WebUIFragment::signal("body_end".to_string(), false),
14794 structural_fragment("body_end"),
14795 WebUIFragment::raw("</body></html>"),
14796 ],
14797 contains_boundary: false,
14798 },
14799 )]);
14800 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14801 let mut writer = TestWriter::new();
14802 WebUIHandler::new()
14803 .render(
14804 &protocol,
14805 &test_json!({ "body_end": "content" }),
14806 &RenderOptions::new("index.html", "/"),
14807 &mut writer,
14808 )
14809 .expect("current protocol must render");
14810 assert!(writer.get_content().contains("content"));
14811 }
14812
14813 #[test]
14814 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14815 let source = concat!(
14816 "<html><head></head><body>",
14817 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14818 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14819 "</body></html>",
14820 );
14821 let state = test_json!({
14822 "head_start": "hs",
14823 "head_end": "he",
14824 "body_start": "bs",
14825 "body_end": "be",
14826 "boundary_start:0": "b0s",
14827 "boundary_end:0": "b0e",
14828 "streaming_root:forged": "root",
14829 });
14830 let mut parser = HtmlParser::new();
14831 parser.parse("index.html", source).expect("parse fixture");
14832 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14833 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14834
14835 let mut ordinary = TestWriter::new();
14836 WebUIHandler::new()
14837 .render(
14838 &protocol,
14839 &state,
14840 &RenderOptions::new("index.html", "/"),
14841 &mut ordinary,
14842 )
14843 .unwrap();
14844 assert_eq!(ordinary.get_content(), expected);
14845
14846 let mut streaming = FlushTestWriter::default();
14847 WebUIHandler::new()
14848 .render_streaming(
14849 &protocol,
14850 &state,
14851 &RenderOptions::new("index.html", "/"),
14852 &mut streaming,
14853 )
14854 .unwrap();
14855 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14856 assert!(!streaming.output.contains("<!--wb:"));
14857 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14858 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14859 }
14860
14861 #[test]
14862 fn streaming_rejects_structural_signals_after_terminal() {
14863 let fragments = HashMap::from([(
14864 "index.html".to_string(),
14865 FragmentList {
14866 fragments: vec![
14867 WebUIFragment::raw("<html><head>"),
14868 structural_fragment("head_start"),
14869 structural_fragment("head_end"),
14870 WebUIFragment::raw("</head><body>"),
14871 structural_fragment("body_start"),
14872 structural_fragment("body_end"),
14873 WebUIFragment::raw("</body>"),
14874 structural_fragment("boundary_start:0"),
14875 WebUIFragment::raw("</html>"),
14876 ],
14877 contains_boundary: false,
14878 },
14879 )]);
14880 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14881 let mut streaming = FlushTestWriter::default();
14882 let result = WebUIHandler::new().render_streaming(
14883 &protocol,
14884 &test_json!({}),
14885 &RenderOptions::new("index.html", "/"),
14886 &mut streaming,
14887 );
14888
14889 match result {
14890 Err(HandlerError::StreamingBoundary(error)) => {
14891 assert_eq!(error.signal, "boundary_start:0");
14892 assert!(
14893 error
14894 .reason
14895 .contains("structural signal arrived after body_end"),
14896 "reason: {}",
14897 error.reason
14898 );
14899 }
14900 other => panic!("expected post-terminal rejection, got {other:?}"),
14901 }
14902 assert_eq!(
14903 streaming.output,
14904 concat!(
14905 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
14906 "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
14907 "<webui-hydrate></webui-hydrate></body>"
14908 )
14909 );
14910 assert!(!streaming.output.contains("<!--wb:0-->"));
14911
14912 let mut ordinary = TestWriter::new();
14913 WebUIHandler::new()
14914 .render(
14915 &protocol,
14916 &test_json!({}),
14917 &RenderOptions::new("index.html", "/"),
14918 &mut ordinary,
14919 )
14920 .unwrap();
14921 assert_eq!(
14922 ordinary.get_content(),
14923 "<html><head></head><body></body></html>"
14924 );
14925 }
14926
14927 fn disjoint_streaming_protocol_ext(
14928 hosts: &[&str],
14929 emit_root_signal: bool,
14930 include_styles: bool,
14931 ) -> Protocol {
14932 let mut fragments = HashMap::new();
14933 let mut entry = vec![
14934 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14935 structural_fragment("head_start"),
14936 structural_fragment("head_end"),
14937 WebUIFragment::raw("</head><body>"),
14938 structural_fragment("body_start"),
14939 ];
14940 for (sequence, host) in hosts.iter().enumerate() {
14941 entry.push(WebUIFragment::boundary(
14942 sequence as u32,
14943 "index.html",
14944 format!("boundary-{sequence}"),
14945 None,
14946 ));
14947 entry.push(WebUIFragment::raw(format!("<{host}")));
14948 if emit_root_signal {
14949 entry.push(structural_fragment(format!("streaming_root:{host}")));
14950 }
14951 entry.push(WebUIFragment::raw(">"));
14952 entry.push(WebUIFragment::component(*host));
14953 entry.push(WebUIFragment::raw(format!("</{host}>")));
14954 entry.push(WebUIFragment::boundary_end(sequence as u32));
14955 }
14956 entry.push(structural_fragment("body_end"));
14957 entry.push(WebUIFragment::raw("</body></html>"));
14958 fragments.insert(
14959 "index.html".to_string(),
14960 FragmentList {
14961 fragments: entry,
14962 contains_boundary: true,
14963 },
14964 );
14965 fragments.insert(
14966 "comp-a".to_string(),
14967 FragmentList {
14968 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14969 contains_boundary: false,
14970 },
14971 );
14972 fragments.insert(
14973 "comp-b".to_string(),
14974 FragmentList {
14975 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14976 contains_boundary: false,
14977 },
14978 );
14979
14980 let mut document = WebUIProtocol::new(fragments);
14981 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14982 document.components.insert(
14983 "comp-a".to_string(),
14984 webui_protocol::ComponentData {
14985 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14986 hydration_mode: StateProjectionMode::Keys as i32,
14987 hydration_keys: vec!["a_count".to_string()],
14988 ..Default::default()
14989 },
14990 );
14991 document.components.insert(
14992 "comp-b".to_string(),
14993 webui_protocol::ComponentData {
14994 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14995 hydration_mode: StateProjectionMode::Keys as i32,
14996 hydration_keys: vec!["b_count".to_string()],
14997 ..Default::default()
14998 },
14999 );
15000 if include_styles {
15001 document.set_css_strategy(webui_protocol::CssStrategy::Style);
15002 document
15003 .components
15004 .get_mut("comp-a")
15005 .expect("comp-a component")
15006 .css = ".comp-a{color:red}".to_string();
15007 document
15008 .components
15009 .get_mut("comp-b")
15010 .expect("comp-b component")
15011 .css = ".comp-b{color:blue}".to_string();
15012 document.populate_style_closures(&["index.html"]);
15013 }
15014 Protocol::new(document)
15015 }
15016
15017 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15018 disjoint_streaming_protocol_ext(hosts, true, false)
15019 }
15020
15021 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15022 disjoint_streaming_protocol_ext(hosts, true, true)
15023 }
15024
15025 fn streaming_root_validation_protocol(
15026 mut host_fragments: Vec<WebUIFragment>,
15027 tail: Vec<WebUIFragment>,
15028 ) -> Protocol {
15029 let mut entry = vec![
15030 WebUIFragment::raw("<html><head>"),
15031 structural_fragment("head_start"),
15032 structural_fragment("head_end"),
15033 WebUIFragment::raw("</head><body>"),
15034 structural_fragment("body_start"),
15035 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15036 ];
15037 entry.append(&mut host_fragments);
15038 entry.extend(tail);
15039 let fragments = HashMap::from([
15040 (
15041 "index.html".to_string(),
15042 FragmentList {
15043 fragments: entry,
15044 contains_boundary: true,
15045 },
15046 ),
15047 (
15048 "comp-a".to_string(),
15049 FragmentList {
15050 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15051 contains_boundary: false,
15052 },
15053 ),
15054 (
15055 "comp-b".to_string(),
15056 FragmentList {
15057 fragments: vec![WebUIFragment::raw("<b>B</b>")],
15058 contains_boundary: false,
15059 },
15060 ),
15061 ]);
15062 Protocol::new(WebUIProtocol::new(fragments))
15063 }
15064
15065 fn completed_streaming_tail() -> Vec<WebUIFragment> {
15066 vec![
15067 WebUIFragment::raw("</comp-a>"),
15068 WebUIFragment::boundary_end(0),
15069 structural_fragment("body_end"),
15070 WebUIFragment::raw("</body></html>"),
15071 ]
15072 }
15073
15074 fn assert_streaming_root_error(
15075 protocol: &Protocol,
15076 expected_signal: &str,
15077 expected_reason: &str,
15078 ) {
15079 let mut writer = FlushTestWriter::default();
15080 let result = WebUIHandler::new().render_streaming(
15081 protocol,
15082 &test_json!({}),
15083 &RenderOptions::new("index.html", "/"),
15084 &mut writer,
15085 );
15086 match result {
15087 Err(HandlerError::StreamingBoundary(error)) => {
15088 assert_eq!(error.signal, expected_signal);
15089 assert!(
15090 error.reason.contains(expected_reason),
15091 "reason: {}",
15092 error.reason
15093 );
15094 }
15095 other => panic!("expected streaming-root rejection, got {other:?}"),
15096 }
15097 }
15098
15099 #[test]
15100 fn streaming_root_signal_injects_data_ws_inside_boundary() {
15101 let protocol = disjoint_streaming_protocol(&["comp-a"]);
15102 let handler = WebUIHandler::with_plugin(|| {
15103 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15104 });
15105 let mut writer = FlushTestWriter::default();
15106 handler
15107 .render_streaming(
15108 &protocol,
15109 &test_json!({ "a_count": 1 }),
15110 &RenderOptions::new("index.html", "/"),
15111 &mut writer,
15112 )
15113 .unwrap();
15114 assert!(
15117 writer.output.contains("<comp-a data-ws>"),
15118 "streamed host must carry data-ws: {}",
15119 writer.output
15120 );
15121 }
15122
15123 #[test]
15124 fn streaming_root_signal_preserves_ordinary_output_bytes() {
15125 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15128 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15129 let state = test_json!({ "a_count": 1, "b_count": 2 });
15130 let plugin = || {
15131 WebUIHandler::with_plugin(
15132 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15133 )
15134 };
15135 let mut with_writer = TestWriter::new();
15136 let mut without_writer = TestWriter::new();
15137 plugin()
15138 .render(
15139 &with_signal,
15140 &state,
15141 &RenderOptions::new("index.html", "/"),
15142 &mut with_writer,
15143 )
15144 .unwrap();
15145 plugin()
15146 .render(
15147 &without_signal,
15148 &state,
15149 &RenderOptions::new("index.html", "/"),
15150 &mut without_writer,
15151 )
15152 .unwrap();
15153 let dom_prefix = |content: &str| -> String {
15158 content
15159 .split_once(r#"<script type="application/json" id="webui-data""#)
15160 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15161 };
15162 assert_eq!(
15163 dom_prefix(&with_writer.get_content()),
15164 dom_prefix(&without_writer.get_content())
15165 );
15166 assert!(!with_writer.get_content().contains("data-ws"));
15167 assert!(with_writer.get_content().contains("<comp-a>"));
15168 }
15169
15170 #[test]
15171 fn streaming_component_without_root_signal_is_rejected() {
15172 let protocol = streaming_root_validation_protocol(
15173 vec![
15174 WebUIFragment::raw("<comp-a>"),
15175 WebUIFragment::component("comp-a"),
15176 ],
15177 completed_streaming_tail(),
15178 );
15179
15180 assert_streaming_root_error(
15181 &protocol,
15182 "comp-a",
15183 "has no compiler-owned streaming_root signal",
15184 );
15185 }
15186
15187 #[test]
15188 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15189 let duplicate = streaming_root_validation_protocol(
15190 vec![
15191 WebUIFragment::raw("<comp-a"),
15192 structural_fragment("streaming_root:comp-a"),
15193 structural_fragment("streaming_root:comp-a"),
15194 WebUIFragment::raw(">"),
15195 WebUIFragment::component("comp-a"),
15196 ],
15197 completed_streaming_tail(),
15198 );
15199 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15200
15201 let after_tag_close = streaming_root_validation_protocol(
15202 vec![
15203 WebUIFragment::raw("<comp-a>"),
15204 structural_fragment("streaming_root:comp-a"),
15205 WebUIFragment::component("comp-a"),
15206 ],
15207 completed_streaming_tail(),
15208 );
15209 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15210
15211 let detached_from_opening = streaming_root_validation_protocol(
15212 vec![
15213 WebUIFragment::raw("not an opening tag"),
15214 structural_fragment("streaming_root:comp-a"),
15215 WebUIFragment::raw(">"),
15216 WebUIFragment::component("comp-a"),
15217 ],
15218 completed_streaming_tail(),
15219 );
15220 assert_streaming_root_error(
15221 &detached_from_opening,
15222 "comp-a",
15223 "unclosed component opening-tag close",
15224 );
15225 }
15226
15227 #[test]
15228 fn mismatched_streaming_root_signal_is_rejected() {
15229 let protocol = streaming_root_validation_protocol(
15230 vec![
15231 WebUIFragment::raw("<comp-a"),
15232 structural_fragment("streaming_root:comp-b"),
15233 WebUIFragment::raw(">"),
15234 WebUIFragment::component("comp-a"),
15235 ],
15236 completed_streaming_tail(),
15237 );
15238
15239 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15240 }
15241
15242 #[test]
15243 fn pending_streaming_root_is_rejected_at_structural_ends() {
15244 let protocol = streaming_root_validation_protocol(
15245 vec![
15246 WebUIFragment::raw("<comp-a"),
15247 structural_fragment("streaming_root:comp-a"),
15248 WebUIFragment::raw(">"),
15249 ],
15250 vec![structural_fragment("boundary_end:0")],
15251 );
15252 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15253 }
15254
15255 #[test]
15256 fn streaming_root_outside_boundary_is_rejected() {
15257 let fragments = HashMap::from([
15258 (
15259 "index.html".to_string(),
15260 FragmentList {
15261 fragments: vec![
15262 WebUIFragment::raw("<html><head>"),
15263 structural_fragment("head_start"),
15264 structural_fragment("head_end"),
15265 WebUIFragment::raw("</head><body>"),
15266 structural_fragment("body_start"),
15267 WebUIFragment::raw("<comp-a"),
15268 structural_fragment("streaming_root:comp-a"),
15269 WebUIFragment::raw(">"),
15270 WebUIFragment::component("comp-a"),
15271 WebUIFragment::raw("</comp-a>"),
15272 structural_fragment("body_end"),
15273 WebUIFragment::raw("</body></html>"),
15274 ],
15275 contains_boundary: false,
15276 },
15277 ),
15278 (
15279 "comp-a".to_string(),
15280 FragmentList {
15281 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15282 contains_boundary: false,
15283 },
15284 ),
15285 ]);
15286 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15287 let mut writer = FlushTestWriter::default();
15288 let result = WebUIHandler::new().render_streaming(
15289 &protocol,
15290 &test_json!({}),
15291 &RenderOptions::new("index.html", "/"),
15292 &mut writer,
15293 );
15294 match result {
15295 Err(HandlerError::StreamingBoundary(err)) => {
15296 assert_eq!(err.signal, "comp-a");
15297 assert!(
15298 err.reason
15299 .contains("no boundary or generated component span"),
15300 "reason: {}",
15301 err.reason
15302 );
15303 }
15304 other => panic!("expected outside-boundary rejection, got {other:?}"),
15305 }
15306 }
15307
15308 fn parser_route_protocol(with_boundary: bool) -> Protocol {
15309 let mut parser = HtmlParser::with_options(DomStrategy::Light);
15310 parser
15311 .component_registry_mut()
15312 .register_component(ComponentRegistration::new(
15313 "route-page",
15314 "<p>route content</p>",
15315 None,
15316 true,
15317 ))
15318 .expect("register route component");
15319 let route = r#"<route path="/" component="route-page" exact />"#;
15320 let html = if with_boundary {
15321 format!(
15322 "<html><head></head><body>\
15323 <boundary name=\"route\">{route}</boundary>\
15324 </body></html>"
15325 )
15326 } else {
15327 format!("<html><head></head><body>{route}</body></html>")
15328 };
15329 parser
15330 .parse("index.html", &html)
15331 .expect("parse route streaming fixture");
15332 let mut document = WebUIProtocol::new(parser.into_fragment_records());
15333 document.components.insert(
15334 "route-page".to_string(),
15335 webui_protocol::ComponentData::default(),
15336 );
15337 Protocol::new(document)
15338 }
15339
15340 #[test]
15341 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15342 let protocol = parser_route_protocol(true);
15343 let mut writer = FlushTestWriter::default();
15344 WebUIHandler::new()
15345 .render_streaming(
15346 &protocol,
15347 &test_json!({}),
15348 &RenderOptions::new("index.html", "/"),
15349 &mut writer,
15350 )
15351 .unwrap();
15352
15353 assert!(
15354 writer
15355 .output
15356 .contains("<route-page data-ws><p>route content</p></route-page>"),
15357 "matched route host must be deferred before upgrade: {}",
15358 writer.output
15359 );
15360 }
15361
15362 #[test]
15363 fn streaming_parser_route_host_outside_boundary_is_rejected() {
15364 let protocol = parser_route_protocol(false);
15365 let mut writer = FlushTestWriter::default();
15366 let result = WebUIHandler::new().render_streaming(
15367 &protocol,
15368 &test_json!({}),
15369 &RenderOptions::new("index.html", "/"),
15370 &mut writer,
15371 );
15372
15373 match result {
15374 Err(HandlerError::StreamingBoundary(error)) => {
15375 assert_eq!(error.signal, "route-page");
15376 assert!(
15377 error
15378 .reason
15379 .contains("no boundary or generated component span"),
15380 "reason: {}",
15381 error.reason
15382 );
15383 }
15384 other => panic!("expected route boundary rejection, got {other:?}"),
15385 }
15386 assert!(
15387 !writer.output.contains("<route-page>"),
15388 "an unmarked custom-element opening must never be completed"
15389 );
15390 }
15391
15392 #[test]
15393 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15394 let entry = vec![
15395 WebUIFragment::raw("<html><head>"),
15396 structural_fragment("head_start"),
15397 structural_fragment("head_end"),
15398 WebUIFragment::raw("</head><body>"),
15399 structural_fragment("body_start"),
15400 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15401 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15402 path: "/account".into(),
15403 fragment_id: "account-shell".into(),
15404 exact: false,
15405 ..Default::default()
15406 }),
15407 WebUIFragment::boundary_end(0),
15408 structural_fragment("body_end"),
15409 WebUIFragment::raw("</body></html>"),
15410 ];
15411 let fragments = HashMap::from([
15412 (
15413 "index.html".to_string(),
15414 FragmentList {
15415 fragments: entry,
15416 contains_boundary: true,
15417 },
15418 ),
15419 (
15420 "account-shell".to_string(),
15421 FragmentList {
15422 fragments: vec![WebUIFragment::route_from(
15423 webui_protocol::WebUiFragmentRoute {
15424 path: "./details".into(),
15425 fragment_id: "details-page".into(),
15426 exact: true,
15427 pending_component: "details-loading".into(),
15428 error_component: "details-error".into(),
15429 ..Default::default()
15430 },
15431 )],
15432 contains_boundary: false,
15433 },
15434 ),
15435 (
15436 "details-page".to_string(),
15437 FragmentList {
15438 fragments: vec![WebUIFragment::raw("<p>details</p>")],
15439 contains_boundary: false,
15440 },
15441 ),
15442 (
15443 "details-loading".to_string(),
15444 FragmentList {
15445 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15446 contains_boundary: false,
15447 },
15448 ),
15449 (
15450 "details-error".to_string(),
15451 FragmentList {
15452 fragments: vec![WebUIFragment::raw("<p>error</p>")],
15453 contains_boundary: false,
15454 },
15455 ),
15456 ]);
15457 let mut document = WebUIProtocol::new(fragments);
15458 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15459 for name in [
15460 "account-shell",
15461 "details-page",
15462 "details-loading",
15463 "details-error",
15464 ] {
15465 document.components.insert(
15466 name.to_string(),
15467 webui_protocol::ComponentData {
15468 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15469 ..Default::default()
15470 },
15471 );
15472 }
15473 let protocol = Protocol::new(document);
15474 let handler = WebUIHandler::with_plugin(|| {
15475 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15476 });
15477 let mut writer = FlushTestWriter::default();
15478
15479 handler
15480 .render_streaming(
15481 &protocol,
15482 &test_json!({}),
15483 &RenderOptions::new("index.html", "/account/details"),
15484 &mut writer,
15485 )
15486 .unwrap();
15487
15488 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15489 assert!(
15490 first.contains(r#""details-loading":"#),
15491 "relative-route pending metadata missing: {first}"
15492 );
15493 assert!(
15494 first.contains(r#""details-error":"#),
15495 "relative-route error metadata missing: {first}"
15496 );
15497 }
15498
15499 #[test]
15500 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15501 let entry = vec![
15502 WebUIFragment::raw("<html><head>"),
15503 structural_fragment("head_start"),
15504 structural_fragment("head_end"),
15505 WebUIFragment::raw("</head><body>"),
15506 structural_fragment("body_start"),
15507 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15508 WebUIFragment::raw("<static-shell"),
15509 structural_fragment("streaming_root:static-shell"),
15510 WebUIFragment::raw(">"),
15511 WebUIFragment::component("static-shell"),
15512 WebUIFragment::raw("</static-shell>"),
15513 WebUIFragment::raw("<route-shell"),
15514 structural_fragment("streaming_root:route-shell"),
15515 WebUIFragment::raw(">"),
15516 WebUIFragment::component("route-shell"),
15517 WebUIFragment::raw("</route-shell>"),
15518 WebUIFragment::boundary_end(0),
15519 structural_fragment("body_end"),
15520 WebUIFragment::raw("</body></html>"),
15521 ];
15522 let fragments = HashMap::from([
15523 (
15524 "index.html".to_string(),
15525 FragmentList {
15526 fragments: entry,
15527 contains_boundary: true,
15528 },
15529 ),
15530 (
15531 "static-shell".to_string(),
15532 FragmentList {
15533 fragments: vec![WebUIFragment::if_cond(
15534 ConditionExpr::identifier("show_hidden"),
15535 "static-hidden-if",
15536 )],
15537 contains_boundary: false,
15538 },
15539 ),
15540 (
15541 "static-hidden-if".to_string(),
15542 FragmentList {
15543 fragments: vec![WebUIFragment::component("static-hidden")],
15544 contains_boundary: false,
15545 },
15546 ),
15547 (
15548 "static-hidden".to_string(),
15549 FragmentList {
15550 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15551 contains_boundary: false,
15552 },
15553 ),
15554 (
15555 "route-shell".to_string(),
15556 FragmentList {
15557 fragments: vec![WebUIFragment::route_from(
15558 webui_protocol::WebUiFragmentRoute {
15559 path: "/account".into(),
15560 fragment_id: "route-page".into(),
15561 exact: true,
15562 ..Default::default()
15563 },
15564 )],
15565 contains_boundary: false,
15566 },
15567 ),
15568 (
15569 "route-page".to_string(),
15570 FragmentList {
15571 fragments: vec![WebUIFragment::raw("<p>route</p>")],
15572 contains_boundary: false,
15573 },
15574 ),
15575 ]);
15576 let mut document = WebUIProtocol::new(fragments);
15577 document.set_css_strategy(webui_protocol::CssStrategy::Link);
15578 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15579 for (name, keys) in [
15580 ("static-shell", vec!["show_hidden", "static_count"]),
15581 ("static-hidden", vec!["hidden_count"]),
15582 ("route-shell", vec!["route_shell_count"]),
15583 ("route-page", vec!["route_count"]),
15584 ] {
15585 document.components.insert(
15586 name.to_string(),
15587 webui_protocol::ComponentData {
15588 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15589 hydration_mode: StateProjectionMode::Keys as i32,
15590 hydration_keys: keys.into_iter().map(str::to_string).collect(),
15591 css_href: format!("/{name}.css"),
15592 ..Default::default()
15593 },
15594 );
15595 }
15596 document.populate_style_closures(&["index.html"]);
15597 let protocol = Protocol::new(document);
15598 assert_eq!(
15599 protocol
15600 .component_reachability()
15601 .is_route_dependent(protocol.component_index()["route-shell"]),
15602 Some(true),
15603 "fixture must exercise request-aware checkpoint reachability"
15604 );
15605 let handler = WebUIHandler::with_plugin(|| {
15606 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15607 });
15608 let mut writer = FlushTestWriter::default();
15609
15610 handler
15611 .render_streaming(
15612 &protocol,
15613 &test_json!({
15614 "show_hidden": false,
15615 "static_count": 1,
15616 "hidden_count": 2,
15617 "route_shell_count": 3,
15618 "route_count": 4,
15619 "unrelated": "private",
15620 }),
15621 &RenderOptions::new("index.html", "/account"),
15622 &mut writer,
15623 )
15624 .unwrap();
15625
15626 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15627 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15628 assert!(
15629 checkpoint.contains(&format!(r#""{template}":"#)),
15630 "{template} metadata missing: {checkpoint}"
15631 );
15632 }
15633 for state in [
15634 r#""show_hidden":false"#,
15635 r#""static_count":1"#,
15636 r#""hidden_count":2"#,
15637 r#""route_shell_count":3"#,
15638 r#""route_count":4"#,
15639 ] {
15640 assert!(
15641 checkpoint.contains(state),
15642 "{state} state missing: {checkpoint}"
15643 );
15644 }
15645 assert!(
15646 !checkpoint.contains("unrelated"),
15647 "checkpoint leaked unrelated state: {checkpoint}"
15648 );
15649 assert!(
15650 checkpoint.contains(
15651 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15652 ),
15653 "checkpoint metadata lost source order: {checkpoint}"
15654 );
15655 assert!(
15656 checkpoint.contains(r#""componentStyles":"#)
15657 && checkpoint.contains(r#""strategy":"link""#)
15658 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15659 "checkpoint is missing tree-local style metadata: {checkpoint}"
15660 );
15661 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15662 assert_eq!(
15663 writer
15664 .output
15665 .matches(&format!(r#"data-webui-resource="{resource}""#))
15666 .count(),
15667 1,
15668 "Document delivery state must persist across streamed checkpoints"
15669 );
15670 }
15671 }
15672
15673 #[test]
15674 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15675 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15680 let handler = WebUIHandler::with_plugin(|| {
15681 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15682 });
15683 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15684 let mut writer = FlushTestWriter::default();
15685 handler
15686 .render_streaming(
15687 &protocol,
15688 &state,
15689 &RenderOptions::new("index.html", "/"),
15690 &mut writer,
15691 )
15692 .unwrap();
15693
15694 assert_eq!(
15695 writer.flushes.len(),
15696 5,
15697 "bootstrap, three boundary commits, and one terminal"
15698 );
15699 let segment = |index: usize| -> &str {
15700 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15701 };
15702 let b0 = segment(0);
15703 let b1 = segment(1);
15704 let b2 = segment(2);
15705
15706 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15708 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15709 assert!(
15710 b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15711 "b0: {b0}"
15712 );
15713 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15714 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15715 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15716
15717 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15719 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15720 assert!(
15721 b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15722 "b1: {b1}"
15723 );
15724 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15725 assert!(
15726 !b1.contains(r#""templates":{"comp-a"#),
15727 "b1 re-sent comp-a: {b1}"
15728 );
15729 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15730
15731 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15733 assert!(
15734 b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15735 "b2: {b2}"
15736 );
15737 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15738 assert!(
15739 !b2.contains(r#""templates""#),
15740 "b2 re-sent a template: {b2}"
15741 );
15742
15743 assert!(!writer.output.contains("serverOnly"));
15745 }
15746
15747 #[test]
15748 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15749 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15750 let handler = WebUIHandler::with_plugin(|| {
15751 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15752 });
15753 let mut writer = FlushTestWriter::default();
15754 handler
15755 .render_streaming(
15756 &protocol,
15757 &test_json!({ "a_count": 1, "b_count": 2 }),
15758 &RenderOptions::new("index.html", "/"),
15759 &mut writer,
15760 )
15761 .unwrap();
15762
15763 let segment = |index: usize| -> &str {
15764 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15765 };
15766 let checkpoint_styles = |checkpoint: &str| -> Value {
15767 let script = checkpoint
15768 .rfind(r#"<script type="application/json" data-webui-boundary"#)
15769 .unwrap();
15770 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15771 let payload_end =
15772 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15773 let record: Value =
15774 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15775 record[3]["componentStyles"].clone()
15776 };
15777
15778 let first = checkpoint_styles(segment(0));
15779 assert!(first["resources"]["comp-a"].is_object());
15780 assert!(first["resources"]["comp-b"].is_object());
15781 assert!(first["closures"]["index.html"].is_array());
15782 assert!(first["closures"]["comp-a"].is_array());
15783
15784 let second = checkpoint_styles(segment(1));
15785 assert_eq!(second["resources"], test_json!({}));
15786 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15787
15788 let repeated = checkpoint_styles(segment(2));
15789 assert_eq!(repeated["resources"], test_json!({}));
15790 assert_eq!(repeated["closures"], test_json!({}));
15791 for resource in ["comp-a", "comp-b"] {
15792 assert_eq!(
15793 [&first, &second, &repeated]
15794 .iter()
15795 .filter(|styles| {
15796 styles["resources"]
15797 .as_object()
15798 .is_some_and(|resources| resources.contains_key(resource))
15799 })
15800 .count(),
15801 1,
15802 "{resource} style metadata was serialized more than once"
15803 );
15804 }
15805 }
15806
15807 #[test]
15808 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15809 let entry = vec![
15810 WebUIFragment::raw("<html><head>"),
15811 structural_fragment("head_start"),
15812 structural_fragment("head_end"),
15813 WebUIFragment::raw("</head><body>"),
15814 structural_fragment("body_start"),
15815 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15816 WebUIFragment::raw("<comp-a"),
15817 structural_fragment("streaming_root:comp-a"),
15818 WebUIFragment::raw(">"),
15819 WebUIFragment::component("comp-a"),
15820 WebUIFragment::raw("</comp-a>"),
15821 WebUIFragment::boundary_end(0),
15822 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15823 WebUIFragment::raw("<comp-hidden"),
15824 structural_fragment("streaming_root:comp-hidden"),
15825 WebUIFragment::raw(">"),
15826 WebUIFragment::component("comp-hidden"),
15827 WebUIFragment::raw("</comp-hidden>"),
15828 WebUIFragment::boundary_end(1),
15829 structural_fragment("body_end"),
15830 WebUIFragment::raw("</body></html>"),
15831 ];
15832 let fragments = HashMap::from([
15833 (
15834 "index.html".to_string(),
15835 FragmentList {
15836 fragments: entry,
15837 contains_boundary: true,
15838 },
15839 ),
15840 (
15841 "comp-a".to_string(),
15842 FragmentList {
15843 fragments: vec![WebUIFragment::if_cond(
15844 ConditionExpr::identifier("show_hidden"),
15845 "hidden-if",
15846 )],
15847 contains_boundary: false,
15848 },
15849 ),
15850 (
15851 "hidden-if".to_string(),
15852 FragmentList {
15853 fragments: vec![WebUIFragment::component("comp-hidden")],
15854 contains_boundary: false,
15855 },
15856 ),
15857 (
15858 "comp-hidden".to_string(),
15859 FragmentList {
15860 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15861 contains_boundary: false,
15862 },
15863 ),
15864 ]);
15865 let mut document = WebUIProtocol::new(fragments);
15866 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15867 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15868 document.components.insert(
15869 "comp-a".to_string(),
15870 webui_protocol::ComponentData {
15871 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15872 hydration_mode: StateProjectionMode::Keys as i32,
15873 hydration_keys: vec!["show_hidden".to_string()],
15874 ..Default::default()
15875 },
15876 );
15877 document.components.insert(
15878 "comp-hidden".to_string(),
15879 webui_protocol::ComponentData {
15880 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15881 css: ".hidden{display:block}".to_string(),
15882 hydration_mode: StateProjectionMode::Keys as i32,
15883 hydration_keys: vec!["hidden_count".to_string()],
15884 ..Default::default()
15885 },
15886 );
15887 document.populate_style_closures(&["index.html"]);
15888 let protocol = Protocol::new(document);
15889 let handler = WebUIHandler::with_plugin(|| {
15890 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15891 });
15892 let mut writer = FlushTestWriter::default();
15893
15894 handler
15895 .render_streaming(
15896 &protocol,
15897 &test_json!({
15898 "show_hidden": false,
15899 "hidden_count": 7,
15900 "unrelated": "private",
15901 }),
15902 &RenderOptions::new("index.html", "/"),
15903 &mut writer,
15904 )
15905 .unwrap();
15906
15907 let boundary = |index: usize| -> &str {
15908 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15909 };
15910 let first = boundary(0);
15911 let second = boundary(1);
15912
15913 assert!(first.contains(r#""comp-a":"#), "first: {first}");
15914 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15915 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15916 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15917 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15918 assert!(
15919 first.contains(r#"<script type="importmap""#),
15920 "first: {first}"
15921 );
15922 assert!(
15923 !first.contains("unrelated"),
15924 "first leaked unrelated state: {first}"
15925 );
15926
15927 assert!(
15928 !second.contains(r#""templates""#),
15929 "second re-sent metadata: {second}"
15930 );
15931 assert!(
15932 !second.contains(r#"<script type="importmap""#),
15933 "second re-sent CSS: {second}"
15934 );
15935 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15936 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15937 assert_eq!(
15938 writer
15939 .output
15940 .matches(r#"<script type="importmap">"#)
15941 .count(),
15942 1,
15943 "import-map output: {}",
15944 writer.output
15945 );
15946 }
15947
15948 #[test]
15949 fn streaming_flush_count_is_boundaries_plus_one() {
15950 for &count in &[1usize, 3, 10, 100] {
15951 let hosts: Vec<&str> = (0..count)
15952 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15953 .collect();
15954 let protocol = disjoint_streaming_protocol(&hosts);
15955 let handler = WebUIHandler::with_plugin(|| {
15956 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15957 });
15958 let mut writer = FlushTestWriter::default();
15959 handler
15960 .render_streaming(
15961 &protocol,
15962 &test_json!({ "a_count": 1, "b_count": 2 }),
15963 &RenderOptions::new("index.html", "/"),
15964 &mut writer,
15965 )
15966 .unwrap();
15967 assert_eq!(
15968 writer.flushes.len(),
15969 count + 2,
15970 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15971 );
15972 }
15973 }
15974
15975 #[test]
15976 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15977 let mut fragments = HashMap::new();
15978 fragments.insert("comp-a".to_string(), FragmentList::default());
15979 fragments.insert("comp-b".to_string(), FragmentList::default());
15980 let mut protocol = WebUIProtocol::new(fragments);
15981 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15982 protocol.components.insert(
15983 "comp-a".to_string(),
15984 webui_protocol::ComponentData {
15985 hydration_mode: StateProjectionMode::Keys as i32,
15986 hydration_keys: vec!["a".to_string(), "shared".to_string()],
15987 ..Default::default()
15988 },
15989 );
15990 protocol.components.insert(
15991 "comp-b".to_string(),
15992 webui_protocol::ComponentData {
15993 hydration_mode: StateProjectionMode::Keys as i32,
15994 hydration_keys: vec!["b".to_string(), "shared".to_string()],
15995 ..Default::default()
15996 },
15997 );
15998
15999 let protocol = Protocol::new(protocol);
16000 let component_index = protocol.component_index();
16001 let reachability = protocol.component_reachability();
16002 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16003 assert!(!collect_hydration_key_ids_into(
16004 protocol.protocol(),
16005 reachability,
16006 [component_index["comp-a"]],
16007 &mut scratch,
16008 ));
16009 let keys = scratch
16010 .iter()
16011 .filter_map(|id| reachability.hydration_key(*id))
16012 .collect::<Vec<_>>();
16013 assert_eq!(keys, ["a", "shared"]);
16014 let pointer = scratch.as_ptr();
16015 let capacity = scratch.capacity();
16016
16017 assert!(!collect_hydration_key_ids_into(
16018 protocol.protocol(),
16019 reachability,
16020 [component_index["comp-b"]],
16021 &mut scratch,
16022 ));
16023 let keys = scratch
16024 .iter()
16025 .filter_map(|id| reachability.hydration_key(*id))
16026 .collect::<Vec<_>>();
16027 assert_eq!(keys, ["b", "shared"]);
16028 assert_eq!(scratch.as_ptr(), pointer);
16029 assert_eq!(scratch.capacity(), capacity);
16030 }
16031}