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() {
3943 Some(Value::String(s)) => {
3944 write_attr(
3945 context.writer,
3946 &attr.name,
3947 &crate::html_encode::encode_safe(s),
3948 )?;
3949 }
3950 Some(Value::Null) | None => {}
3951 Some(other) => {
3952 let mut scalar = ScalarBuffer::new();
3953 match format_plain_json_scalar(&mut scalar, other) {
3954 Some(rendered) => {
3955 write_attr(context.writer, &attr.name, rendered)?;
3956 }
3957 None => {
3958 let s = other.to_string();
3959 write_attr(
3960 context.writer,
3961 &attr.name,
3962 &crate::html_encode::encode_safe(&s),
3963 )?;
3964 }
3965 }
3966 }
3967 }
3968
3969 if context.collecting_component_attrs && !attr.attr_skip {
3970 if let Some(borrowed) = state_backed_value {
3971 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3972 remove_owned_component_attr(&mut context.component_attrs, name);
3973 context.component_borrowed_attrs.insert(name, borrowed);
3974 } else {
3975 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3976 context.component_borrowed_attrs.remove(name);
3977 context.component_attrs.insert(
3978 name.to_owned(),
3979 value
3980 .map(Cow::into_owned)
3981 .unwrap_or(Value::String(String::new())),
3982 );
3983 }
3984 }
3985 }
3986 }
3987
3988 Ok(())
3989 }
3990
3991 fn render_template_attr_value(
3993 &self,
3994 template_id: &str,
3995 target: Option<usize>,
3996 context: &WebUIProcessContext,
3997 ) -> Result<String> {
3998 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3999 return Err(HandlerError::MissingFragment(template_id.to_string()));
4000 };
4001 let fragments = context
4002 .render_fragments
4003 .list(index)
4004 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
4005 let mut raw_value = String::with_capacity(template_attr_capacity(fragments.fragments));
4006 for frag in fragments.fragments {
4007 match frag.fragment.as_ref() {
4008 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
4009 Some(Fragment::Signal(signal)) => {
4010 if let Some(value) = resolve_value_from_sources(
4011 &signal.value,
4012 &context.loop_vars,
4013 context.visible_loop_scope,
4014 LocalValueSources {
4015 owned: &context.local_vars,
4016 borrowed: &context.local_borrowed_vars,
4017 },
4018 context.state,
4019 ) {
4020 match value.as_ref() {
4021 Value::String(s) => raw_value.push_str(s),
4022 other => {
4023 let mut scalar = ScalarBuffer::new();
4024 match format_plain_json_scalar(&mut scalar, other) {
4025 Some(rendered) => raw_value.push_str(rendered),
4026 None => raw_value.push_str(&other.to_string()),
4027 }
4028 }
4029 }
4030 }
4031 }
4032 _ => {}
4033 }
4034 }
4035 Ok(raw_value)
4036 }
4037
4038 pub fn render<'a>(
4040 &self,
4041 protocol: &'a Protocol,
4042 state: &'a Value,
4043 options: &RenderOptions<'a>,
4044 writer: &'a mut dyn ResponseWriter,
4045 ) -> Result<()> {
4046 protocol.ensure_style_metadata()?;
4047 let document = protocol.protocol();
4048 let render_fragments = protocol.render_fragments().resolve(document);
4049 let Some(entry_index) = render_fragments.index(options.entry_id) else {
4050 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
4051 };
4052 let entry = render_fragments
4053 .list(entry_index)
4054 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
4055 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
4056 let has_document_head_boundary = !entry_owns_css_tree
4057 && !document.style_closures.is_empty()
4058 && entry.fragments.iter().any(|fragment| {
4059 matches!(
4060 fragment.fragment.as_ref(),
4061 Some(Fragment::Signal(signal))
4062 if structural_signal_value(signal) == Some("head_end")
4063 )
4064 });
4065 let doctype_split = (!entry_owns_css_tree
4066 && !has_document_head_boundary
4067 && !document.style_closures.is_empty())
4068 .then(|| {
4069 let Some(Fragment::Raw(raw)) = entry
4070 .fragments
4071 .first()
4072 .and_then(|fragment| fragment.fragment.as_ref())
4073 else {
4074 return None;
4075 };
4076 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
4077 })
4078 .flatten();
4079 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
4080 let mut context = WebUIProcessContext {
4081 protocol: document,
4082 render_fragments,
4083 component_asset_style_manifest,
4084 component_asset_style_links: protocol.component_asset_style_links(),
4085 state,
4086 writer,
4087 local_vars: ScopeMap::default(),
4088 local_borrowed_vars: BorrowedScope::default(),
4089 loop_vars: Vec::new(),
4090 visible_loop_scope: VisibleLoopScope::EMPTY,
4091 component_attrs: ScopeMap::default(),
4092 component_borrowed_attrs: BorrowedScope::default(),
4093 collecting_component_attrs: false,
4094 request_path: options.request_path,
4095 route_base: Cow::Borrowed("/"),
4096 rendered_components: HashSet::new(),
4097 plugin: self.plugin_factory.map(|f| f()),
4098 route_children: Cow::Borrowed(&[]),
4099 entry_id: options.entry_id,
4100 nonce: options.nonce.filter(|s| !s.is_empty()),
4103 head_inject: options.head_inject.filter(|s| !s.is_empty()),
4104 body_inject: options.body_inject.filter(|s| !s.is_empty()),
4105 state_inject: StateInject::resolve(state),
4106 head_end_emitted: false,
4107 body_start_emitted: false,
4108 component_asset_styles_emitted: false,
4109 component_index: protocol.component_index(),
4110 style_resource_index: protocol.style_resource_index(),
4111 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4112 style_chunk_index: protocol.protocol().style_chunk_index(),
4113 css_strategy: protocol.css_strategy(),
4114 body_end_emitted: false,
4115 route_index: protocol.route_index(),
4116 route_chain_index: 0,
4117 route_chain: None,
4118 route_document_style_targets: Vec::new(),
4119 reachable_components: None,
4120 streaming: None,
4121 json_scratch: Vec::new(),
4122 scope_pool: Vec::new(),
4123 document_style_resources: HashSet::new(),
4124 shadow_style_roots: Vec::new(),
4125 borrowed_scope_pool: Vec::new(),
4126 };
4127
4128 if entry_owns_css_tree {
4129 Self::push_shadow_style_root(options.entry_id, &mut context)?;
4130 }
4131
4132 let render_result = if let Some((first_raw, split)) = doctype_split {
4133 context.writer.write(&first_raw[..split])?;
4134 self.emit_component_style_closure(
4135 options.entry_id,
4136 StyleClosureInstall::Static,
4137 &mut context,
4138 )?;
4139 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4140 context.writer.write(&first_raw[split..])?;
4141 self.process_fragment_from(entry, 1, &mut context)
4142 } else {
4143 if !entry_owns_css_tree
4144 && !has_document_head_boundary
4145 && !document.style_closures.is_empty()
4146 {
4147 self.emit_component_style_closure(
4148 options.entry_id,
4149 StyleClosureInstall::Static,
4150 &mut context,
4151 )?;
4152 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4153 }
4154 self.process_fragment(entry, &mut context)
4155 };
4156
4157 if entry_owns_css_tree {
4158 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4159 }
4160 render_result?;
4161 writer.end()?;
4162
4163 Ok(())
4164 }
4165}
4166
4167impl Default for WebUIHandler {
4168 fn default() -> Self {
4169 Self::new()
4170 }
4171}
4172
4173const TEMPLATE_SIGNAL_CAPACITY_HINT: usize = 32;
4176
4177fn template_attr_capacity(fragments: &[WebUIFragment]) -> usize {
4181 fragments
4182 .iter()
4183 .map(|frag| match frag.fragment.as_ref() {
4184 Some(Fragment::Raw(raw)) => raw.value.len(),
4185 Some(Fragment::Signal(_)) => TEMPLATE_SIGNAL_CAPACITY_HINT,
4186 _ => 0,
4187 })
4188 .sum()
4189}
4190
4191fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4193 writer.write_attribute(name, value)
4194}
4195
4196#[cfg(test)]
4197fn handle(
4198 protocol: &WebUIProtocol,
4199 state: &Value,
4200 options: &RenderOptions<'_>,
4201 writer: &mut dyn ResponseWriter,
4202) -> Result<()> {
4203 let handler = WebUIHandler::new();
4204 handler.handle(protocol, state, options, writer)
4205}
4206
4207#[cfg(test)]
4208mod scalar_buffer_tests {
4209 use super::*;
4210
4211 #[test]
4214 fn plain_scalars_match_value_to_string() {
4215 let cases = [
4216 Value::Null,
4217 Value::Bool(true),
4218 Value::Bool(false),
4219 serde_json::json!(0),
4220 serde_json::json!(-1),
4221 serde_json::json!(42),
4222 serde_json::json!(i64::MIN),
4223 serde_json::json!(i64::MAX),
4224 serde_json::json!(u64::MAX),
4225 serde_json::json!(0.5),
4226 serde_json::json!(-0.0_f64),
4227 serde_json::json!(1.7976931348623157e308_f64),
4228 serde_json::json!(-1.7976931348623157e308_f64),
4229 serde_json::json!(f64::MIN_POSITIVE),
4230 ];
4231 for value in cases {
4232 let mut buffer = ScalarBuffer::new();
4233 let rendered = format_plain_json_scalar(&mut buffer, &value);
4234 assert_eq!(
4235 rendered,
4236 Some(value.to_string().as_str()),
4237 "scalar rendering diverged for {value:?}"
4238 );
4239 }
4240 }
4241
4242 #[test]
4245 fn non_plain_values_are_declined() {
4246 let cases = [
4247 serde_json::json!("<script>"),
4248 serde_json::json!([1, 2]),
4249 serde_json::json!({ "a": 1 }),
4250 ];
4251 for value in cases {
4252 let mut buffer = ScalarBuffer::new();
4253 assert_eq!(
4254 format_plain_json_scalar(&mut buffer, &value),
4255 None,
4256 "expected fallback for {value:?}"
4257 );
4258 }
4259 }
4260
4261 #[test]
4263 fn overflow_is_reported_instead_of_truncating() {
4264 let mut buffer = ScalarBuffer::new();
4265 assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4266 }
4267}
4268
4269#[cfg(test)]
4270mod tests {
4271 use super::*;
4272 use crate::streaming::STREAMING_MARKER;
4273 use std::cell::RefCell;
4274 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4275 use webui_protocol::{
4276 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4277 WebUIFragmentAttribute, WebUiFragmentRoute,
4278 };
4279 use webui_test_utils::test_json;
4280
4281 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4282 WebUIFragment::signal(
4283 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4284 true,
4285 )
4286 }
4287
4288 #[test]
4289 fn route_descent_borrows_protocol_children() {
4290 let routes = vec![WebUiFragmentRoute {
4291 children: vec![WebUiFragmentRoute {
4292 fragment_id: "child".to_string(),
4293 ..Default::default()
4294 }],
4295 ..Default::default()
4296 }];
4297 let mut level = RouteChildren::Borrowed(&routes);
4298
4299 let descended = descend_into(&mut level, 0);
4300
4301 let Cow::Borrowed(children) = descended else {
4302 panic!("protocol-owned route children should remain borrowed");
4303 };
4304 assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4305 }
4306
4307 #[test]
4308 fn route_descent_moves_parked_children() {
4309 let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4310 children: vec![WebUiFragmentRoute {
4311 fragment_id: "child".to_string(),
4312 ..Default::default()
4313 }],
4314 ..Default::default()
4315 }]);
4316
4317 let descended = descend_into(&mut level, 0);
4318
4319 let Cow::Owned(children) = descended else {
4320 panic!("parked route children must not borrow session-owned storage");
4321 };
4322 assert_eq!(children[0].fragment_id, "child");
4323 let Cow::Owned(source) = level else {
4324 panic!("the parked source level must stay owned");
4325 };
4326 assert!(
4327 source[0].children.is_empty(),
4328 "owned route children must move out instead of being cloned"
4329 );
4330 }
4331
4332 #[test]
4333 fn borrowed_collection_reuses_state_array() {
4334 let state = test_json!({
4335 "items": [
4336 {"name": "first"},
4337 {"name": "second"}
4338 ]
4339 });
4340 let local_vars = ScopeMap::default();
4341 let local_borrowed_vars = BorrowedScope::default();
4342 let loop_vars = Vec::new();
4343 let items = resolve_borrowed_collection(
4344 "items",
4345 &loop_vars,
4346 VisibleLoopScope::EMPTY,
4347 LocalValueSources {
4348 owned: &local_vars,
4349 borrowed: &local_borrowed_vars,
4350 },
4351 &state,
4352 )
4353 .unwrap_or_else(|| panic!("state array should be borrowed"));
4354 let state_items = state["items"]
4355 .as_array()
4356 .unwrap_or_else(|| panic!("fixture items should be an array"));
4357 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4358 }
4359
4360 #[test]
4361 fn owned_local_collection_keeps_precedence() {
4362 let state = test_json!({"items": [{"name": "global"}]});
4363 let local_vars =
4364 ScopeMap::from_iter([("items".to_string(), test_json!([{"name": "local"}]))]);
4365 let local_borrowed_vars = BorrowedScope::default();
4366 assert!(resolve_borrowed_collection(
4367 "items",
4368 &[],
4369 VisibleLoopScope::EMPTY,
4370 LocalValueSources {
4371 owned: &local_vars,
4372 borrowed: &local_borrowed_vars,
4373 },
4374 &state,
4375 )
4376 .is_none());
4377 }
4378
4379 #[test]
4380 fn borrowed_component_collection_reuses_state_array() {
4381 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4382 let contacts = &state["teams"][0]["contacts"];
4383 let mut local_borrowed_vars = BorrowedScope::default();
4384 local_borrowed_vars.insert("contacts", contacts);
4385 let local_vars = ScopeMap::default();
4386 let items = resolve_borrowed_collection(
4387 "contacts",
4388 &[],
4389 VisibleLoopScope::EMPTY,
4390 LocalValueSources {
4391 owned: &local_vars,
4392 borrowed: &local_borrowed_vars,
4393 },
4394 &state,
4395 )
4396 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4397 let state_items = contacts
4398 .as_array()
4399 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4400 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4401 }
4402
4403 #[test]
4404 fn nested_borrowed_collection_reuses_child_array() {
4405 let state = test_json!({
4406 "items": [{
4407 "children": [
4408 {"name": "first"},
4409 {"name": "second"}
4410 ]
4411 }]
4412 });
4413 let item = &state["items"][0];
4414 let loop_vars = vec![LoopBinding {
4415 name: "item",
4416 value: item,
4417 }];
4418 let local_vars = ScopeMap::default();
4419 let local_borrowed_vars = BorrowedScope::default();
4420 let children = resolve_borrowed_collection(
4421 "item.children",
4422 &loop_vars,
4423 VisibleLoopScope { start: 0, end: 1 },
4424 LocalValueSources {
4425 owned: &local_vars,
4426 borrowed: &local_borrowed_vars,
4427 },
4428 &state,
4429 )
4430 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4431 let state_children = item["children"]
4432 .as_array()
4433 .unwrap_or_else(|| panic!("fixture children should be an array"));
4434 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4435 }
4436
4437 #[test]
4438 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4439 let state = test_json!({
4440 "item": {"name": "global", "fallback": "global fallback"},
4441 "outer": {"name": "outer"},
4442 "inner": {"name": "inner"}
4443 });
4444 let loop_vars = vec![
4445 LoopBinding {
4446 name: "item",
4447 value: &state["outer"],
4448 },
4449 LoopBinding {
4450 name: "item",
4451 value: &state["inner"],
4452 },
4453 ];
4454 let local_vars = ScopeMap::default();
4455 let local_borrowed_vars = BorrowedScope::default();
4456 let sources = LocalValueSources {
4457 owned: &local_vars,
4458 borrowed: &local_borrowed_vars,
4459 };
4460
4461 assert_eq!(
4462 resolve_value_from_sources(
4463 "item.name",
4464 &loop_vars,
4465 VisibleLoopScope { start: 0, end: 2 },
4466 sources,
4467 &state,
4468 )
4469 .as_deref()
4470 .and_then(Value::as_str),
4471 Some("inner")
4472 );
4473 assert_eq!(
4474 resolve_value_from_sources(
4475 "item.fallback",
4476 &loop_vars,
4477 VisibleLoopScope { start: 0, end: 2 },
4478 sources,
4479 &state,
4480 )
4481 .as_deref()
4482 .and_then(Value::as_str),
4483 Some("global fallback")
4484 );
4485 assert_eq!(
4486 resolve_value_from_sources(
4487 "item.name",
4488 &loop_vars,
4489 VisibleLoopScope { start: 2, end: 2 },
4490 sources,
4491 &state,
4492 )
4493 .as_deref()
4494 .and_then(Value::as_str),
4495 Some("global")
4496 );
4497 }
4498
4499 #[test]
4500 fn state_backed_resolver_matches_general_borrowed_resolution() {
4501 let state = test_json!({
4502 "global": {"name": "global"},
4503 "inner": {"fallback": "global fallback"},
4504 "borrowed": {"name": "borrowed"},
4505 "items": [1, 2, 3]
4506 });
4507 let loop_values = test_json!({
4508 "outer": {"name": "outer"},
4509 "inner": {"name": "inner"}
4510 });
4511 let loop_vars = vec![
4512 LoopBinding {
4513 name: "outer",
4514 value: &loop_values["outer"],
4515 },
4516 LoopBinding {
4517 name: "inner",
4518 value: &loop_values["inner"],
4519 },
4520 ];
4521 let mut local_borrowed_vars = BorrowedScope::default();
4522 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4523 let local_vars =
4524 ScopeMap::from_iter([("owned".to_string(), test_json!({"name": "owned"}))]);
4525 let sources = LocalValueSources {
4526 owned: &local_vars,
4527 borrowed: &local_borrowed_vars,
4528 };
4529 let scope = VisibleLoopScope { start: 0, end: 2 };
4530
4531 for path in [
4532 "global.name",
4533 "inner.name",
4534 "inner.fallback",
4535 "outer.name",
4536 "borrowed.name",
4537 ] {
4538 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4539 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4540 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4541 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4542 let Cow::Borrowed(resolved) = resolved else {
4543 panic!("{path} should remain borrowed");
4544 };
4545 assert!(
4546 std::ptr::eq(state_backed, resolved),
4547 "{path} resolved from different sources"
4548 );
4549 }
4550
4551 assert!(
4552 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4553 "owned local values must use the general resolver"
4554 );
4555 assert!(
4556 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4557 .is_none(),
4558 "synthetic values must use the general resolver"
4559 );
4560 }
4561
4562 #[test]
4563 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4564 let values = [
4565 Value::String("a".to_string()),
4566 Value::String("b".to_string()),
4567 Value::String("c".to_string()),
4568 Value::String("d".to_string()),
4569 Value::String("e".to_string()),
4570 Value::String("f".to_string()),
4571 Value::String("replacement".to_string()),
4572 ];
4573 let mut scope = BorrowedScope::default();
4574 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4575 assert!(scope.insert(name, &values[index]).is_none());
4576 }
4577 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4578 assert_eq!(scope.overflow.len(), 2);
4579 assert_eq!(scope.get("a"), Some(&values[0]));
4580 assert_eq!(scope.get("f"), Some(&values[5]));
4581 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4582 assert_eq!(scope.get("a"), Some(&values[6]));
4583 assert_eq!(scope.remove("b"), Some(&values[1]));
4584 assert!(scope.get("b").is_none());
4585 assert_eq!(scope.get("f"), Some(&values[5]));
4586 scope.clear();
4587 assert!(scope.get("a").is_none());
4588 assert!(scope.overflow.is_empty());
4589 }
4590
4591 struct TestWriter {
4593 content: RefCell<String>,
4594 ended: RefCell<bool>,
4595 }
4596
4597 impl TestWriter {
4598 fn new() -> Self {
4599 Self {
4600 content: RefCell::new(String::new()),
4601 ended: RefCell::new(false),
4602 }
4603 }
4604
4605 fn get_content(&self) -> String {
4606 self.content.borrow().clone()
4607 }
4608
4609 fn is_ended(&self) -> bool {
4610 *self.ended.borrow()
4611 }
4612 }
4613
4614 impl ResponseWriter for TestWriter {
4615 fn write(&mut self, content: &str) -> Result<()> {
4616 self.content.borrow_mut().push_str(content);
4617 Ok(())
4618 }
4619
4620 fn end(&mut self) -> Result<()> {
4621 *self.ended.borrow_mut() = true;
4622 Ok(())
4623 }
4624 }
4625
4626 #[test]
4627 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4628 struct AttributeWriter {
4629 output: String,
4630 }
4631
4632 impl ResponseWriter for AttributeWriter {
4633 fn write(&mut self, content: &str) -> Result<()> {
4634 self.output.push_str(content);
4635 Ok(())
4636 }
4637
4638 crate::string_response_writer_methods!(output);
4639
4640 fn end(&mut self) -> Result<()> {
4641 Ok(())
4642 }
4643 }
4644
4645 let mut writer = AttributeWriter {
4646 output: String::new(),
4647 };
4648 write_attr(&mut writer, "data-id", "42")
4649 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4650 writer
4651 .write_boolean_attribute("disabled")
4652 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4653 assert_eq!(writer.output, " data-id=\"42\" disabled");
4654 }
4655
4656 #[test]
4657 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4658 let mut fragments = HashMap::new();
4659 fragments.insert(
4660 "index.html".to_string(),
4661 FragmentList {
4662 fragments: vec![
4663 WebUIFragment {
4664 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4665 name: ":data-title".to_string(),
4666 value: "source".to_string(),
4667 complex: true,
4668 attr_start: true,
4669 ..Default::default()
4670 })),
4671 },
4672 WebUIFragment::component("prepared-child"),
4673 ],
4674 contains_boundary: false,
4675 },
4676 );
4677 fragments.insert(
4678 "prepared-child".to_string(),
4679 FragmentList {
4680 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4681 contains_boundary: false,
4682 },
4683 );
4684 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4685 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4686 let entry = resolved
4687 .list_by_id("index.html")
4688 .unwrap_or_else(|| panic!("entry render plan should exist"));
4689 let child_index = protocol
4690 .render_fragments()
4691 .index("prepared-child")
4692 .unwrap_or_else(|| panic!("component target should be indexed"));
4693 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4694 assert_eq!(entry.target(1), Some(child_index));
4695 assert!(!entry.has_routes);
4696 let source_entry = protocol
4697 .protocol()
4698 .fragments
4699 .get("index.html")
4700 .unwrap_or_else(|| panic!("source entry should exist"));
4701 assert!(std::ptr::eq(
4702 entry.fragments.as_ptr(),
4703 source_entry.fragments.as_ptr()
4704 ));
4705
4706 let state = test_json!({"source": "Prepared"});
4707 let options = RenderOptions::new("index.html", "/");
4708 for handler in [
4709 WebUIHandler::new(),
4710 WebUIHandler::with_plugin(|| {
4711 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4712 }),
4713 ] {
4714 let mut writer = TestWriter::new();
4715 assert!(handler
4716 .render(&protocol, &state, &options, &mut writer)
4717 .is_ok());
4718 assert!(writer.get_content().contains("Prepared"));
4719 }
4720 }
4721
4722 #[test]
4723 fn prepared_graph_records_route_presence_per_fragment_list() {
4724 let mut fragments = HashMap::new();
4725 fragments.insert(
4726 "index.html".to_string(),
4727 FragmentList {
4728 fragments: vec![WebUIFragment::route("/", "dash-page")],
4729 contains_boundary: false,
4730 },
4731 );
4732 fragments.insert(
4733 "dash-page".to_string(),
4734 FragmentList {
4735 fragments: vec![WebUIFragment::raw("dashboard")],
4736 contains_boundary: false,
4737 },
4738 );
4739 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4740 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4741 let entry = resolved
4742 .list_by_id("index.html")
4743 .unwrap_or_else(|| panic!("entry render plan should exist"));
4744 let page = resolved
4745 .list_by_id("dash-page")
4746 .unwrap_or_else(|| panic!("page render plan should exist"));
4747 assert!(entry.has_routes);
4749 assert!(!page.has_routes);
4750 }
4751
4752 #[test]
4753 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4754 let names = [
4758 "data-title",
4759 ":data-title",
4760 "aria-labelledby",
4761 "aria-valuemax",
4762 "readonly",
4763 "tabindex",
4764 "class",
4765 "for",
4766 "maxlength",
4767 "novalidate",
4768 "already-camel",
4769 "trailing-",
4770 "-leading",
4771 "multi-part-attribute-name",
4772 "single",
4773 "",
4774 ];
4775 for name in names {
4776 let stripped = name.strip_prefix(':').unwrap_or(name);
4777 assert_eq!(
4778 component_attr_name(name),
4779 webui_protocol::attrs::attribute_to_camel(stripped),
4780 "prepared name diverged for `{name}`"
4781 );
4782 }
4783 }
4784
4785 #[test]
4786 fn prepared_component_prop_names_match_per_branch_stripping() {
4787 let complex = WebUIFragmentAttribute {
4791 name: "::data-title".to_string(),
4792 value: "source".to_string(),
4793 complex: true,
4794 ..Default::default()
4795 };
4796 assert_eq!(component_attr_source(&complex), ":data-title");
4797 assert_eq!(
4798 component_attr_name(component_attr_source(&complex)),
4799 "dataTitle"
4800 );
4801
4802 let raw = WebUIFragmentAttribute {
4804 raw_value: true,
4805 ..complex.clone()
4806 };
4807 assert_eq!(component_attr_source(&raw), "::data-title");
4808
4809 let templated = WebUIFragmentAttribute {
4811 template: "tpl".to_string(),
4812 ..complex.clone()
4813 };
4814 assert_eq!(component_attr_source(&templated), "::data-title");
4815
4816 let empty_value = WebUIFragmentAttribute {
4818 value: String::new(),
4819 ..complex.clone()
4820 };
4821 assert_eq!(component_attr_source(&empty_value), "::data-title");
4822
4823 let ordinary = WebUIFragmentAttribute {
4825 name: ":data-title".to_string(),
4826 ..complex
4827 };
4828 assert_eq!(component_attr_source(&ordinary), "data-title");
4829 assert_eq!(
4830 component_attr_name(component_attr_source(&ordinary)),
4831 "dataTitle"
4832 );
4833 }
4834
4835 #[test]
4836 fn render_slots_match_continuation_slots() {
4837 let mut fragments = HashMap::new();
4842 for id in [
4843 "index.html",
4844 "zeta-last",
4845 "alpha-first",
4846 "middle-one",
4847 "middle-two",
4848 ] {
4849 fragments.insert(
4850 id.to_string(),
4851 FragmentList {
4852 fragments: vec![WebUIFragment::raw(id)],
4853 contains_boundary: false,
4854 },
4855 );
4856 }
4857 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4858 let index = protocol.render_fragments();
4859 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4860 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4861 {
4862 let id = protocol
4863 .fragment_id(slot)
4864 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4865 assert_eq!(
4866 index.index(id),
4867 Some(slot as usize),
4868 "render slot must equal continuation slot for `{id}`"
4869 );
4870 assert_eq!(protocol.fragment_slot(id), Some(slot));
4871 }
4872 }
4873
4874 #[test]
4875 fn prepared_graph_reports_missing_fragments_without_panicking() {
4876 let mut fragments = HashMap::new();
4877 fragments.insert(
4878 "index.html".to_string(),
4879 FragmentList {
4880 fragments: vec![WebUIFragment::component("absent-child")],
4881 contains_boundary: false,
4882 },
4883 );
4884 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4885 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4886 let entry = resolved
4887 .list_by_id("index.html")
4888 .unwrap_or_else(|| panic!("entry render plan should exist"));
4889 assert_eq!(entry.target(0), None);
4892 assert!(resolved.list_by_id("absent-child").is_none());
4893 assert!(resolved.list(usize::MAX).is_none());
4894
4895 let state = test_json!({});
4896 let options = RenderOptions::new("index.html", "/");
4897 let mut writer = TestWriter::new();
4898 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4899 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4900 other => panic!("expected a missing fragment error, got {other:?}"),
4901 }
4902
4903 let mut writer = TestWriter::new();
4904 match WebUIHandler::new().render(
4905 &protocol,
4906 &state,
4907 &RenderOptions::new("absent-entry", "/"),
4908 &mut writer,
4909 ) {
4910 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4911 other => panic!("expected a missing entry error, got {other:?}"),
4912 }
4913 assert!(protocol.has_fragment("index.html"));
4916 assert!(!protocol.has_fragment("absent-entry"));
4917 }
4918
4919 #[test]
4920 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4921 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4922 let mut fragments = HashMap::with_capacity(list_count);
4923 for index in 0..list_count {
4924 fragments.insert(
4925 format!("fragment-{index}"),
4926 FragmentList {
4927 fragments: vec![WebUIFragment::raw("content")],
4928 contains_boundary: false,
4929 },
4930 );
4931 }
4932 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4933 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4934 assert!(resolved.cache[0].get().is_none());
4936 assert!(resolved.spilled.get().is_none());
4937 assert!(resolved.list(0).is_some());
4938 assert!(resolved.cache[0].get().is_some());
4939 assert!(resolved.spilled.get().is_none());
4941 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4944 let spilled = resolved
4945 .spilled
4946 .get()
4947 .expect("a slot past the inline cache should allocate the spill region");
4948 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4949 assert!(spilled[0].get().is_some());
4950 assert!(resolved.list(list_count - 1).is_some());
4951
4952 for (id, source) in &protocol.protocol().fragments {
4953 let prepared = resolved
4954 .list_by_id(id)
4955 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4956 assert!(std::ptr::eq(
4958 prepared.fragments.as_ptr(),
4959 source.fragments.as_ptr()
4960 ));
4961 }
4962 }
4963
4964 #[test]
4965 fn test_handle_raw() {
4966 let mut fragments = HashMap::new();
4968 fragments.insert(
4969 "index.html".to_string(),
4970 FragmentList {
4971 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4972 contains_boundary: false,
4973 },
4974 );
4975
4976 let protocol = WebUIProtocol::new(fragments);
4977 let state = test_json!({});
4978
4979 let mut writer = TestWriter::new();
4981
4982 assert!(
4984 handle(
4985 &protocol,
4986 &state,
4987 &RenderOptions::new("index.html", "/"),
4988 &mut writer
4989 )
4990 .is_ok(),
4991 "Failed to handle raw protocol"
4992 );
4993
4994 assert_eq!(writer.get_content(), "Hello, WebUI!");
4996 assert!(writer.is_ended());
4997 }
4998
4999 #[test]
5000 fn test_handle_signal() {
5001 let mut fragments = HashMap::new();
5003 fragments.insert(
5004 "index.html".to_string(),
5005 FragmentList {
5006 fragments: vec![
5007 WebUIFragment::raw("Hello, "),
5008 WebUIFragment::signal("name", false),
5009 WebUIFragment::raw("!"),
5010 ],
5011 contains_boundary: false,
5012 },
5013 );
5014
5015 let protocol = WebUIProtocol::new(fragments);
5016 let state = test_json!({"name": "WebUI"});
5017
5018 let mut writer = TestWriter::new();
5020
5021 assert!(
5023 handle(
5024 &protocol,
5025 &state,
5026 &RenderOptions::new("index.html", "/"),
5027 &mut writer
5028 )
5029 .is_ok(),
5030 "Failed to handle signal protocol"
5031 );
5032
5033 assert_eq!(writer.get_content(), "Hello, WebUI!");
5035 assert!(writer.is_ended());
5036 }
5037
5038 #[test]
5039 fn test_handle_for_loop() {
5040 let mut fragments = HashMap::new();
5042 fragments.insert(
5043 "index.html".to_string(),
5044 FragmentList {
5045 fragments: vec![
5046 WebUIFragment::raw("People: "),
5047 WebUIFragment::for_loop("person", "people", "person-item"),
5048 ],
5049 contains_boundary: false,
5050 },
5051 );
5052
5053 fragments.insert(
5054 "person-item".to_string(),
5055 FragmentList {
5056 fragments: vec![
5057 WebUIFragment::signal("person.name", false),
5058 WebUIFragment::raw(", "),
5059 ],
5060 contains_boundary: false,
5061 },
5062 );
5063
5064 let protocol = WebUIProtocol::new(fragments);
5065 let state = test_json!({
5066 "people": [
5067 {"name": "Alice"},
5068 {"name": "Bob"},
5069 {"name": "Charlie"}
5070 ]
5071 });
5072
5073 let mut writer = TestWriter::new();
5075
5076 assert!(
5078 handle(
5079 &protocol,
5080 &state,
5081 &RenderOptions::new("index.html", "/"),
5082 &mut writer
5083 )
5084 .is_ok(),
5085 "Failed to handle for loop protocol"
5086 );
5087
5088 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
5090 assert!(writer.is_ended());
5091 }
5092
5093 #[test]
5094 fn test_handle_if_condition() {
5095 let mut fragments = HashMap::new();
5097 fragments.insert(
5098 "index.html".to_string(),
5099 FragmentList {
5100 fragments: vec![
5101 WebUIFragment::raw("Status: "),
5102 WebUIFragment::if_cond(
5103 webui_protocol::ConditionExpr::identifier("isActive"),
5104 "active-content",
5105 ),
5106 WebUIFragment::raw("End"),
5107 ],
5108 contains_boundary: false,
5109 },
5110 );
5111
5112 fragments.insert(
5113 "active-content".to_string(),
5114 FragmentList {
5115 fragments: vec![WebUIFragment::raw("Active")],
5116 contains_boundary: false,
5117 },
5118 );
5119
5120 let protocol = WebUIProtocol::new(fragments);
5121
5122 let state_true = test_json!({"isActive": true});
5124 let mut writer_true = TestWriter::new();
5125 assert!(
5126 handle(
5127 &protocol,
5128 &state_true,
5129 &RenderOptions::new("index.html", "/"),
5130 &mut writer_true
5131 )
5132 .is_ok(),
5133 "Failed to handle if condition (true case)"
5134 );
5135 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5136 assert!(writer_true.is_ended());
5137
5138 let state_false = test_json!({"isActive": false});
5140 let mut writer_false = TestWriter::new();
5141 assert!(
5142 handle(
5143 &protocol,
5144 &state_false,
5145 &RenderOptions::new("index.html", "/"),
5146 &mut writer_false
5147 )
5148 .is_ok(),
5149 "Failed to handle if condition (false case)"
5150 );
5151 assert_eq!(writer_false.get_content(), "Status: End");
5152 assert!(writer_false.is_ended());
5153 }
5154
5155 #[test]
5156 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5157 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>"#;
5158 let mut parser = HtmlParser::new();
5159 parser
5160 .parse("index.html", source)
5161 .expect("parse missing-path condition fixture");
5162 let protocol = WebUIProtocol::new(parser.into_fragment_records());
5163 let state = test_json!({
5164 "items": [
5165 {"label": "Normal"},
5166 {"label": "Search", "searchPresentation": true}
5167 ]
5168 });
5169 let mut writer = TestWriter::new();
5170
5171 handle(
5172 &protocol,
5173 &state,
5174 &RenderOptions::new("index.html", "/"),
5175 &mut writer,
5176 )
5177 .expect("render missing-path condition fixture");
5178
5179 assert_eq!(
5180 writer.get_content(),
5181 "top-negated<span>Normal</span><mark>Search</mark>"
5182 );
5183 }
5184
5185 #[test]
5186 fn native_dynamic_attribute_does_not_leak_into_next_component() {
5187 let mut fragments = HashMap::new();
5188 fragments.insert(
5189 "index.html".to_string(),
5190 FragmentList {
5191 fragments: vec![
5192 WebUIFragment::raw("<div"),
5193 WebUIFragment {
5194 fragment: Some(web_ui_fragment::Fragment::Attribute(
5195 WebUIFragmentAttribute {
5196 name: "title".into(),
5197 value: "nativeTitle".into(),
5198 ..Default::default()
5199 },
5200 )),
5201 },
5202 WebUIFragment::raw("></div><my-comp>"),
5203 WebUIFragment::component("my-comp"),
5204 WebUIFragment::raw("</my-comp>"),
5205 ],
5206 contains_boundary: false,
5207 },
5208 );
5209 fragments.insert(
5210 "my-comp".to_string(),
5211 FragmentList {
5212 fragments: vec![WebUIFragment::signal("title", false)],
5213 contains_boundary: false,
5214 },
5215 );
5216 let protocol = WebUIProtocol::new(fragments);
5217 let state = test_json!({
5218 "nativeTitle": "native",
5219 "title": "global"
5220 });
5221 let mut writer = TestWriter::new();
5222 handle(
5223 &protocol,
5224 &state,
5225 &RenderOptions::new("index.html", "/"),
5226 &mut writer,
5227 )
5228 .unwrap_or_else(|error| panic!("render failed: {error}"));
5229 assert_eq!(
5230 writer.get_content(),
5231 "<div title=\"native\"></div><my-comp>global</my-comp>"
5232 );
5233 }
5234
5235 #[test]
5236 fn test_handle_component() {
5237 let mut fragments = HashMap::new();
5239 fragments.insert(
5240 "index.html".to_string(),
5241 FragmentList {
5242 fragments: vec![
5243 WebUIFragment::raw("Component: "),
5244 WebUIFragment::component("my-component"),
5245 ],
5246 contains_boundary: false,
5247 },
5248 );
5249
5250 fragments.insert(
5251 "my-component".to_string(),
5252 FragmentList {
5253 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5254 contains_boundary: false,
5255 },
5256 );
5257
5258 let protocol = WebUIProtocol::new(fragments);
5259 let state = test_json!({});
5260
5261 let mut writer = TestWriter::new();
5263
5264 assert!(
5266 handle(
5267 &protocol,
5268 &state,
5269 &RenderOptions::new("index.html", "/"),
5270 &mut writer
5271 )
5272 .is_ok(),
5273 "Failed to handle component protocol"
5274 );
5275
5276 assert_eq!(
5278 writer.get_content(),
5279 "Component: <div>Component Content</div>"
5280 );
5281 assert!(writer.is_ended());
5282 }
5283
5284 #[test]
5285 fn test_missing_fragment() {
5286 let mut fragments = HashMap::new();
5288 fragments.insert(
5289 "index.html".to_string(),
5290 FragmentList {
5291 fragments: vec![WebUIFragment::component("missing-component")],
5292 contains_boundary: false,
5293 },
5294 );
5295
5296 let protocol = WebUIProtocol::new(fragments);
5297 let state = test_json!({});
5298
5299 let mut writer = TestWriter::new();
5301
5302 let result = handle(
5304 &protocol,
5305 &state,
5306 &RenderOptions::new("index.html", "/"),
5307 &mut writer,
5308 );
5309
5310 assert!(result.is_err());
5312 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5313 assert_eq!(fragment_id, "missing-component");
5314 } else {
5315 panic!("Expected MissingFragment error");
5316 }
5317 }
5318
5319 #[test]
5320 fn test_missing_signal_renders_empty() {
5321 let mut fragments = HashMap::new();
5323 fragments.insert(
5324 "index.html".to_string(),
5325 FragmentList {
5326 fragments: vec![
5327 WebUIFragment::raw("Hello, "),
5328 WebUIFragment::signal("missing_field", false),
5329 WebUIFragment::raw("!"),
5330 ],
5331 contains_boundary: false,
5332 },
5333 );
5334
5335 let protocol = WebUIProtocol::new(fragments);
5336 let state = test_json!({});
5337
5338 let mut writer = TestWriter::new();
5339
5340 assert!(
5341 handle(
5342 &protocol,
5343 &state,
5344 &RenderOptions::new("index.html", "/"),
5345 &mut writer
5346 )
5347 .is_ok(),
5348 "Missing signal should not produce an error"
5349 );
5350
5351 assert_eq!(writer.get_content(), "Hello, !");
5352 assert!(writer.is_ended());
5353 }
5354
5355 #[test]
5358 fn test_boolean_attr_true() {
5359 let mut fragments = HashMap::new();
5360 fragments.insert(
5361 "index.html".to_string(),
5362 FragmentList {
5363 fragments: vec![
5364 WebUIFragment::raw("<button"),
5365 WebUIFragment::attribute_boolean(
5366 "disabled",
5367 ConditionExpr::identifier("isDisabled"),
5368 ),
5369 WebUIFragment::raw(">Click</button>"),
5370 ],
5371 contains_boundary: false,
5372 },
5373 );
5374 let protocol = WebUIProtocol::new(fragments);
5375 let state = test_json!({"isDisabled": true});
5376 let mut writer = TestWriter::new();
5377 handle(
5378 &protocol,
5379 &state,
5380 &RenderOptions::new("index.html", "/"),
5381 &mut writer,
5382 )
5383 .unwrap();
5384 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5385 }
5386
5387 #[test]
5388 fn test_boolean_attr_false() {
5389 let mut fragments = HashMap::new();
5390 fragments.insert(
5391 "index.html".to_string(),
5392 FragmentList {
5393 fragments: vec![
5394 WebUIFragment::raw("<button"),
5395 WebUIFragment::attribute_boolean(
5396 "disabled",
5397 ConditionExpr::identifier("isDisabled"),
5398 ),
5399 WebUIFragment::raw(">Click</button>"),
5400 ],
5401 contains_boundary: false,
5402 },
5403 );
5404 let protocol = WebUIProtocol::new(fragments);
5405 let state = test_json!({"isDisabled": false});
5406 let mut writer = TestWriter::new();
5407 handle(
5408 &protocol,
5409 &state,
5410 &RenderOptions::new("index.html", "/"),
5411 &mut writer,
5412 )
5413 .unwrap();
5414 assert_eq!(writer.get_content(), "<button>Click</button>");
5415 }
5416
5417 #[test]
5418 fn test_boolean_attr_missing() {
5419 let mut fragments = HashMap::new();
5420 fragments.insert(
5421 "index.html".to_string(),
5422 FragmentList {
5423 fragments: vec![
5424 WebUIFragment::raw("<input type=\"checkbox\""),
5425 WebUIFragment::attribute_boolean(
5426 "checked",
5427 ConditionExpr::identifier("checked"),
5428 ),
5429 WebUIFragment::raw(">"),
5430 ],
5431 contains_boundary: false,
5432 },
5433 );
5434 let protocol = WebUIProtocol::new(fragments);
5435 let state = test_json!({});
5436 let mut writer = TestWriter::new();
5437 handle(
5438 &protocol,
5439 &state,
5440 &RenderOptions::new("index.html", "/"),
5441 &mut writer,
5442 )
5443 .unwrap();
5444 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5445 }
5446
5447 #[test]
5448 fn test_boolean_attr_multiple() {
5449 let mut fragments = HashMap::new();
5450 fragments.insert(
5451 "index.html".to_string(),
5452 FragmentList {
5453 fragments: vec![
5454 WebUIFragment::raw("<input type=\"checkbox\""),
5455 WebUIFragment::attribute_boolean(
5456 "checked",
5457 ConditionExpr::identifier("checked"),
5458 ),
5459 WebUIFragment::attribute_boolean(
5460 "disabled",
5461 ConditionExpr::identifier("disabled"),
5462 ),
5463 WebUIFragment::raw(">"),
5464 ],
5465 contains_boundary: false,
5466 },
5467 );
5468 let protocol = WebUIProtocol::new(fragments);
5469 let state = test_json!({"checked": true, "disabled": false});
5470 let mut writer = TestWriter::new();
5471 handle(
5472 &protocol,
5473 &state,
5474 &RenderOptions::new("index.html", "/"),
5475 &mut writer,
5476 )
5477 .unwrap();
5478 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5479 }
5480
5481 #[test]
5484 fn test_attribute_with_value() {
5485 let mut fragments = HashMap::new();
5486 fragments.insert(
5487 "index.html".to_string(),
5488 FragmentList {
5489 fragments: vec![
5490 WebUIFragment::raw("<input"),
5491 WebUIFragment::attribute("value", "inputValue"),
5492 WebUIFragment::raw(">"),
5493 ],
5494 contains_boundary: false,
5495 },
5496 );
5497 let protocol = WebUIProtocol::new(fragments);
5498 let state = test_json!({"inputValue": "Hello"});
5499 let mut writer = TestWriter::new();
5500 handle(
5501 &protocol,
5502 &state,
5503 &RenderOptions::new("index.html", "/"),
5504 &mut writer,
5505 )
5506 .unwrap();
5507 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5508 }
5509
5510 #[test]
5511 fn test_dynamic_attribute_preserves_nullish_and_falsy_values() -> Result<()> {
5512 let mut fragments = HashMap::new();
5513 fragments.insert(
5514 "index.html".to_string(),
5515 FragmentList {
5516 fragments: vec![
5517 WebUIFragment::raw("<input"),
5518 WebUIFragment::attribute("pattern", "pattern"),
5519 WebUIFragment::raw(">"),
5520 ],
5521 contains_boundary: false,
5522 },
5523 );
5524 let protocol = WebUIProtocol::new(fragments);
5525
5526 for (state, expected) in [
5527 (test_json!({}), "<input>"),
5528 (test_json!({"pattern": null}), "<input>"),
5529 (test_json!({"pattern": ""}), "<input pattern=\"\">"),
5530 (test_json!({"pattern": 0}), "<input pattern=\"0\">"),
5531 (test_json!({"pattern": false}), "<input pattern=\"false\">"),
5532 (
5533 test_json!({"pattern": "[a-z]+"}),
5534 "<input pattern=\"[a-z]+\">",
5535 ),
5536 ] {
5537 let mut writer = TestWriter::new();
5538 handle(
5539 &protocol,
5540 &state,
5541 &RenderOptions::new("index.html", "/"),
5542 &mut writer,
5543 )?;
5544 assert_eq!(writer.get_content(), expected, "state: {state}");
5545 }
5546 Ok(())
5547 }
5548
5549 #[test]
5550 fn test_dynamic_attribute_omission_preserves_component_props() -> Result<()> {
5551 let mut fragments = HashMap::new();
5552 fragments.insert(
5553 "index.html".to_string(),
5554 FragmentList {
5555 fragments: vec![
5556 WebUIFragment::raw("<my-input"),
5557 WebUIFragment {
5558 fragment: Some(web_ui_fragment::Fragment::Attribute(
5559 WebUIFragmentAttribute {
5560 name: "pattern".into(),
5561 value: "constraint".into(),
5562 attr_start: true,
5563 ..Default::default()
5564 },
5565 )),
5566 },
5567 WebUIFragment::raw(">"),
5568 WebUIFragment::component("my-input"),
5569 WebUIFragment::raw("</my-input>"),
5570 ],
5571 contains_boundary: false,
5572 },
5573 );
5574 fragments.insert(
5575 "my-input".to_string(),
5576 FragmentList {
5577 fragments: vec![
5578 WebUIFragment::raw("<input"),
5579 WebUIFragment::attribute("pattern", "pattern"),
5580 WebUIFragment::raw(">"),
5581 WebUIFragment::signal("pattern", false),
5582 ],
5583 contains_boundary: false,
5584 },
5585 );
5586 let protocol = WebUIProtocol::new(fragments);
5587
5588 for (state, expected) in [
5589 (test_json!({}), "<my-input><input pattern=\"\"></my-input>"),
5590 (
5591 test_json!({"constraint": null}),
5592 "<my-input><input>null</my-input>",
5593 ),
5594 (
5595 test_json!({"pattern": "global"}),
5596 "<my-input><input pattern=\"\"></my-input>",
5597 ),
5598 (
5599 test_json!({"constraint": ""}),
5600 "<my-input pattern=\"\"><input pattern=\"\"></my-input>",
5601 ),
5602 (
5603 test_json!({"constraint": "[a-z]+"}),
5604 "<my-input pattern=\"[a-z]+\"><input pattern=\"[a-z]+\">[a-z]+</my-input>",
5605 ),
5606 ] {
5607 let mut writer = TestWriter::new();
5608 handle(
5609 &protocol,
5610 &state,
5611 &RenderOptions::new("index.html", "/"),
5612 &mut writer,
5613 )?;
5614 assert_eq!(writer.get_content(), expected, "state: {state}");
5615 }
5616 Ok(())
5617 }
5618
5619 #[test]
5620 fn test_attribute_with_falsy_numeric() {
5621 let mut fragments = HashMap::new();
5622 fragments.insert(
5623 "index.html".to_string(),
5624 FragmentList {
5625 fragments: vec![
5626 WebUIFragment::raw("<div name=\"test\""),
5627 WebUIFragment::attribute("handle", "number"),
5628 WebUIFragment::raw("></div>"),
5629 ],
5630 contains_boundary: false,
5631 },
5632 );
5633 let protocol = WebUIProtocol::new(fragments);
5634 let state = test_json!({"number": 0});
5635 let mut writer = TestWriter::new();
5636 handle(
5637 &protocol,
5638 &state,
5639 &RenderOptions::new("index.html", "/"),
5640 &mut writer,
5641 )
5642 .unwrap();
5643 assert_eq!(
5644 writer.get_content(),
5645 "<div name=\"test\" handle=\"0\"></div>"
5646 );
5647 }
5648
5649 #[test]
5652 fn test_attribute_array_value_is_escaped() {
5653 let mut fragments = HashMap::new();
5654 fragments.insert(
5655 "index.html".to_string(),
5656 FragmentList {
5657 fragments: vec![
5658 WebUIFragment::raw("<a"),
5659 WebUIFragment::attribute("href", "value"),
5660 WebUIFragment::raw(">demo</a>"),
5661 ],
5662 contains_boundary: false,
5663 },
5664 );
5665 let protocol = WebUIProtocol::new(fragments);
5666 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5667 let mut writer = TestWriter::new();
5668 handle(
5669 &protocol,
5670 &state,
5671 &RenderOptions::new("index.html", "/"),
5672 &mut writer,
5673 )
5674 .unwrap();
5675 let content = writer.get_content();
5676 assert!(
5679 content.contains("""),
5680 "Double quotes inside attribute value must be escaped: {content}"
5681 );
5682 assert_eq!(
5685 content.matches("=\"").count(),
5686 1,
5687 "Only one attribute assignment expected: {content}"
5688 );
5689 }
5690
5691 #[test]
5692 fn test_attribute_object_value_is_escaped() {
5693 let mut fragments = HashMap::new();
5694 fragments.insert(
5695 "index.html".to_string(),
5696 FragmentList {
5697 fragments: vec![
5698 WebUIFragment::raw("<div"),
5699 WebUIFragment::attribute("data-cfg", "cfg"),
5700 WebUIFragment::raw("></div>"),
5701 ],
5702 contains_boundary: false,
5703 },
5704 );
5705 let protocol = WebUIProtocol::new(fragments);
5706 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5707 let mut writer = TestWriter::new();
5708 handle(
5709 &protocol,
5710 &state,
5711 &RenderOptions::new("index.html", "/"),
5712 &mut writer,
5713 )
5714 .unwrap();
5715 let content = writer.get_content();
5716 assert!(
5717 content.contains("""),
5718 "Double quotes inside attribute value must be escaped: {content}"
5719 );
5720 assert_eq!(
5721 content.matches("=\"").count(),
5722 1,
5723 "Only one attribute assignment expected: {content}"
5724 );
5725 }
5726
5727 #[test]
5730 fn test_mixed_attribute_template() {
5731 let mut fragments = HashMap::new();
5732 fragments.insert(
5733 "index.html".to_string(),
5734 FragmentList {
5735 fragments: vec![
5736 WebUIFragment::raw("<input"),
5737 WebUIFragment::attribute_template("value", "attr-1"),
5738 WebUIFragment::raw(">"),
5739 ],
5740 contains_boundary: false,
5741 },
5742 );
5743 fragments.insert(
5744 "attr-1".to_string(),
5745 FragmentList {
5746 fragments: vec![
5747 WebUIFragment::raw("hello "),
5748 WebUIFragment::signal("item", false),
5749 ],
5750 contains_boundary: false,
5751 },
5752 );
5753 let protocol = WebUIProtocol::new(fragments);
5754 let state = test_json!({"item": "world"});
5755 let mut writer = TestWriter::new();
5756 handle(
5757 &protocol,
5758 &state,
5759 &RenderOptions::new("index.html", "/"),
5760 &mut writer,
5761 )
5762 .unwrap();
5763 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5764 }
5765
5766 #[test]
5769 fn test_raw_signal_not_escaped() {
5770 let mut fragments = HashMap::new();
5771 fragments.insert(
5772 "index.html".to_string(),
5773 FragmentList {
5774 fragments: vec![
5775 WebUIFragment::signal("html", false),
5776 WebUIFragment::signal("html", true),
5777 ],
5778 contains_boundary: false,
5779 },
5780 );
5781 let protocol = WebUIProtocol::new(fragments);
5782 let state = test_json!({"html": "<strong>hi</strong>"});
5783 let mut writer = TestWriter::new();
5784 handle(
5785 &protocol,
5786 &state,
5787 &RenderOptions::new("index.html", "/"),
5788 &mut writer,
5789 )
5790 .unwrap();
5791 assert_eq!(
5792 writer.get_content(),
5793 "<strong>hi</strong><strong>hi</strong>"
5794 );
5795 }
5796
5797 fn render_named_for(template: &str, state: &Value) -> String {
5800 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5801 parser.parse("index.html", template).unwrap();
5802 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5803 let mut writer = TestWriter::new();
5804 WebUIHandler::new()
5805 .render(
5806 &protocol,
5807 state,
5808 &RenderOptions::new("index.html", "/"),
5809 &mut writer,
5810 )
5811 .unwrap();
5812 writer.get_content()
5813 }
5814
5815 #[test]
5816 fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5817 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>"#;
5818 let state = test_json!({
5819 "child": { "name": "global" },
5820 "items": [
5821 { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5822 { "name": "Name", "children": [] },
5823 { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5824 ]
5825 });
5826 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>");
5827 }
5828
5829 #[test]
5830 fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5831 let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5832 let state = test_json!({
5833 "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5834 });
5835 assert_eq!(render_named_for(source, &state), "abbcca");
5836 assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5837 }
5838
5839 #[test]
5840 fn named_for_forward_and_mutual_references_follow_finite_data() {
5841 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>"#;
5842 let state = test_json!({
5843 "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5844 "empty": []
5845 });
5846 assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5847 }
5848
5849 #[test]
5850 fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5851 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>"#;
5852 let state = test_json!({
5853 "groups": [
5854 { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5855 { "name": "two", "items": [{ "name": "last" }] }
5856 ]
5857 });
5858 assert_eq!(
5859 render_named_for(source, &state),
5860 "one:<root>;one:&leaf;two:last;"
5861 );
5862 }
5863
5864 #[test]
5865 fn named_for_non_array_collection_returns_the_existing_type_error() {
5866 let mut parser = HtmlParser::new();
5867 parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5868 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5869 let mut writer = TestWriter::new();
5870 let error = WebUIHandler::new()
5871 .render(
5872 &protocol,
5873 &test_json!({"items": [{"children": 1}]}),
5874 &RenderOptions::new("index.html", "/"),
5875 &mut writer,
5876 )
5877 .unwrap_err();
5878 assert!(matches!(error, HandlerError::TypeError(_)));
5879 }
5880
5881 #[test]
5882 fn test_nested_for_loop() {
5883 let mut fragments = HashMap::new();
5884 fragments.insert(
5885 "index.html".to_string(),
5886 FragmentList {
5887 fragments: vec![
5888 WebUIFragment::raw("<div>"),
5889 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5890 WebUIFragment::raw("</div>"),
5891 ],
5892 contains_boundary: false,
5893 },
5894 );
5895 fragments.insert(
5896 "outer".to_string(),
5897 FragmentList {
5898 fragments: vec![
5899 WebUIFragment::raw("<div>"),
5900 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5901 WebUIFragment::raw("</div>"),
5902 ],
5903 contains_boundary: false,
5904 },
5905 );
5906 fragments.insert(
5907 "inner".to_string(),
5908 FragmentList {
5909 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5910 contains_boundary: false,
5911 },
5912 );
5913 let protocol = WebUIProtocol::new(fragments);
5914 let state = test_json!({
5915 "outerItems": [
5916 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5917 {"innerItems": [{"name": "C"}]}
5918 ]
5919 });
5920 let mut writer = TestWriter::new();
5921 handle(
5922 &protocol,
5923 &state,
5924 &RenderOptions::new("index.html", "/"),
5925 &mut writer,
5926 )
5927 .unwrap();
5928 assert_eq!(
5929 writer.get_content(),
5930 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5931 );
5932 }
5933
5934 #[test]
5935 fn test_nested_for_with_signals() {
5936 let mut fragments = HashMap::new();
5937 fragments.insert(
5938 "index.html".to_string(),
5939 FragmentList {
5940 fragments: vec![
5941 WebUIFragment::raw("<div>"),
5942 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5943 WebUIFragment::raw("</div>"),
5944 ],
5945 contains_boundary: false,
5946 },
5947 );
5948 fragments.insert(
5949 "outerTemplate".to_string(),
5950 FragmentList {
5951 fragments: vec![
5952 WebUIFragment::raw("<div>"),
5953 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5954 WebUIFragment::raw("</div>"),
5955 ],
5956 contains_boundary: false,
5957 },
5958 );
5959 fragments.insert(
5960 "innerTemplate".to_string(),
5961 FragmentList {
5962 fragments: vec![
5963 WebUIFragment::raw("<span>"),
5964 WebUIFragment::signal("innerItem.name", false),
5965 WebUIFragment::raw("</span>"),
5966 ],
5967 contains_boundary: false,
5968 },
5969 );
5970 let protocol = WebUIProtocol::new(fragments);
5971 let state = test_json!({
5972 "outerItems": [
5973 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5974 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5975 ]
5976 });
5977 let mut writer = TestWriter::new();
5978 handle(
5979 &protocol,
5980 &state,
5981 &RenderOptions::new("index.html", "/"),
5982 &mut writer,
5983 )
5984 .unwrap();
5985 assert_eq!(
5986 writer.get_content(),
5987 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5988 );
5989 }
5990
5991 #[test]
5992 fn test_nested_for_with_global_state() {
5993 let mut fragments = HashMap::new();
5994 fragments.insert(
5995 "index.html".to_string(),
5996 FragmentList {
5997 fragments: vec![
5998 WebUIFragment::raw("<div>"),
5999 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
6000 WebUIFragment::raw("</div>"),
6001 ],
6002 contains_boundary: false,
6003 },
6004 );
6005 fragments.insert(
6006 "outerTemplate".to_string(),
6007 FragmentList {
6008 fragments: vec![
6009 WebUIFragment::raw("<div>"),
6010 WebUIFragment::signal("globalOuter", false),
6011 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
6012 WebUIFragment::raw("</div>"),
6013 ],
6014 contains_boundary: false,
6015 },
6016 );
6017 fragments.insert(
6018 "innerTemplate".to_string(),
6019 FragmentList {
6020 fragments: vec![
6021 WebUIFragment::raw("<span>"),
6022 WebUIFragment::signal("innerItem.name", false),
6023 WebUIFragment::signal("globalInner", false),
6024 WebUIFragment::raw("</span>"),
6025 ],
6026 contains_boundary: false,
6027 },
6028 );
6029 let protocol = WebUIProtocol::new(fragments);
6030 let state = test_json!({
6031 "globalOuter": "GO",
6032 "globalInner": "GI",
6033 "outerItems": [
6034 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
6035 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
6036 ]
6037 });
6038 let mut writer = TestWriter::new();
6039 handle(
6040 &protocol,
6041 &state,
6042 &RenderOptions::new("index.html", "/"),
6043 &mut writer,
6044 )
6045 .unwrap();
6046 assert_eq!(
6047 writer.get_content(),
6048 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
6049 );
6050 }
6051
6052 #[test]
6055 fn test_if_in_for_uses_local_state() {
6056 let mut fragments = HashMap::new();
6057 fragments.insert(
6058 "index.html".to_string(),
6059 FragmentList {
6060 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6061 contains_boundary: false,
6062 },
6063 );
6064 fragments.insert(
6065 "item-tpl".to_string(),
6066 FragmentList {
6067 fragments: vec![WebUIFragment::if_cond(
6068 ConditionExpr::identifier("item.visible"),
6069 "visible-tpl",
6070 )],
6071 contains_boundary: false,
6072 },
6073 );
6074 fragments.insert(
6075 "visible-tpl".to_string(),
6076 FragmentList {
6077 fragments: vec![WebUIFragment::signal("item.name", false)],
6078 contains_boundary: false,
6079 },
6080 );
6081 let protocol = WebUIProtocol::new(fragments);
6082 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
6083 let mut writer = TestWriter::new();
6084 handle(
6085 &protocol,
6086 &state,
6087 &RenderOptions::new("index.html", "/"),
6088 &mut writer,
6089 )
6090 .unwrap();
6091 assert_eq!(writer.get_content(), "Show");
6092 }
6093
6094 #[test]
6095 fn test_for_if_local_overrides_global() {
6096 let mut fragments = HashMap::new();
6097 fragments.insert(
6098 "index.html".to_string(),
6099 FragmentList {
6100 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
6101 contains_boundary: false,
6102 },
6103 );
6104 fragments.insert(
6105 "item-tpl".to_string(),
6106 FragmentList {
6107 fragments: vec![WebUIFragment::if_cond(
6108 ConditionExpr::identifier("item.flag"),
6109 "show-tpl",
6110 )],
6111 contains_boundary: false,
6112 },
6113 );
6114 fragments.insert(
6115 "show-tpl".to_string(),
6116 FragmentList {
6117 fragments: vec![WebUIFragment::raw("yes")],
6118 contains_boundary: false,
6119 },
6120 );
6121 let protocol = WebUIProtocol::new(fragments);
6122 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
6124 let mut writer = TestWriter::new();
6125 handle(
6126 &protocol,
6127 &state,
6128 &RenderOptions::new("index.html", "/"),
6129 &mut writer,
6130 )
6131 .unwrap();
6132 assert_eq!(writer.get_content(), "yes");
6133 }
6134
6135 #[test]
6138 fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
6139 let cases = [
6140 (
6141 r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
6142 test_json!({}),
6143 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6144 ),
6145 (
6146 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6147 test_json!({"open": true}),
6148 r#"<dialog open aria-label="Canvas information"></dialog>"#,
6149 ),
6150 (
6151 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
6152 test_json!({"open": false}),
6153 r#"<dialog aria-label="Canvas information"></dialog>"#,
6154 ),
6155 (
6156 r#"<my-drawer aria-label=""></my-drawer>"#,
6157 test_json!({"ariaLabel": "Fallback"}),
6158 r#"<dialog aria-label=""></dialog>"#,
6159 ),
6160 (
6161 r#"<my-drawer open aria-label="Canvas & information"></my-drawer>"#,
6162 test_json!({}),
6163 r#"<dialog open aria-label="Canvas & information"></dialog>"#,
6164 ),
6165 ];
6166 for (input, state, expected) in cases {
6167 let mut parser = HtmlParser::with_options(DomStrategy::Light);
6168 parser
6169 .component_registry_mut()
6170 .register_component(ComponentRegistration::new(
6171 "my-drawer",
6172 r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
6173 None,
6174 true,
6175 ))
6176 .unwrap();
6177 parser.parse("index.html", input).unwrap();
6178 let protocol = WebUIProtocol::new(parser.into_fragment_records());
6179 let mut writer = TestWriter::new();
6180 handle(
6181 &protocol,
6182 &state,
6183 &RenderOptions::new("index.html", "/"),
6184 &mut writer,
6185 )
6186 .unwrap();
6187 assert!(
6188 writer.get_content().contains(expected),
6189 "{input}: {}",
6190 writer.get_content()
6191 );
6192 }
6193 }
6194
6195 #[test]
6196 fn test_component_attr_state_simple() {
6197 let mut fragments = HashMap::new();
6198 fragments.insert(
6199 "index.html".to_string(),
6200 FragmentList {
6201 fragments: vec![
6202 WebUIFragment::raw("<my-comp"),
6203 WebUIFragment {
6204 fragment: Some(web_ui_fragment::Fragment::Attribute(
6205 WebUIFragmentAttribute {
6206 name: "title".into(),
6207 value: "Attribute Title".into(),
6208 attr_start: true,
6209 raw_value: true,
6210 ..Default::default()
6211 },
6212 )),
6213 },
6214 WebUIFragment::raw(">"),
6215 WebUIFragment::component("my-comp"),
6216 WebUIFragment::raw("</my-comp>"),
6217 ],
6218
6219 contains_boundary: false,
6220 },
6221 );
6222 fragments.insert(
6223 "my-comp".to_string(),
6224 FragmentList {
6225 fragments: vec![
6226 WebUIFragment::raw("<span>"),
6227 WebUIFragment::signal("title", false),
6228 WebUIFragment::raw("</span>"),
6229 ],
6230 contains_boundary: false,
6231 },
6232 );
6233 let protocol = WebUIProtocol::new(fragments);
6234 let state = test_json!({"title": "Global Title"});
6235 let mut writer = TestWriter::new();
6236 handle(
6237 &protocol,
6238 &state,
6239 &RenderOptions::new("index.html", "/"),
6240 &mut writer,
6241 )
6242 .unwrap();
6243 assert_eq!(
6244 writer.get_content(),
6245 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6246 );
6247 }
6248
6249 #[test]
6250 fn test_component_attr_state_template() {
6251 let mut fragments = HashMap::new();
6252 fragments.insert(
6253 "index.html".to_string(),
6254 FragmentList {
6255 fragments: vec![
6256 WebUIFragment::raw("<my-comp"),
6257 WebUIFragment {
6258 fragment: Some(web_ui_fragment::Fragment::Attribute(
6259 WebUIFragmentAttribute {
6260 name: "title".into(),
6261 template: "title-attr".into(),
6262 attr_start: true,
6263 ..Default::default()
6264 },
6265 )),
6266 },
6267 WebUIFragment::raw(">"),
6268 WebUIFragment::component("my-comp"),
6269 WebUIFragment::raw("</my-comp>"),
6270 ],
6271
6272 contains_boundary: false,
6273 },
6274 );
6275 fragments.insert(
6276 "title-attr".to_string(),
6277 FragmentList {
6278 fragments: vec![
6279 WebUIFragment::raw("hello "),
6280 WebUIFragment::signal("item", false),
6281 ],
6282 contains_boundary: false,
6283 },
6284 );
6285 fragments.insert(
6286 "my-comp".to_string(),
6287 FragmentList {
6288 fragments: vec![
6289 WebUIFragment::raw("<span>"),
6290 WebUIFragment::signal("title", false),
6291 WebUIFragment::raw("</span>"),
6292 ],
6293 contains_boundary: false,
6294 },
6295 );
6296 let protocol = WebUIProtocol::new(fragments);
6297 let state = test_json!({"item": "<world>"});
6298 let mut writer = TestWriter::new();
6299 handle(
6300 &protocol,
6301 &state,
6302 &RenderOptions::new("index.html", "/"),
6303 &mut writer,
6304 )
6305 .unwrap();
6306 assert_eq!(
6307 writer.get_content(),
6308 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
6309 );
6310 }
6311
6312 #[test]
6313 fn test_component_attr_camel_case() {
6314 let mut fragments = HashMap::new();
6315 fragments.insert(
6316 "index.html".to_string(),
6317 FragmentList {
6318 fragments: vec![
6319 WebUIFragment::raw("<my-comp"),
6320 WebUIFragment {
6321 fragment: Some(web_ui_fragment::Fragment::Attribute(
6322 WebUIFragmentAttribute {
6323 name: "data-title".into(),
6324 template: "dt-attr".into(),
6325 attr_start: true,
6326 ..Default::default()
6327 },
6328 )),
6329 },
6330 WebUIFragment::raw(">"),
6331 WebUIFragment::component("my-comp"),
6332 WebUIFragment::raw("</my-comp>"),
6333 ],
6334
6335 contains_boundary: false,
6336 },
6337 );
6338 fragments.insert(
6339 "dt-attr".to_string(),
6340 FragmentList {
6341 fragments: vec![
6342 WebUIFragment::raw("prefix "),
6343 WebUIFragment::signal("item", false),
6344 ],
6345 contains_boundary: false,
6346 },
6347 );
6348 fragments.insert(
6349 "my-comp".to_string(),
6350 FragmentList {
6351 fragments: vec![
6352 WebUIFragment::raw("<span>"),
6353 WebUIFragment::signal("dataTitle", false),
6354 WebUIFragment::raw("</span>"),
6355 ],
6356 contains_boundary: false,
6357 },
6358 );
6359 let protocol = WebUIProtocol::new(fragments);
6360 let state = test_json!({"item": "a&b"});
6361 let mut writer = TestWriter::new();
6362 handle(
6363 &protocol,
6364 &state,
6365 &RenderOptions::new("index.html", "/"),
6366 &mut writer,
6367 )
6368 .unwrap();
6369 assert_eq!(
6370 writer.get_content(),
6371 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
6372 );
6373 }
6374
6375 #[test]
6376 fn test_component_complex_attr() {
6377 let mut fragments = HashMap::new();
6378 fragments.insert(
6379 "index.html".to_string(),
6380 FragmentList {
6381 fragments: vec![
6382 WebUIFragment::raw("<my-comp"),
6383 WebUIFragment {
6384 fragment: Some(web_ui_fragment::Fragment::Attribute(
6385 WebUIFragmentAttribute {
6386 name: ":item".into(),
6387 value: "complexItem".into(),
6388 attr_start: true,
6389 complex: true,
6390 ..Default::default()
6391 },
6392 )),
6393 },
6394 WebUIFragment::raw(">"),
6395 WebUIFragment::component("my-comp"),
6396 WebUIFragment::raw("</my-comp>"),
6397 ],
6398
6399 contains_boundary: false,
6400 },
6401 );
6402 fragments.insert(
6403 "my-comp".to_string(),
6404 FragmentList {
6405 fragments: vec![
6406 WebUIFragment::raw("<span>"),
6407 WebUIFragment::signal("item.foo", false),
6408 WebUIFragment::raw("</span><p>"),
6409 WebUIFragment::signal("item.bar", false),
6410 WebUIFragment::raw("</p>"),
6411 ],
6412 contains_boundary: false,
6413 },
6414 );
6415 let protocol = WebUIProtocol::new(fragments);
6416 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6417 let mut writer = TestWriter::new();
6418 handle(
6419 &protocol,
6420 &state,
6421 &RenderOptions::new("index.html", "/"),
6422 &mut writer,
6423 )
6424 .unwrap();
6425 assert_eq!(
6426 writer.get_content(),
6427 "<my-comp><span>1</span><p>true</p></my-comp>"
6428 );
6429 }
6430
6431 #[test]
6432 fn test_component_no_parent_pollution() {
6433 let mut fragments = HashMap::new();
6434 fragments.insert(
6435 "index.html".to_string(),
6436 FragmentList {
6437 fragments: vec![
6438 WebUIFragment::raw("<parent"),
6439 WebUIFragment {
6440 fragment: Some(web_ui_fragment::Fragment::Attribute(
6441 WebUIFragmentAttribute {
6442 name: "var".into(),
6443 value: "var".into(),
6444 attr_start: true,
6445 ..Default::default()
6446 },
6447 )),
6448 },
6449 WebUIFragment::raw(">"),
6450 WebUIFragment::component("parent"),
6451 WebUIFragment::raw("</parent>"),
6452 ],
6453
6454 contains_boundary: false,
6455 },
6456 );
6457 fragments.insert(
6458 "parent".to_string(),
6459 FragmentList {
6460 fragments: vec![
6461 WebUIFragment::raw("Before: "),
6462 WebUIFragment::signal("var", false),
6463 WebUIFragment::raw("<child foo"),
6464 WebUIFragment {
6465 fragment: Some(web_ui_fragment::Fragment::Attribute(
6466 WebUIFragmentAttribute {
6467 name: "var".into(),
6468 value: "replaced".into(),
6469 raw_value: true,
6470 ..Default::default()
6471 },
6472 )),
6473 },
6474 WebUIFragment::raw(">"),
6475 WebUIFragment::component("child"),
6476 WebUIFragment::raw("Label</child>After: "),
6477 WebUIFragment::signal("var", false),
6478 ],
6479
6480 contains_boundary: false,
6481 },
6482 );
6483 fragments.insert(
6484 "child".to_string(),
6485 FragmentList {
6486 fragments: vec![],
6487 contains_boundary: false,
6488 },
6489 );
6490 let protocol = WebUIProtocol::new(fragments);
6491 let state = test_json!({"var": "original<&"});
6492 let mut writer = TestWriter::new();
6493 handle(
6494 &protocol,
6495 &state,
6496 &RenderOptions::new("index.html", "/"),
6497 &mut writer,
6498 )
6499 .unwrap();
6500 assert_eq!(
6501 writer.get_content(),
6502 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
6503 );
6504 }
6505
6506 #[test]
6507 fn test_component_boolean_attr_state() {
6508 let mut fragments = HashMap::new();
6509 fragments.insert(
6510 "index.html".to_string(),
6511 FragmentList {
6512 fragments: vec![
6513 WebUIFragment::raw("<my-comp"),
6514 WebUIFragment {
6515 fragment: Some(web_ui_fragment::Fragment::Attribute(
6516 WebUIFragmentAttribute {
6517 name: "disabled".into(),
6518 attr_start: true,
6519 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6520 ..Default::default()
6521 },
6522 )),
6523 },
6524 WebUIFragment::raw(">"),
6525 WebUIFragment::component("my-comp"),
6526 WebUIFragment::raw("</my-comp>"),
6527 ],
6528
6529 contains_boundary: false,
6530 },
6531 );
6532 fragments.insert(
6533 "my-comp".to_string(),
6534 FragmentList {
6535 fragments: vec![WebUIFragment::if_cond(
6536 ConditionExpr::identifier("disabled"),
6537 "show",
6538 )],
6539 contains_boundary: false,
6540 },
6541 );
6542 fragments.insert(
6543 "show".to_string(),
6544 FragmentList {
6545 fragments: vec![WebUIFragment::raw("disabled!")],
6546 contains_boundary: false,
6547 },
6548 );
6549 let protocol = WebUIProtocol::new(fragments);
6550 let state = test_json!({"isDisabled": true});
6551 let mut writer = TestWriter::new();
6552 handle(
6553 &protocol,
6554 &state,
6555 &RenderOptions::new("index.html", "/"),
6556 &mut writer,
6557 )
6558 .unwrap();
6559 assert_eq!(
6560 writer.get_content(),
6561 "<my-comp disabled>disabled!</my-comp>"
6562 );
6563 }
6564
6565 fn render_signal(value: &str) -> String {
6569 let mut fragments = HashMap::new();
6570 fragments.insert(
6571 "index.html".to_string(),
6572 FragmentList {
6573 fragments: vec![WebUIFragment::signal("v", false)],
6574 contains_boundary: false,
6575 },
6576 );
6577 let protocol = WebUIProtocol::new(fragments);
6578 let state = test_json!({"v": value});
6579 let mut writer = TestWriter::new();
6580 handle(
6581 &protocol,
6582 &state,
6583 &RenderOptions::new("index.html", "/"),
6584 &mut writer,
6585 )
6586 .unwrap();
6587 writer.get_content()
6588 }
6589
6590 #[test]
6591 fn test_escape_ampersand() {
6592 assert_eq!(render_signal("&"), "&");
6593 }
6594
6595 #[test]
6596 fn test_escape_less_than() {
6597 assert_eq!(render_signal("<"), "<");
6598 }
6599
6600 #[test]
6601 fn test_escape_greater_than() {
6602 assert_eq!(render_signal(">"), ">");
6603 }
6604
6605 #[test]
6606 fn test_escape_double_quote() {
6607 assert_eq!(render_signal("\""), """);
6608 }
6609
6610 #[test]
6611 fn test_escape_single_quote() {
6612 let result = render_signal("'");
6614 assert_eq!(
6615 result, "'",
6616 "Expected ' for single quote, got: {result}"
6617 );
6618 }
6619
6620 #[test]
6621 fn test_escape_multiple_special_chars() {
6622 let result = render_signal("<script>alert('xss');</script>");
6623 assert!(
6624 result.contains("<") && result.contains(">"),
6625 "Expected escaped HTML, got: {}",
6626 result
6627 );
6628 assert!(
6629 !result.contains("<script>"),
6630 "Should not contain raw <script> tag"
6631 );
6632 }
6633
6634 #[test]
6635 fn test_escape_no_special_chars() {
6636 assert_eq!(render_signal("Hello World"), "Hello World");
6637 }
6638
6639 #[test]
6640 fn test_escape_empty_string() {
6641 assert_eq!(render_signal(""), "");
6642 }
6643
6644 #[test]
6645 fn test_escape_special_at_beginning() {
6646 let result = render_signal("<Hello");
6647 assert!(
6648 result.starts_with("<"),
6649 "Expected < at start, got: {}",
6650 result
6651 );
6652 }
6653
6654 #[test]
6655 fn test_escape_special_at_end() {
6656 let result = render_signal("Hello>");
6657 assert!(
6658 result.ends_with(">"),
6659 "Expected > at end, got: {}",
6660 result
6661 );
6662 }
6663
6664 #[test]
6665 fn test_escape_special_in_middle() {
6666 let result = render_signal("Hel&lo");
6667 assert!(
6668 result.contains("&"),
6669 "Expected & in middle, got: {}",
6670 result
6671 );
6672 }
6673
6674 #[test]
6677 fn test_boolean_attr_truthy_values() {
6678 {
6680 let mut fragments = HashMap::new();
6681 fragments.insert(
6682 "index.html".to_string(),
6683 FragmentList {
6684 fragments: vec![
6685 WebUIFragment::raw("<input"),
6686 WebUIFragment::attribute_boolean(
6687 "checked",
6688 ConditionExpr::identifier("checked"),
6689 ),
6690 WebUIFragment::raw(">"),
6691 ],
6692 contains_boundary: false,
6693 },
6694 );
6695 let protocol = WebUIProtocol::new(fragments);
6696 let state = test_json!({"checked": 1});
6697 let mut writer = TestWriter::new();
6698 handle(
6699 &protocol,
6700 &state,
6701 &RenderOptions::new("index.html", "/"),
6702 &mut writer,
6703 )
6704 .unwrap();
6705 assert_eq!(writer.get_content(), "<input checked>");
6706 }
6707 {
6709 let mut fragments = HashMap::new();
6710 fragments.insert(
6711 "index.html".to_string(),
6712 FragmentList {
6713 fragments: vec![
6714 WebUIFragment::raw("<input"),
6715 WebUIFragment::attribute_boolean(
6716 "checked",
6717 ConditionExpr::identifier("checked"),
6718 ),
6719 WebUIFragment::raw(">"),
6720 ],
6721 contains_boundary: false,
6722 },
6723 );
6724 let protocol = WebUIProtocol::new(fragments);
6725 let state = test_json!({"checked": "yes"});
6726 let mut writer = TestWriter::new();
6727 handle(
6728 &protocol,
6729 &state,
6730 &RenderOptions::new("index.html", "/"),
6731 &mut writer,
6732 )
6733 .unwrap();
6734 assert_eq!(writer.get_content(), "<input checked>");
6735 }
6736 {
6738 let mut fragments = HashMap::new();
6739 fragments.insert(
6740 "index.html".to_string(),
6741 FragmentList {
6742 fragments: vec![
6743 WebUIFragment::raw("<input"),
6744 WebUIFragment::attribute_boolean(
6745 "checked",
6746 ConditionExpr::identifier("checked"),
6747 ),
6748 WebUIFragment::raw(">"),
6749 ],
6750 contains_boundary: false,
6751 },
6752 );
6753 let protocol = WebUIProtocol::new(fragments);
6754 let state = test_json!({"checked": {}});
6755 let mut writer = TestWriter::new();
6756 handle(
6757 &protocol,
6758 &state,
6759 &RenderOptions::new("index.html", "/"),
6760 &mut writer,
6761 )
6762 .unwrap();
6763 assert_eq!(writer.get_content(), "<input>");
6765 }
6766 {
6768 let mut fragments = HashMap::new();
6769 fragments.insert(
6770 "index.html".to_string(),
6771 FragmentList {
6772 fragments: vec![
6773 WebUIFragment::raw("<input"),
6774 WebUIFragment::attribute_boolean(
6775 "checked",
6776 ConditionExpr::identifier("checked"),
6777 ),
6778 WebUIFragment::raw(">"),
6779 ],
6780 contains_boundary: false,
6781 },
6782 );
6783 let protocol = WebUIProtocol::new(fragments);
6784 let state = test_json!({"checked": "false"});
6785 let mut writer = TestWriter::new();
6786 handle(
6787 &protocol,
6788 &state,
6789 &RenderOptions::new("index.html", "/"),
6790 &mut writer,
6791 )
6792 .unwrap();
6793 assert_eq!(writer.get_content(), "<input checked>");
6794 }
6795 }
6796
6797 #[test]
6798 fn test_boolean_attr_falsy_values() {
6799 {
6801 let mut fragments = HashMap::new();
6802 fragments.insert(
6803 "index.html".to_string(),
6804 FragmentList {
6805 fragments: vec![
6806 WebUIFragment::raw("<input"),
6807 WebUIFragment::attribute_boolean(
6808 "checked",
6809 ConditionExpr::identifier("checked"),
6810 ),
6811 WebUIFragment::raw(">"),
6812 ],
6813 contains_boundary: false,
6814 },
6815 );
6816 let protocol = WebUIProtocol::new(fragments);
6817 let state = test_json!({"checked": 0});
6818 let mut writer = TestWriter::new();
6819 handle(
6820 &protocol,
6821 &state,
6822 &RenderOptions::new("index.html", "/"),
6823 &mut writer,
6824 )
6825 .unwrap();
6826 assert_eq!(writer.get_content(), "<input>");
6827 }
6828 {
6830 let mut fragments = HashMap::new();
6831 fragments.insert(
6832 "index.html".to_string(),
6833 FragmentList {
6834 fragments: vec![
6835 WebUIFragment::raw("<input"),
6836 WebUIFragment::attribute_boolean(
6837 "checked",
6838 ConditionExpr::identifier("checked"),
6839 ),
6840 WebUIFragment::raw(">"),
6841 ],
6842 contains_boundary: false,
6843 },
6844 );
6845 let protocol = WebUIProtocol::new(fragments);
6846 let state = test_json!({"checked": ""});
6847 let mut writer = TestWriter::new();
6848 handle(
6849 &protocol,
6850 &state,
6851 &RenderOptions::new("index.html", "/"),
6852 &mut writer,
6853 )
6854 .unwrap();
6855 assert_eq!(writer.get_content(), "<input>");
6856 }
6857 {
6859 let mut fragments = HashMap::new();
6860 fragments.insert(
6861 "index.html".to_string(),
6862 FragmentList {
6863 fragments: vec![
6864 WebUIFragment::raw("<input"),
6865 WebUIFragment::attribute_boolean(
6866 "checked",
6867 ConditionExpr::identifier("checked"),
6868 ),
6869 WebUIFragment::raw(">"),
6870 ],
6871 contains_boundary: false,
6872 },
6873 );
6874 let protocol = WebUIProtocol::new(fragments);
6875 let state = test_json!({"checked": false});
6876 let mut writer = TestWriter::new();
6877 handle(
6878 &protocol,
6879 &state,
6880 &RenderOptions::new("index.html", "/"),
6881 &mut writer,
6882 )
6883 .unwrap();
6884 assert_eq!(writer.get_content(), "<input>");
6885 }
6886 {
6888 let mut fragments = HashMap::new();
6889 fragments.insert(
6890 "index.html".to_string(),
6891 FragmentList {
6892 fragments: vec![
6893 WebUIFragment::raw("<input"),
6894 WebUIFragment::attribute_boolean(
6895 "checked",
6896 ConditionExpr::identifier("checked"),
6897 ),
6898 WebUIFragment::raw(">"),
6899 ],
6900 contains_boundary: false,
6901 },
6902 );
6903 let protocol = WebUIProtocol::new(fragments);
6904 let state = test_json!({});
6905 let mut writer = TestWriter::new();
6906 handle(
6907 &protocol,
6908 &state,
6909 &RenderOptions::new("index.html", "/"),
6910 &mut writer,
6911 )
6912 .unwrap();
6913 assert_eq!(writer.get_content(), "<input>");
6914 }
6915 }
6916
6917 #[test]
6918 fn test_boolean_attr_expression_true() {
6919 let mut fragments = HashMap::new();
6920 fragments.insert(
6921 "index.html".to_string(),
6922 FragmentList {
6923 fragments: vec![
6924 WebUIFragment::raw("<button"),
6925 WebUIFragment::attribute_boolean(
6926 "disabled",
6927 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6928 ),
6929 WebUIFragment::raw(">Click</button>"),
6930 ],
6931 contains_boundary: false,
6932 },
6933 );
6934 let protocol = WebUIProtocol::new(fragments);
6935 let state = test_json!({"itemCount": 5});
6936 let mut writer = TestWriter::new();
6937 handle(
6938 &protocol,
6939 &state,
6940 &RenderOptions::new("index.html", "/"),
6941 &mut writer,
6942 )
6943 .unwrap();
6944 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6945 }
6946
6947 #[test]
6948 fn test_boolean_attr_expression_false() {
6949 let mut fragments = HashMap::new();
6950 fragments.insert(
6951 "index.html".to_string(),
6952 FragmentList {
6953 fragments: vec![
6954 WebUIFragment::raw("<button"),
6955 WebUIFragment::attribute_boolean(
6956 "disabled",
6957 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6958 ),
6959 WebUIFragment::raw(">Click</button>"),
6960 ],
6961 contains_boundary: false,
6962 },
6963 );
6964 let protocol = WebUIProtocol::new(fragments);
6965 let state = test_json!({"itemCount": 3});
6966 let mut writer = TestWriter::new();
6967 handle(
6968 &protocol,
6969 &state,
6970 &RenderOptions::new("index.html", "/"),
6971 &mut writer,
6972 )
6973 .unwrap();
6974 assert_eq!(writer.get_content(), "<button>Click</button>");
6975 }
6976
6977 #[test]
6980 fn test_nested_component_attr_capture() {
6981 let mut fragments = HashMap::new();
6982 fragments.insert(
6983 "index.html".to_string(),
6984 FragmentList {
6985 fragments: vec![
6986 WebUIFragment::raw("<parent-component"),
6987 WebUIFragment {
6988 fragment: Some(web_ui_fragment::Fragment::Attribute(
6989 WebUIFragmentAttribute {
6990 name: "title".into(),
6991 template: "parent-title".into(),
6992 attr_start: true,
6993 ..Default::default()
6994 },
6995 )),
6996 },
6997 WebUIFragment::raw(">"),
6998 WebUIFragment::component("parent-component"),
6999 WebUIFragment::raw("</parent-component>"),
7000 ],
7001
7002 contains_boundary: false,
7003 },
7004 );
7005 fragments.insert(
7006 "parent-title".to_string(),
7007 FragmentList {
7008 fragments: vec![
7009 WebUIFragment::raw("Hello "),
7010 WebUIFragment::signal("who", false),
7011 ],
7012 contains_boundary: false,
7013 },
7014 );
7015 fragments.insert(
7016 "parent-component".to_string(),
7017 FragmentList {
7018 fragments: vec![
7019 WebUIFragment::raw("<child-component"),
7020 WebUIFragment {
7021 fragment: Some(web_ui_fragment::Fragment::Attribute(
7022 WebUIFragmentAttribute {
7023 name: "title".into(),
7024 template: "child-title".into(),
7025 attr_start: true,
7026 ..Default::default()
7027 },
7028 )),
7029 },
7030 WebUIFragment::raw(">"),
7031 WebUIFragment::component("child-component"),
7032 WebUIFragment::raw("</child-component>"),
7033 ],
7034
7035 contains_boundary: false,
7036 },
7037 );
7038 fragments.insert(
7039 "child-title".to_string(),
7040 FragmentList {
7041 fragments: vec![
7042 WebUIFragment::raw("Child of "),
7043 WebUIFragment::signal("title", false),
7044 ],
7045 contains_boundary: false,
7046 },
7047 );
7048 fragments.insert(
7049 "child-component".to_string(),
7050 FragmentList {
7051 fragments: vec![
7052 WebUIFragment::raw("<span>"),
7053 WebUIFragment::signal("title", false),
7054 WebUIFragment::raw("</span>"),
7055 ],
7056 contains_boundary: false,
7057 },
7058 );
7059 let protocol = WebUIProtocol::new(fragments);
7060 let state = test_json!({"who": "<world>"});
7061 let mut writer = TestWriter::new();
7062 handle(
7063 &protocol,
7064 &state,
7065 &RenderOptions::new("index.html", "/"),
7066 &mut writer,
7067 )
7068 .unwrap();
7069 assert_eq!(
7070 writer.get_content(),
7071 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
7072 );
7073 }
7074
7075 #[test]
7076 fn test_grandchild_attr_propagation() {
7077 let mut fragments = HashMap::new();
7078 fragments.insert(
7079 "index.html".to_string(),
7080 FragmentList {
7081 fragments: vec![
7082 WebUIFragment::raw("<parent-component"),
7083 WebUIFragment {
7084 fragment: Some(web_ui_fragment::Fragment::Attribute(
7085 WebUIFragmentAttribute {
7086 name: "title".into(),
7087 template: "p-title".into(),
7088 attr_start: true,
7089 ..Default::default()
7090 },
7091 )),
7092 },
7093 WebUIFragment::raw(">"),
7094 WebUIFragment::component("parent-component"),
7095 WebUIFragment::raw("</parent-component>"),
7096 ],
7097
7098 contains_boundary: false,
7099 },
7100 );
7101 fragments.insert(
7102 "p-title".to_string(),
7103 FragmentList {
7104 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
7105 contains_boundary: false,
7106 },
7107 );
7108 fragments.insert(
7109 "parent-component".to_string(),
7110 FragmentList {
7111 fragments: vec![
7112 WebUIFragment::raw("<child-component"),
7113 WebUIFragment {
7114 fragment: Some(web_ui_fragment::Fragment::Attribute(
7115 WebUIFragmentAttribute {
7116 name: "title".into(),
7117 template: "c-title".into(),
7118 attr_start: true,
7119 ..Default::default()
7120 },
7121 )),
7122 },
7123 WebUIFragment::raw(">"),
7124 WebUIFragment::component("child-component"),
7125 WebUIFragment::raw("</child-component>"),
7126 ],
7127
7128 contains_boundary: false,
7129 },
7130 );
7131 fragments.insert(
7132 "c-title".to_string(),
7133 FragmentList {
7134 fragments: vec![
7135 WebUIFragment::raw("C("),
7136 WebUIFragment::signal("title", false),
7137 WebUIFragment::raw(")-"),
7138 WebUIFragment::signal("cExtra", false),
7139 ],
7140 contains_boundary: false,
7141 },
7142 );
7143 fragments.insert(
7144 "child-component".to_string(),
7145 FragmentList {
7146 fragments: vec![
7147 WebUIFragment::raw("<grandchild-component"),
7148 WebUIFragment {
7149 fragment: Some(web_ui_fragment::Fragment::Attribute(
7150 WebUIFragmentAttribute {
7151 name: "title".into(),
7152 value: "title".into(),
7153 attr_start: true,
7154 ..Default::default()
7155 },
7156 )),
7157 },
7158 WebUIFragment::raw(">"),
7159 WebUIFragment::component("grandchild-component"),
7160 WebUIFragment::raw("</grandchild-component>"),
7161 ],
7162
7163 contains_boundary: false,
7164 },
7165 );
7166 fragments.insert(
7167 "grandchild-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!({"p": "<p>", "cExtra": "x&y"});
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=\"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>"
7190 );
7191 }
7192
7193 #[test]
7194 fn test_for_loop_component_attr() {
7195 let mut fragments = HashMap::new();
7196 fragments.insert(
7197 "index.html".to_string(),
7198 FragmentList {
7199 fragments: vec![
7200 WebUIFragment::raw("<parent-component"),
7201 WebUIFragment {
7202 fragment: Some(web_ui_fragment::Fragment::Attribute(
7203 WebUIFragmentAttribute {
7204 name: "title".into(),
7205 template: "parent-title-loop".into(),
7206 attr_start: true,
7207 ..Default::default()
7208 },
7209 )),
7210 },
7211 WebUIFragment::raw(">"),
7212 WebUIFragment::component("parent-component"),
7213 WebUIFragment::raw("</parent-component>"),
7214 ],
7215
7216 contains_boundary: false,
7217 },
7218 );
7219 fragments.insert(
7220 "parent-title-loop".to_string(),
7221 FragmentList {
7222 fragments: vec![
7223 WebUIFragment::raw("Parent:"),
7224 WebUIFragment::signal("who", false),
7225 ],
7226 contains_boundary: false,
7227 },
7228 );
7229 fragments.insert(
7230 "parent-component".to_string(),
7231 FragmentList {
7232 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
7233 contains_boundary: false,
7234 },
7235 );
7236 fragments.insert(
7237 "child-loop".to_string(),
7238 FragmentList {
7239 fragments: vec![
7240 WebUIFragment::raw("<child-component"),
7241 WebUIFragment {
7242 fragment: Some(web_ui_fragment::Fragment::Attribute(
7243 WebUIFragmentAttribute {
7244 name: "title".into(),
7245 template: "child-title-loop".into(),
7246 attr_start: true,
7247 ..Default::default()
7248 },
7249 )),
7250 },
7251 WebUIFragment::raw(">"),
7252 WebUIFragment::component("child-component"),
7253 WebUIFragment::raw("</child-component>"),
7254 ],
7255
7256 contains_boundary: false,
7257 },
7258 );
7259 fragments.insert(
7260 "child-title-loop".to_string(),
7261 FragmentList {
7262 fragments: vec![
7263 WebUIFragment::raw("Hi "),
7264 WebUIFragment::signal("item.name", false),
7265 WebUIFragment::raw(" / "),
7266 WebUIFragment::signal("title", false),
7267 ],
7268 contains_boundary: false,
7269 },
7270 );
7271 fragments.insert(
7272 "child-component".to_string(),
7273 FragmentList {
7274 fragments: vec![
7275 WebUIFragment::raw("<span>"),
7276 WebUIFragment::signal("title", false),
7277 WebUIFragment::raw("</span>"),
7278 ],
7279 contains_boundary: false,
7280 },
7281 );
7282 let protocol = WebUIProtocol::new(fragments);
7283 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7284 let mut writer = TestWriter::new();
7285 handle(
7286 &protocol,
7287 &state,
7288 &RenderOptions::new("index.html", "/"),
7289 &mut writer,
7290 )
7291 .unwrap();
7292 assert_eq!(
7293 writer.get_content(),
7294 "<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>"
7295 );
7296 }
7297
7298 #[test]
7299 fn test_multiple_template_attrs() {
7300 let mut fragments = HashMap::new();
7301 fragments.insert(
7302 "index.html".to_string(),
7303 FragmentList {
7304 fragments: vec![
7305 WebUIFragment::raw("<my-component"),
7306 WebUIFragment {
7307 fragment: Some(web_ui_fragment::Fragment::Attribute(
7308 WebUIFragmentAttribute {
7309 name: "title".into(),
7310 template: "attr-title".into(),
7311 attr_start: true,
7312 ..Default::default()
7313 },
7314 )),
7315 },
7316 WebUIFragment {
7317 fragment: Some(web_ui_fragment::Fragment::Attribute(
7318 WebUIFragmentAttribute {
7319 name: "data-title".into(),
7320 template: "attr-data-title".into(),
7321 attr_start: false,
7322 ..Default::default()
7323 },
7324 )),
7325 },
7326 WebUIFragment {
7327 fragment: Some(web_ui_fragment::Fragment::Attribute(
7328 WebUIFragmentAttribute {
7329 name: "aria-label".into(),
7330 template: "attr-aria-label".into(),
7331 attr_start: false,
7332 ..Default::default()
7333 },
7334 )),
7335 },
7336 WebUIFragment::raw(">"),
7337 WebUIFragment::component("my-component"),
7338 WebUIFragment::raw("</my-component>"),
7339 ],
7340
7341 contains_boundary: false,
7342 },
7343 );
7344 fragments.insert(
7345 "attr-title".to_string(),
7346 FragmentList {
7347 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7348 contains_boundary: false,
7349 },
7350 );
7351 fragments.insert(
7352 "attr-data-title".to_string(),
7353 FragmentList {
7354 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7355 contains_boundary: false,
7356 },
7357 );
7358 fragments.insert(
7359 "attr-aria-label".to_string(),
7360 FragmentList {
7361 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7362 contains_boundary: false,
7363 },
7364 );
7365 fragments.insert(
7366 "my-component".to_string(),
7367 FragmentList {
7368 fragments: vec![
7369 WebUIFragment::raw("<span>"),
7370 WebUIFragment::signal("title", false),
7371 WebUIFragment::raw("|"),
7372 WebUIFragment::signal("dataTitle", false),
7373 WebUIFragment::raw("|"),
7374 WebUIFragment::signal("ariaLabel", false),
7375 WebUIFragment::raw("</span>"),
7376 ],
7377 contains_boundary: false,
7378 },
7379 );
7380 let protocol = WebUIProtocol::new(fragments);
7381 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7382 let mut writer = TestWriter::new();
7383 handle(
7384 &protocol,
7385 &state,
7386 &RenderOptions::new("index.html", "/"),
7387 &mut writer,
7388 )
7389 .unwrap();
7390 assert_eq!(
7391 writer.get_content(),
7392 "<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>"
7393 );
7394 }
7395
7396 #[test]
7397 fn test_attr_priority_over_global() {
7398 let mut fragments = HashMap::new();
7399 fragments.insert(
7400 "index.html".to_string(),
7401 FragmentList {
7402 fragments: vec![
7403 WebUIFragment::raw("<my-component"),
7404 WebUIFragment {
7405 fragment: Some(web_ui_fragment::Fragment::Attribute(
7406 WebUIFragmentAttribute {
7407 name: "title".into(),
7408 value: "Attribute Title".into(),
7409 raw_value: true,
7410 attr_start: true,
7411 ..Default::default()
7412 },
7413 )),
7414 },
7415 WebUIFragment::raw(">"),
7416 WebUIFragment::component("my-component"),
7417 WebUIFragment::raw("</my-component>"),
7418 ],
7419
7420 contains_boundary: false,
7421 },
7422 );
7423 fragments.insert(
7424 "my-component".to_string(),
7425 FragmentList {
7426 fragments: vec![
7427 WebUIFragment::raw("<span>"),
7428 WebUIFragment::signal("title", false),
7429 WebUIFragment::raw("</span>"),
7430 ],
7431 contains_boundary: false,
7432 },
7433 );
7434 let protocol = WebUIProtocol::new(fragments);
7435 let state = test_json!({"title": "Global Title"});
7436 let mut writer = TestWriter::new();
7437 handle(
7438 &protocol,
7439 &state,
7440 &RenderOptions::new("index.html", "/"),
7441 &mut writer,
7442 )
7443 .unwrap();
7444 assert_eq!(
7445 writer.get_content(),
7446 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7447 );
7448 }
7449
7450 #[test]
7451 fn test_attr_priority_over_local_and_global() {
7452 let mut fragments = HashMap::new();
7453 fragments.insert(
7454 "index.html".to_string(),
7455 FragmentList {
7456 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7457 contains_boundary: false,
7458 },
7459 );
7460 fragments.insert(
7461 "loop".to_string(),
7462 FragmentList {
7463 fragments: vec![
7464 WebUIFragment::raw("<my-component"),
7465 WebUIFragment {
7466 fragment: Some(web_ui_fragment::Fragment::Attribute(
7467 WebUIFragmentAttribute {
7468 name: "title".into(),
7469 value: "Attribute Title".into(),
7470 raw_value: true,
7471 attr_start: true,
7472 ..Default::default()
7473 },
7474 )),
7475 },
7476 WebUIFragment::raw(">"),
7477 WebUIFragment::component("my-component"),
7478 WebUIFragment::raw("</my-component>"),
7479 ],
7480
7481 contains_boundary: false,
7482 },
7483 );
7484 fragments.insert(
7485 "my-component".to_string(),
7486 FragmentList {
7487 fragments: vec![
7488 WebUIFragment::raw("<span>"),
7489 WebUIFragment::signal("title", false),
7490 WebUIFragment::raw("</span>"),
7491 ],
7492 contains_boundary: false,
7493 },
7494 );
7495 let protocol = WebUIProtocol::new(fragments);
7496 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7497 let mut writer = TestWriter::new();
7498 handle(
7499 &protocol,
7500 &state,
7501 &RenderOptions::new("index.html", "/"),
7502 &mut writer,
7503 )
7504 .unwrap();
7505 assert_eq!(
7506 writer.get_content(),
7507 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7508 );
7509 }
7510
7511 #[test]
7512 fn test_boolean_attr_first_component_attr() {
7513 let mut fragments = HashMap::new();
7514 fragments.insert(
7515 "index.html".to_string(),
7516 FragmentList {
7517 fragments: vec![
7518 WebUIFragment::raw("<my-component"),
7519 WebUIFragment {
7520 fragment: Some(web_ui_fragment::Fragment::Attribute(
7521 WebUIFragmentAttribute {
7522 name: "disabled".into(),
7523 attr_start: true,
7524 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7525 ..Default::default()
7526 },
7527 )),
7528 },
7529 WebUIFragment {
7530 fragment: Some(web_ui_fragment::Fragment::Attribute(
7531 WebUIFragmentAttribute {
7532 name: "label".into(),
7533 value: "Component Label".into(),
7534 raw_value: true,
7535 attr_start: false,
7536 ..Default::default()
7537 },
7538 )),
7539 },
7540 WebUIFragment::raw(">"),
7541 WebUIFragment::component("my-component"),
7542 WebUIFragment::raw("</my-component>"),
7543 ],
7544
7545 contains_boundary: false,
7546 },
7547 );
7548 fragments.insert(
7549 "my-component".to_string(),
7550 FragmentList {
7551 fragments: vec![
7552 WebUIFragment::if_cond(
7553 ConditionExpr::identifier("disabled"),
7554 "disabledTemplate",
7555 ),
7556 WebUIFragment::raw("<span>"),
7557 WebUIFragment::signal("label", false),
7558 WebUIFragment::raw("</span>"),
7559 ],
7560 contains_boundary: false,
7561 },
7562 );
7563 fragments.insert(
7564 "disabledTemplate".to_string(),
7565 FragmentList {
7566 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7567 contains_boundary: false,
7568 },
7569 );
7570 let protocol = WebUIProtocol::new(fragments);
7571 let state = test_json!({"isDisabled": true});
7572 let mut writer = TestWriter::new();
7573 handle(
7574 &protocol,
7575 &state,
7576 &RenderOptions::new("index.html", "/"),
7577 &mut writer,
7578 )
7579 .unwrap();
7580 assert_eq!(
7581 writer.get_content(),
7582 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7583 );
7584 }
7585
7586 #[test]
7587 fn test_hyphenated_attr_camelcase() {
7588 let mut fragments = HashMap::new();
7589 fragments.insert(
7590 "index.html".to_string(),
7591 FragmentList {
7592 fragments: vec![
7593 WebUIFragment::raw("<my-component"),
7594 WebUIFragment {
7595 fragment: Some(web_ui_fragment::Fragment::Attribute(
7596 WebUIFragmentAttribute {
7597 name: "key-hyphen".into(),
7598 value: "Local Value".into(),
7599 raw_value: true,
7600 attr_start: true,
7601 ..Default::default()
7602 },
7603 )),
7604 },
7605 WebUIFragment::raw(">"),
7606 WebUIFragment::component("my-component"),
7607 WebUIFragment::raw("</my-component>"),
7608 ],
7609
7610 contains_boundary: false,
7611 },
7612 );
7613 fragments.insert(
7614 "my-component".to_string(),
7615 FragmentList {
7616 fragments: vec![
7617 WebUIFragment::raw("<span>"),
7618 WebUIFragment::signal("keyHyphen", false),
7619 WebUIFragment::raw("</span>"),
7620 ],
7621 contains_boundary: false,
7622 },
7623 );
7624 let protocol = WebUIProtocol::new(fragments);
7625 let state = test_json!({"keyHyphen": "Global Value"});
7626 let mut writer = TestWriter::new();
7627 handle(
7628 &protocol,
7629 &state,
7630 &RenderOptions::new("index.html", "/"),
7631 &mut writer,
7632 )
7633 .unwrap();
7634 assert_eq!(
7635 writer.get_content(),
7636 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7637 );
7638 }
7639
7640 #[test]
7641 fn test_skipped_component_attrs() {
7642 let mut fragments = HashMap::new();
7646 fragments.insert(
7647 "index.html".to_string(),
7648 FragmentList {
7649 fragments: vec![
7650 WebUIFragment::raw("<test-component"),
7651 WebUIFragment {
7653 fragment: Some(web_ui_fragment::Fragment::Attribute(
7654 WebUIFragmentAttribute {
7655 name: "class".into(),
7656 value: "skippedClass".into(),
7657 attr_start: true,
7658 attr_skip: true,
7659 ..Default::default()
7660 },
7661 )),
7662 },
7663 WebUIFragment {
7665 fragment: Some(web_ui_fragment::Fragment::Attribute(
7666 WebUIFragmentAttribute {
7667 name: "style".into(),
7668 value: "skippedStyle".into(),
7669 attr_skip: true,
7670 ..Default::default()
7671 },
7672 )),
7673 },
7674 WebUIFragment {
7676 fragment: Some(web_ui_fragment::Fragment::Attribute(
7677 WebUIFragmentAttribute {
7678 name: "role".into(),
7679 value: "skippedRole".into(),
7680 attr_skip: true,
7681 ..Default::default()
7682 },
7683 )),
7684 },
7685 WebUIFragment {
7687 fragment: Some(web_ui_fragment::Fragment::Attribute(
7688 WebUIFragmentAttribute {
7689 name: "data-testid".into(),
7690 value: "skippedDataTestid".into(),
7691 attr_skip: true,
7692 ..Default::default()
7693 },
7694 )),
7695 },
7696 WebUIFragment {
7698 fragment: Some(web_ui_fragment::Fragment::Attribute(
7699 WebUIFragmentAttribute {
7700 name: "aria-label".into(),
7701 value: "skippedAriaLabel".into(),
7702 attr_skip: true,
7703 ..Default::default()
7704 },
7705 )),
7706 },
7707 WebUIFragment {
7709 fragment: Some(web_ui_fragment::Fragment::Attribute(
7710 WebUIFragmentAttribute {
7711 name: "title".into(),
7712 value: "title".into(),
7713 ..Default::default()
7714 },
7715 )),
7716 },
7717 WebUIFragment::raw(">"),
7718 WebUIFragment::component("test-component"),
7719 WebUIFragment::raw("</test-component>"),
7720 ],
7721
7722 contains_boundary: false,
7723 },
7724 );
7725 fragments.insert(
7726 "test-component".to_string(),
7727 FragmentList {
7728 fragments: vec![
7729 WebUIFragment::raw("<span>"),
7730 WebUIFragment::signal("title", false),
7731 WebUIFragment::raw("-"),
7732 WebUIFragment::signal("class", false),
7733 WebUIFragment::raw("-"),
7734 WebUIFragment::signal("style", false),
7735 WebUIFragment::raw("-"),
7736 WebUIFragment::signal("role", false),
7737 WebUIFragment::raw("-"),
7738 WebUIFragment::signal("dataTestid", false),
7739 WebUIFragment::raw("-"),
7740 WebUIFragment::signal("ariaLabel", false),
7741 WebUIFragment::raw("</span>"),
7742 ],
7743 contains_boundary: false,
7744 },
7745 );
7746 let protocol = WebUIProtocol::new(fragments);
7747 let state = test_json!({
7748 "title": "Hello",
7749 "skippedClass": "my-class",
7750 "skippedStyle": "color:red",
7751 "skippedRole": "button",
7752 "skippedDataTestid": "test-id",
7753 "skippedAriaLabel": "label-text"
7754 });
7755 let mut writer = TestWriter::new();
7756 handle(
7757 &protocol,
7758 &state,
7759 &RenderOptions::new("index.html", "/"),
7760 &mut writer,
7761 )
7762 .unwrap();
7763 assert_eq!(
7767 writer.get_content(),
7768 "<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>"
7769 );
7770 }
7771
7772 #[test]
7775 fn test_attr_inherit_parent_to_child() {
7776 let mut fragments = HashMap::new();
7777 fragments.insert(
7778 "index.html".to_string(),
7779 FragmentList {
7780 fragments: vec![
7781 WebUIFragment::raw("<parent-component"),
7782 WebUIFragment {
7783 fragment: Some(web_ui_fragment::Fragment::Attribute(
7784 WebUIFragmentAttribute {
7785 name: "title".into(),
7786 value: "Parent Title".into(),
7787 raw_value: true,
7788 attr_start: true,
7789 ..Default::default()
7790 },
7791 )),
7792 },
7793 WebUIFragment::raw(">"),
7794 WebUIFragment::component("parent-component"),
7795 WebUIFragment::raw("</parent-component>"),
7796 ],
7797
7798 contains_boundary: false,
7799 },
7800 );
7801 fragments.insert(
7802 "parent-component".to_string(),
7803 FragmentList {
7804 fragments: vec![
7805 WebUIFragment::raw("<h1>"),
7806 WebUIFragment::signal("title", false),
7807 WebUIFragment::raw("</h1><child-component"),
7808 WebUIFragment {
7809 fragment: Some(web_ui_fragment::Fragment::Attribute(
7810 WebUIFragmentAttribute {
7811 name: "title".into(),
7812 value: "title".into(),
7813 attr_start: true,
7814 ..Default::default()
7815 },
7816 )),
7817 },
7818 WebUIFragment::raw(">"),
7819 WebUIFragment::component("child-component"),
7820 WebUIFragment::raw("</child-component>"),
7821 ],
7822
7823 contains_boundary: false,
7824 },
7825 );
7826 fragments.insert(
7827 "child-component".to_string(),
7828 FragmentList {
7829 fragments: vec![
7830 WebUIFragment::raw("<h2>"),
7831 WebUIFragment::signal("title", false),
7832 WebUIFragment::raw("</h2>"),
7833 ],
7834 contains_boundary: false,
7835 },
7836 );
7837 let protocol = WebUIProtocol::new(fragments);
7838 let state = test_json!({});
7839 let mut writer = TestWriter::new();
7840 handle(
7841 &protocol,
7842 &state,
7843 &RenderOptions::new("index.html", "/"),
7844 &mut writer,
7845 )
7846 .unwrap();
7847 assert_eq!(
7848 writer.get_content(),
7849 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7850 );
7851 }
7852
7853 #[test]
7854 fn test_attr_inherit_deep() {
7855 let mut fragments = HashMap::new();
7856 fragments.insert(
7857 "index.html".to_string(),
7858 FragmentList {
7859 fragments: vec![
7860 WebUIFragment::raw("<parent-component"),
7861 WebUIFragment {
7862 fragment: Some(web_ui_fragment::Fragment::Attribute(
7863 WebUIFragmentAttribute {
7864 name: "title".into(),
7865 value: "Parent Title".into(),
7866 raw_value: true,
7867 attr_start: true,
7868 ..Default::default()
7869 },
7870 )),
7871 },
7872 WebUIFragment::raw(">"),
7873 WebUIFragment::component("parent-component"),
7874 WebUIFragment::raw("</parent-component>"),
7875 ],
7876
7877 contains_boundary: false,
7878 },
7879 );
7880 fragments.insert(
7881 "parent-component".to_string(),
7882 FragmentList {
7883 fragments: vec![
7884 WebUIFragment::raw("<child-component"),
7885 WebUIFragment {
7886 fragment: Some(web_ui_fragment::Fragment::Attribute(
7887 WebUIFragmentAttribute {
7888 name: "title".into(),
7889 value: "Child Title".into(),
7890 raw_value: true,
7891 attr_start: true,
7892 ..Default::default()
7893 },
7894 )),
7895 },
7896 WebUIFragment::raw(">"),
7897 WebUIFragment::component("child-component"),
7898 WebUIFragment::raw("</child-component>"),
7899 ],
7900
7901 contains_boundary: false,
7902 },
7903 );
7904 fragments.insert(
7905 "child-component".to_string(),
7906 FragmentList {
7907 fragments: vec![
7908 WebUIFragment::raw("<grandchild-component"),
7909 WebUIFragment {
7910 fragment: Some(web_ui_fragment::Fragment::Attribute(
7911 WebUIFragmentAttribute {
7912 name: "title".into(),
7913 value: "title".into(),
7914 attr_start: true,
7915 ..Default::default()
7916 },
7917 )),
7918 },
7919 WebUIFragment::raw(">"),
7920 WebUIFragment::component("grandchild-component"),
7921 WebUIFragment::raw("</grandchild-component>"),
7922 ],
7923
7924 contains_boundary: false,
7925 },
7926 );
7927 fragments.insert(
7928 "grandchild-component".to_string(),
7929 FragmentList {
7930 fragments: vec![
7931 WebUIFragment::raw("<h3>"),
7932 WebUIFragment::signal("title", false),
7933 WebUIFragment::raw("</h3>"),
7934 ],
7935 contains_boundary: false,
7936 },
7937 );
7938 let protocol = WebUIProtocol::new(fragments);
7939 let state = test_json!({});
7940 let mut writer = TestWriter::new();
7941 handle(
7942 &protocol,
7943 &state,
7944 &RenderOptions::new("index.html", "/"),
7945 &mut writer,
7946 )
7947 .unwrap();
7948 assert_eq!(
7949 writer.get_content(),
7950 "<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>"
7951 );
7952 }
7953
7954 #[test]
7955 fn test_complex_attr_access() {
7956 let mut fragments = HashMap::new();
7957 fragments.insert(
7958 "index.html".to_string(),
7959 FragmentList {
7960 fragments: vec![
7961 WebUIFragment::raw("<my-component"),
7962 WebUIFragment {
7963 fragment: Some(web_ui_fragment::Fragment::Attribute(
7964 WebUIFragmentAttribute {
7965 name: ":item".into(),
7966 value: "complexItem".into(),
7967 attr_start: true,
7968 complex: true,
7969 ..Default::default()
7970 },
7971 )),
7972 },
7973 WebUIFragment::raw(">"),
7974 WebUIFragment::component("my-component"),
7975 WebUIFragment::raw("</my-component>"),
7976 ],
7977
7978 contains_boundary: false,
7979 },
7980 );
7981 fragments.insert(
7982 "my-component".to_string(),
7983 FragmentList {
7984 fragments: vec![
7985 WebUIFragment::raw("<span>"),
7986 WebUIFragment::signal("item.foo", false),
7987 WebUIFragment::raw("</span><p>"),
7988 WebUIFragment::signal("item.bar", false),
7989 WebUIFragment::raw("</p>"),
7990 ],
7991 contains_boundary: false,
7992 },
7993 );
7994 let protocol = WebUIProtocol::new(fragments);
7995 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7996 let mut writer = TestWriter::new();
7997 handle(
7998 &protocol,
7999 &state,
8000 &RenderOptions::new("index.html", "/"),
8001 &mut writer,
8002 )
8003 .unwrap();
8004 assert_eq!(
8005 writer.get_content(),
8006 "<my-component><span>1</span><p>true</p></my-component>"
8007 );
8008 }
8009
8010 #[test]
8011 fn test_complex_attr_for_loop() {
8012 let mut fragments = HashMap::new();
8013 fragments.insert(
8014 "index.html".to_string(),
8015 FragmentList {
8016 fragments: vec![WebUIFragment::for_loop(
8017 "item",
8018 "list.items",
8019 "listTemplate",
8020 )],
8021 contains_boundary: false,
8022 },
8023 );
8024 fragments.insert(
8025 "listTemplate".to_string(),
8026 FragmentList {
8027 fragments: vec![
8028 WebUIFragment {
8029 fragment: Some(web_ui_fragment::Fragment::Attribute(
8030 WebUIFragmentAttribute {
8031 name: ":item".into(),
8032 value: "item".into(),
8033 attr_start: true,
8034 complex: true,
8035 ..Default::default()
8036 },
8037 )),
8038 },
8039 WebUIFragment::component("item_component"),
8040 ],
8041
8042 contains_boundary: false,
8043 },
8044 );
8045 fragments.insert(
8046 "item_component".to_string(),
8047 FragmentList {
8048 fragments: vec![
8049 WebUIFragment::raw("<span>"),
8050 WebUIFragment::signal("item.name", false),
8051 WebUIFragment::raw("</span>"),
8052 ],
8053 contains_boundary: false,
8054 },
8055 );
8056 let protocol = WebUIProtocol::new(fragments);
8057 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
8058 let mut writer = TestWriter::new();
8059 handle(
8060 &protocol,
8061 &state,
8062 &RenderOptions::new("index.html", "/"),
8063 &mut writer,
8064 )
8065 .unwrap();
8066 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
8067 }
8068
8069 #[test]
8070 fn test_complex_attr_nested_for() {
8071 let mut fragments = HashMap::new();
8072 fragments.insert(
8073 "index.html".to_string(),
8074 FragmentList {
8075 fragments: vec![WebUIFragment::for_loop(
8076 "outer",
8077 "data.outer",
8078 "outerTemplate",
8079 )],
8080 contains_boundary: false,
8081 },
8082 );
8083 fragments.insert(
8084 "outerTemplate".to_string(),
8085 FragmentList {
8086 fragments: vec![WebUIFragment::for_loop(
8087 "middle",
8088 "outer.middle",
8089 "middleTemplate",
8090 )],
8091 contains_boundary: false,
8092 },
8093 );
8094 fragments.insert(
8095 "middleTemplate".to_string(),
8096 FragmentList {
8097 fragments: vec![WebUIFragment::for_loop(
8098 "inner",
8099 "middle.inner",
8100 "innerTemplate",
8101 )],
8102 contains_boundary: false,
8103 },
8104 );
8105 fragments.insert(
8106 "innerTemplate".to_string(),
8107 FragmentList {
8108 fragments: vec![
8109 WebUIFragment::raw("<card"),
8110 WebUIFragment {
8111 fragment: Some(web_ui_fragment::Fragment::Attribute(
8112 WebUIFragmentAttribute {
8113 name: ":outer".into(),
8114 value: "outer".into(),
8115 attr_start: true,
8116 complex: true,
8117 ..Default::default()
8118 },
8119 )),
8120 },
8121 WebUIFragment {
8122 fragment: Some(web_ui_fragment::Fragment::Attribute(
8123 WebUIFragmentAttribute {
8124 name: ":middle".into(),
8125 value: "middle".into(),
8126 attr_start: false,
8127 complex: true,
8128 ..Default::default()
8129 },
8130 )),
8131 },
8132 WebUIFragment {
8133 fragment: Some(web_ui_fragment::Fragment::Attribute(
8134 WebUIFragmentAttribute {
8135 name: ":inner".into(),
8136 value: "inner".into(),
8137 attr_start: false,
8138 complex: true,
8139 ..Default::default()
8140 },
8141 )),
8142 },
8143 WebUIFragment::raw(">"),
8144 WebUIFragment::component("card_component"),
8145 WebUIFragment::raw("</card>"),
8146 ],
8147
8148 contains_boundary: false,
8149 },
8150 );
8151 fragments.insert(
8152 "card_component".to_string(),
8153 FragmentList {
8154 fragments: vec![
8155 WebUIFragment::raw("<p>"),
8156 WebUIFragment::signal("outer.label", false),
8157 WebUIFragment::raw(" / "),
8158 WebUIFragment::signal("middle.label", false),
8159 WebUIFragment::raw(" / "),
8160 WebUIFragment::signal("inner.label", false),
8161 WebUIFragment::raw("</p>"),
8162 ],
8163 contains_boundary: false,
8164 },
8165 );
8166 let protocol = WebUIProtocol::new(fragments);
8167 let state = test_json!({"data": {"outer": [
8168 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
8169 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
8170 ]}});
8171 let mut writer = TestWriter::new();
8172 handle(
8173 &protocol,
8174 &state,
8175 &RenderOptions::new("index.html", "/"),
8176 &mut writer,
8177 )
8178 .unwrap();
8179 assert_eq!(
8180 writer.get_content(),
8181 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
8182 );
8183 }
8184
8185 #[test]
8188 fn test_bool_component_state_true() {
8189 let mut fragments = HashMap::new();
8190 fragments.insert(
8191 "index.html".to_string(),
8192 FragmentList {
8193 fragments: vec![
8194 WebUIFragment::raw("<my-component"),
8195 WebUIFragment {
8196 fragment: Some(web_ui_fragment::Fragment::Attribute(
8197 WebUIFragmentAttribute {
8198 name: "disabled".into(),
8199 attr_start: true,
8200 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8201 ..Default::default()
8202 },
8203 )),
8204 },
8205 WebUIFragment::raw(">"),
8206 WebUIFragment::component("my-component"),
8207 WebUIFragment::raw("</my-component>"),
8208 ],
8209
8210 contains_boundary: false,
8211 },
8212 );
8213 fragments.insert(
8214 "my-component".to_string(),
8215 FragmentList {
8216 fragments: vec![
8217 WebUIFragment::if_cond(
8218 ConditionExpr::identifier("disabled"),
8219 "disabledTemplate",
8220 ),
8221 WebUIFragment::if_cond(
8222 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8223 "enabledTemplate",
8224 ),
8225 ],
8226 contains_boundary: false,
8227 },
8228 );
8229 fragments.insert(
8230 "disabledTemplate".to_string(),
8231 FragmentList {
8232 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8233 contains_boundary: false,
8234 },
8235 );
8236 fragments.insert(
8237 "enabledTemplate".to_string(),
8238 FragmentList {
8239 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8240 contains_boundary: false,
8241 },
8242 );
8243 let protocol = WebUIProtocol::new(fragments);
8244 let state = test_json!({"isDisabled": true});
8245 let mut writer = TestWriter::new();
8246 handle(
8247 &protocol,
8248 &state,
8249 &RenderOptions::new("index.html", "/"),
8250 &mut writer,
8251 )
8252 .unwrap();
8253 assert_eq!(
8254 writer.get_content(),
8255 "<my-component disabled><span>Disabled</span></my-component>"
8256 );
8257 }
8258
8259 #[test]
8260 fn test_bool_component_state_false() {
8261 let mut fragments = HashMap::new();
8262 fragments.insert(
8263 "index.html".to_string(),
8264 FragmentList {
8265 fragments: vec![
8266 WebUIFragment::raw("<my-component"),
8267 WebUIFragment {
8268 fragment: Some(web_ui_fragment::Fragment::Attribute(
8269 WebUIFragmentAttribute {
8270 name: "disabled".into(),
8271 attr_start: true,
8272 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8273 ..Default::default()
8274 },
8275 )),
8276 },
8277 WebUIFragment::raw(">"),
8278 WebUIFragment::component("my-component"),
8279 WebUIFragment::raw("</my-component>"),
8280 ],
8281
8282 contains_boundary: false,
8283 },
8284 );
8285 fragments.insert(
8286 "my-component".to_string(),
8287 FragmentList {
8288 fragments: vec![
8289 WebUIFragment::if_cond(
8290 ConditionExpr::identifier("disabled"),
8291 "disabledTemplate",
8292 ),
8293 WebUIFragment::if_cond(
8294 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8295 "enabledTemplate",
8296 ),
8297 ],
8298 contains_boundary: false,
8299 },
8300 );
8301 fragments.insert(
8302 "disabledTemplate".to_string(),
8303 FragmentList {
8304 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8305 contains_boundary: false,
8306 },
8307 );
8308 fragments.insert(
8309 "enabledTemplate".to_string(),
8310 FragmentList {
8311 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8312 contains_boundary: false,
8313 },
8314 );
8315 let protocol = WebUIProtocol::new(fragments);
8316 let state = test_json!({"isDisabled": false});
8317 let mut writer = TestWriter::new();
8318 handle(
8319 &protocol,
8320 &state,
8321 &RenderOptions::new("index.html", "/"),
8322 &mut writer,
8323 )
8324 .unwrap();
8325 assert_eq!(
8326 writer.get_content(),
8327 "<my-component><span>Enabled</span></my-component>"
8328 );
8329 }
8330
8331 #[test]
8332 fn test_bool_component_state_forward() {
8333 let mut fragments = HashMap::new();
8334 fragments.insert(
8335 "index.html".to_string(),
8336 FragmentList {
8337 fragments: vec![
8338 WebUIFragment::raw("<parent-component"),
8339 WebUIFragment {
8340 fragment: Some(web_ui_fragment::Fragment::Attribute(
8341 WebUIFragmentAttribute {
8342 name: "disabled".into(),
8343 attr_start: true,
8344 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8345 ..Default::default()
8346 },
8347 )),
8348 },
8349 WebUIFragment::raw(">"),
8350 WebUIFragment::component("parent-component"),
8351 WebUIFragment::raw("</parent-component>"),
8352 ],
8353
8354 contains_boundary: false,
8355 },
8356 );
8357 fragments.insert(
8358 "parent-component".to_string(),
8359 FragmentList {
8360 fragments: vec![
8361 WebUIFragment::if_cond(
8362 ConditionExpr::identifier("disabled"),
8363 "parentDisabledTemplate",
8364 ),
8365 WebUIFragment::raw("<child-component"),
8366 WebUIFragment {
8367 fragment: Some(web_ui_fragment::Fragment::Attribute(
8368 WebUIFragmentAttribute {
8369 name: "disabled".into(),
8370 attr_start: true,
8371 condition_tree: Some(ConditionExpr::identifier("disabled")),
8372 ..Default::default()
8373 },
8374 )),
8375 },
8376 WebUIFragment::raw(">"),
8377 WebUIFragment::component("child-component"),
8378 WebUIFragment::raw("</child-component>"),
8379 ],
8380
8381 contains_boundary: false,
8382 },
8383 );
8384 fragments.insert(
8385 "parentDisabledTemplate".to_string(),
8386 FragmentList {
8387 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8388 contains_boundary: false,
8389 },
8390 );
8391 fragments.insert(
8392 "child-component".to_string(),
8393 FragmentList {
8394 fragments: vec![
8395 WebUIFragment::if_cond(
8396 ConditionExpr::identifier("disabled"),
8397 "childDisabledTemplate",
8398 ),
8399 WebUIFragment::if_cond(
8400 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8401 "childEnabledTemplate",
8402 ),
8403 ],
8404 contains_boundary: false,
8405 },
8406 );
8407 fragments.insert(
8408 "childDisabledTemplate".to_string(),
8409 FragmentList {
8410 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8411 contains_boundary: false,
8412 },
8413 );
8414 fragments.insert(
8415 "childEnabledTemplate".to_string(),
8416 FragmentList {
8417 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8418 contains_boundary: false,
8419 },
8420 );
8421
8422 {
8424 let protocol = WebUIProtocol::new(fragments.clone());
8425 let state = test_json!({"isDisabled": true});
8426 let mut writer = TestWriter::new();
8427 handle(
8428 &protocol,
8429 &state,
8430 &RenderOptions::new("index.html", "/"),
8431 &mut writer,
8432 )
8433 .unwrap();
8434 assert_eq!(
8435 writer.get_content(),
8436 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8437 );
8438 }
8439
8440 {
8442 let protocol = WebUIProtocol::new(fragments.clone());
8443 let state = test_json!({"isDisabled": false});
8444 let mut writer = TestWriter::new();
8445 handle(
8446 &protocol,
8447 &state,
8448 &RenderOptions::new("index.html", "/"),
8449 &mut writer,
8450 )
8451 .unwrap();
8452 assert_eq!(
8453 writer.get_content(),
8454 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8455 );
8456 }
8457 }
8458
8459 #[test]
8466 fn test_component_with_template() {
8467 let mut fragments = HashMap::new();
8468 fragments.insert(
8469 "index.html".to_string(),
8470 FragmentList {
8471 fragments: vec![
8472 WebUIFragment::raw("<custom-element>"),
8473 WebUIFragment::component("custom-element"),
8474 WebUIFragment::raw("</custom-element>"),
8475 ],
8476 contains_boundary: false,
8477 },
8478 );
8479 fragments.insert(
8480 "custom-element".to_string(),
8481 FragmentList {
8482 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8483 contains_boundary: false,
8484 },
8485 );
8486 let protocol = WebUIProtocol::new(fragments);
8487 let state = test_json!({});
8488 let mut writer = TestWriter::new();
8489 handle(
8490 &protocol,
8491 &state,
8492 &RenderOptions::new("index.html", "/"),
8493 &mut writer,
8494 )
8495 .unwrap();
8496 assert_eq!(
8497 writer.get_content(),
8498 "<custom-element><div>Custom Element</div></custom-element>"
8499 );
8500 assert!(writer.is_ended());
8501 }
8502
8503 #[test]
8504 fn test_component_with_slots() {
8505 let mut fragments = HashMap::new();
8506 fragments.insert(
8507 "index.html".to_string(),
8508 FragmentList {
8509 fragments: vec![
8510 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8511 WebUIFragment::component("custom-element"),
8512 WebUIFragment::raw("Hello World</custom-element>"),
8513 ],
8514 contains_boundary: false,
8515 },
8516 );
8517 fragments.insert(
8518 "custom-element".to_string(),
8519 FragmentList {
8520 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8521 contains_boundary: false,
8522 },
8523 );
8524 let protocol = WebUIProtocol::new(fragments);
8525 let state = test_json!({});
8526 let mut writer = TestWriter::new();
8527 handle(
8528 &protocol,
8529 &state,
8530 &RenderOptions::new("index.html", "/"),
8531 &mut writer,
8532 )
8533 .unwrap();
8534 assert_eq!(
8535 writer.get_content(),
8536 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8537 );
8538 assert!(writer.is_ended());
8539 }
8540
8541 #[test]
8542 fn test_multiple_nested_components() {
8543 let mut fragments = HashMap::new();
8544 fragments.insert(
8545 "index.html".to_string(),
8546 FragmentList {
8547 fragments: vec![
8548 WebUIFragment::raw("<div>"),
8549 WebUIFragment::for_loop("item", "items", "templateRepeat"),
8550 WebUIFragment::raw("</div>"),
8551 ],
8552 contains_boundary: false,
8553 },
8554 );
8555 fragments.insert(
8556 "custom-button".to_string(),
8557 FragmentList {
8558 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8559 contains_boundary: false,
8560 },
8561 );
8562 fragments.insert(
8563 "custom-element".to_string(),
8564 FragmentList {
8565 fragments: vec![
8566 WebUIFragment::raw("<custom-child>"),
8567 WebUIFragment::component("custom-child"),
8568 WebUIFragment::raw("</custom-child><slot></slot>"),
8569 ],
8570 contains_boundary: false,
8571 },
8572 );
8573 fragments.insert(
8574 "custom-child".to_string(),
8575 FragmentList {
8576 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8577 contains_boundary: false,
8578 },
8579 );
8580 fragments.insert(
8581 "templateRepeat".to_string(),
8582 FragmentList {
8583 fragments: vec![
8584 WebUIFragment::raw("<custom-element>"),
8585 WebUIFragment::component("custom-element"),
8586 WebUIFragment::raw("<custom-button>"),
8587 WebUIFragment::component("custom-button"),
8588 WebUIFragment::raw("Ok</custom-button></custom-element>"),
8589 ],
8590 contains_boundary: false,
8591 },
8592 );
8593 let protocol = WebUIProtocol::new(fragments);
8594 let state = test_json!({"items": [{"name": "Item1"}]});
8595 let mut writer = TestWriter::new();
8596 handle(
8597 &protocol,
8598 &state,
8599 &RenderOptions::new("index.html", "/"),
8600 &mut writer,
8601 )
8602 .unwrap();
8603 assert_eq!(
8604 writer.get_content(),
8605 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8606 );
8607 assert!(writer.is_ended());
8608 }
8609
8610 #[test]
8613 fn test_if_with_binary_expression() {
8614 let mut fragments = HashMap::new();
8615 fragments.insert(
8616 "index.html".to_string(),
8617 FragmentList {
8618 fragments: vec![
8619 WebUIFragment::raw("<div>"),
8620 WebUIFragment::if_cond(
8621 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8622 "if-1",
8623 ),
8624 WebUIFragment::raw("</div>"),
8625 ],
8626 contains_boundary: false,
8627 },
8628 );
8629 fragments.insert(
8630 "if-1".to_string(),
8631 FragmentList {
8632 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8633 contains_boundary: false,
8634 },
8635 );
8636 let protocol = WebUIProtocol::new(fragments);
8637
8638 let state_true = test_json!({"x": 10});
8640 let mut writer_true = TestWriter::new();
8641 handle(
8642 &protocol,
8643 &state_true,
8644 &RenderOptions::new("index.html", "/"),
8645 &mut writer_true,
8646 )
8647 .unwrap();
8648 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8649
8650 let state_false = test_json!({"x": 1});
8652 let mut writer_false = TestWriter::new();
8653 handle(
8654 &protocol,
8655 &state_false,
8656 &RenderOptions::new("index.html", "/"),
8657 &mut writer_false,
8658 )
8659 .unwrap();
8660 assert_eq!(writer_false.get_content(), "<div></div>");
8661 }
8662
8663 #[test]
8664 fn test_for_if_overlapping_local_state() {
8665 let mut fragments = HashMap::new();
8666 fragments.insert(
8667 "index.html".to_string(),
8668 FragmentList {
8669 fragments: vec![
8670 WebUIFragment::raw("<div>"),
8671 WebUIFragment::for_loop("item", "items", "template1"),
8672 WebUIFragment::raw("</div>"),
8673 ],
8674 contains_boundary: false,
8675 },
8676 );
8677 fragments.insert(
8678 "template1".to_string(),
8679 FragmentList {
8680 fragments: vec![
8681 WebUIFragment::raw("<div>"),
8682 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8683 WebUIFragment::raw("</div>"),
8684 ],
8685 contains_boundary: false,
8686 },
8687 );
8688 fragments.insert(
8689 "ifBlock".to_string(),
8690 FragmentList {
8691 fragments: vec![
8692 WebUIFragment::raw("<span>"),
8693 WebUIFragment::signal("item.label", false),
8694 WebUIFragment::raw("</span>"),
8695 ],
8696 contains_boundary: false,
8697 },
8698 );
8699 let protocol = WebUIProtocol::new(fragments);
8700 let state = test_json!({
8701 "flag": false,
8702 "items": [
8703 {"label": "A", "flag": true},
8704 {"label": "B", "flag": false},
8705 {"label": "C", "flag": true}
8706 ]
8707 });
8708 let mut writer = TestWriter::new();
8709 handle(
8710 &protocol,
8711 &state,
8712 &RenderOptions::new("index.html", "/"),
8713 &mut writer,
8714 )
8715 .unwrap();
8716 assert_eq!(
8717 writer.get_content(),
8718 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8719 );
8720 }
8721
8722 #[test]
8723 fn test_for_if_global_flag_no_effect() {
8724 let mut fragments = HashMap::new();
8725 fragments.insert(
8726 "index.html".to_string(),
8727 FragmentList {
8728 fragments: vec![
8729 WebUIFragment::raw("<div>"),
8730 WebUIFragment::for_loop("item", "items", "template1"),
8731 WebUIFragment::raw("</div>"),
8732 ],
8733 contains_boundary: false,
8734 },
8735 );
8736 fragments.insert(
8737 "template1".to_string(),
8738 FragmentList {
8739 fragments: vec![
8740 WebUIFragment::raw("<div>"),
8741 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8742 WebUIFragment::raw("</div>"),
8743 ],
8744 contains_boundary: false,
8745 },
8746 );
8747 fragments.insert(
8748 "ifBlock".to_string(),
8749 FragmentList {
8750 fragments: vec![
8751 WebUIFragment::raw("<span>"),
8752 WebUIFragment::signal("item.label", false),
8753 WebUIFragment::raw("</span>"),
8754 ],
8755 contains_boundary: false,
8756 },
8757 );
8758 let protocol = WebUIProtocol::new(fragments);
8759 let state = test_json!({
8760 "item": {"flag": true},
8761 "items": [
8762 {"label": "A", "flag": false},
8763 {"label": "B", "flag": true},
8764 {"label": "C", "flag": false}
8765 ]
8766 });
8767 let mut writer = TestWriter::new();
8768 handle(
8769 &protocol,
8770 &state,
8771 &RenderOptions::new("index.html", "/"),
8772 &mut writer,
8773 )
8774 .unwrap();
8775 assert_eq!(
8776 writer.get_content(),
8777 "<div><div></div><div><span>B</span></div><div></div></div>"
8778 );
8779 }
8780
8781 #[test]
8784 fn test_recursive_template_refs() {
8785 let mut fragments = HashMap::new();
8786 fragments.insert(
8787 "index.html".to_string(),
8788 FragmentList {
8789 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8790 contains_boundary: false,
8791 },
8792 );
8793 fragments.insert(
8794 "static".to_string(),
8795 FragmentList {
8796 fragments: vec![
8797 WebUIFragment::raw("<div expanded=\""),
8798 WebUIFragment::signal("item.expanded", false),
8799 WebUIFragment::raw("\" class=\""),
8800 WebUIFragment::signal("testScenario", false),
8801 WebUIFragment::raw("\"><span>"),
8802 WebUIFragment::signal("item.name", false),
8803 WebUIFragment::raw("</span>"),
8804 WebUIFragment::for_loop("item", "item.children", "static"),
8805 WebUIFragment::raw("</div>"),
8806 ],
8807 contains_boundary: false,
8808 },
8809 );
8810 let protocol = WebUIProtocol::new(fragments);
8811 let state = test_json!({
8812 "testScenario": "RecursiveTemplatesWithGlobalState",
8813 "items": [
8814 {"name": "A", "expanded": "false", "children": []},
8815 {"name": "B", "expanded": "true", "children": [
8816 {"name": "C", "expanded": "false"},
8817 {"name": "D", "expanded": "false"}
8818 ]},
8819 {"name": "E", "expanded": "false"}
8820 ]
8821 });
8822 let mut writer = TestWriter::new();
8823 handle(
8824 &protocol,
8825 &state,
8826 &RenderOptions::new("index.html", "/"),
8827 &mut writer,
8828 )
8829 .unwrap();
8830 assert_eq!(
8831 writer.get_content(),
8832 "<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>"
8833 );
8834 }
8835
8836 #[test]
8839 fn test_component_in_for_no_local_access() {
8840 let mut fragments = HashMap::new();
8841 fragments.insert(
8842 "index.html".to_string(),
8843 FragmentList {
8844 fragments: vec![
8845 WebUIFragment::raw("<div>"),
8846 WebUIFragment::for_loop("item", "items", "templateComponent"),
8847 WebUIFragment::raw("</div>"),
8848 ],
8849 contains_boundary: false,
8850 },
8851 );
8852 fragments.insert(
8853 "templateComponent".to_string(),
8854 FragmentList {
8855 fragments: vec![
8856 WebUIFragment::raw("<component-tag>"),
8857 WebUIFragment::component("my-component"),
8858 WebUIFragment::raw("</component-tag>"),
8859 ],
8860 contains_boundary: false,
8861 },
8862 );
8863 fragments.insert(
8864 "my-component".to_string(),
8865 FragmentList {
8866 fragments: vec![
8867 WebUIFragment::raw("<span>"),
8868 WebUIFragment::signal("name", false),
8869 WebUIFragment::raw("</span>"),
8870 ],
8871 contains_boundary: false,
8872 },
8873 );
8874 let protocol = WebUIProtocol::new(fragments);
8875 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8876 let mut writer = TestWriter::new();
8877 handle(
8878 &protocol,
8879 &state,
8880 &RenderOptions::new("index.html", "/"),
8881 &mut writer,
8882 )
8883 .unwrap();
8884 assert_eq!(
8885 writer.get_content(),
8886 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8887 );
8888 }
8889
8890 #[test]
8891 fn test_nested_for_hierarchical_state() {
8892 let mut fragments = HashMap::new();
8893 fragments.insert(
8894 "index.html".to_string(),
8895 FragmentList {
8896 fragments: vec![
8897 WebUIFragment::raw("<div>"),
8898 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8899 WebUIFragment::raw("</div>"),
8900 ],
8901 contains_boundary: false,
8902 },
8903 );
8904 fragments.insert(
8905 "outerTemplate".to_string(),
8906 FragmentList {
8907 fragments: vec![
8908 WebUIFragment::raw("<section>"),
8909 WebUIFragment::signal("globalPrefix", false),
8910 WebUIFragment::signal("outerItem.outerLabel", false),
8911 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8912 WebUIFragment::raw("</section>"),
8913 ],
8914 contains_boundary: false,
8915 },
8916 );
8917 fragments.insert(
8918 "innerTemplate".to_string(),
8919 FragmentList {
8920 fragments: vec![
8921 WebUIFragment::raw("<p>"),
8922 WebUIFragment::signal("globalPrefix", false),
8923 WebUIFragment::signal("outerItem.outerLabel", false),
8924 WebUIFragment::raw(": "),
8925 WebUIFragment::signal("innerItem.innerLabel", false),
8926 WebUIFragment::raw("</p>"),
8927 ],
8928 contains_boundary: false,
8929 },
8930 );
8931 let protocol = WebUIProtocol::new(fragments);
8932 let state = test_json!({
8933 "globalPrefix": "Prefix: ",
8934 "outerItems": [
8935 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8936 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8937 ]
8938 });
8939 let mut writer = TestWriter::new();
8940 handle(
8941 &protocol,
8942 &state,
8943 &RenderOptions::new("index.html", "/"),
8944 &mut writer,
8945 )
8946 .unwrap();
8947 assert_eq!(
8948 writer.get_content(),
8949 "<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>"
8950 );
8951 }
8952
8953 #[test]
8954 fn test_component_in_for_global_only() {
8955 let mut fragments = HashMap::new();
8956 fragments.insert(
8957 "index.html".to_string(),
8958 FragmentList {
8959 fragments: vec![
8960 WebUIFragment::raw("<div>"),
8961 WebUIFragment::for_loop("item", "items", "templateComponent"),
8962 WebUIFragment::raw("</div>"),
8963 ],
8964 contains_boundary: false,
8965 },
8966 );
8967 fragments.insert(
8968 "templateComponent".to_string(),
8969 FragmentList {
8970 fragments: vec![
8971 WebUIFragment::raw("<component-tag>"),
8972 WebUIFragment::component("my-component"),
8973 WebUIFragment::raw("</component-tag>"),
8974 ],
8975 contains_boundary: false,
8976 },
8977 );
8978 fragments.insert(
8979 "my-component".to_string(),
8980 FragmentList {
8981 fragments: vec![
8982 WebUIFragment::raw("<span>"),
8983 WebUIFragment::signal("name", false),
8984 WebUIFragment::raw("-"),
8985 WebUIFragment::signal("globalSuffix", false),
8986 WebUIFragment::raw("</span>"),
8987 ],
8988 contains_boundary: false,
8989 },
8990 );
8991 let protocol = WebUIProtocol::new(fragments);
8992 let state =
8993 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8994 let mut writer = TestWriter::new();
8995 handle(
8996 &protocol,
8997 &state,
8998 &RenderOptions::new("index.html", "/"),
8999 &mut writer,
9000 )
9001 .unwrap();
9002 assert_eq!(
9003 writer.get_content(),
9004 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9005 );
9006 }
9007
9008 #[test]
9009 fn test_component_no_item_moniker() {
9010 let mut fragments = HashMap::new();
9011 fragments.insert(
9012 "index.html".to_string(),
9013 FragmentList {
9014 fragments: vec![
9015 WebUIFragment::raw("<div>"),
9016 WebUIFragment::for_loop("item", "items", "templateComponent"),
9017 WebUIFragment::raw("</div>"),
9018 ],
9019 contains_boundary: false,
9020 },
9021 );
9022 fragments.insert(
9023 "templateComponent".to_string(),
9024 FragmentList {
9025 fragments: vec![
9026 WebUIFragment::raw("<component-tag>"),
9027 WebUIFragment::component("my-component"),
9028 WebUIFragment::raw("</component-tag>"),
9029 ],
9030 contains_boundary: false,
9031 },
9032 );
9033 fragments.insert(
9034 "my-component".to_string(),
9035 FragmentList {
9036 fragments: vec![
9037 WebUIFragment::raw("<span>"),
9038 WebUIFragment::signal("item.name", false),
9039 WebUIFragment::raw("-"),
9040 WebUIFragment::signal("globalSuffix", false),
9041 WebUIFragment::raw("</span>"),
9042 ],
9043 contains_boundary: false,
9044 },
9045 );
9046 let protocol = WebUIProtocol::new(fragments);
9047 let state =
9048 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
9049 let mut writer = TestWriter::new();
9050 handle(
9051 &protocol,
9052 &state,
9053 &RenderOptions::new("index.html", "/"),
9054 &mut writer,
9055 )
9056 .unwrap();
9057 assert_eq!(
9058 writer.get_content(),
9059 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
9060 );
9061 }
9062
9063 #[test]
9064 fn test_for_nonqualified_uses_global() {
9065 let mut fragments = HashMap::new();
9066 fragments.insert(
9067 "index.html".to_string(),
9068 FragmentList {
9069 fragments: vec![
9070 WebUIFragment::raw("<div>"),
9071 WebUIFragment::for_loop("item", "items", "template1"),
9072 WebUIFragment::raw("</div>"),
9073 ],
9074 contains_boundary: false,
9075 },
9076 );
9077 fragments.insert(
9078 "template1".to_string(),
9079 FragmentList {
9080 fragments: vec![
9081 WebUIFragment::raw("<span>"),
9082 WebUIFragment::signal("name", false),
9083 WebUIFragment::raw("</span>"),
9084 ],
9085 contains_boundary: false,
9086 },
9087 );
9088 let protocol = WebUIProtocol::new(fragments);
9089 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
9090 let mut writer = TestWriter::new();
9091 handle(
9092 &protocol,
9093 &state,
9094 &RenderOptions::new("index.html", "/"),
9095 &mut writer,
9096 )
9097 .unwrap();
9098 assert_eq!(
9099 writer.get_content(),
9100 "<div><span>GlobalName</span><span>GlobalName</span></div>"
9101 );
9102 }
9103
9104 #[test]
9105 fn test_nested_for_if_interleaved() {
9106 let mut fragments = HashMap::new();
9107 fragments.insert(
9108 "index.html".to_string(),
9109 FragmentList {
9110 fragments: vec![
9111 WebUIFragment::raw("<div>"),
9112 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9113 WebUIFragment::raw("</div>"),
9114 ],
9115 contains_boundary: false,
9116 },
9117 );
9118 fragments.insert(
9119 "outerTemplate".to_string(),
9120 FragmentList {
9121 fragments: vec![
9122 WebUIFragment::raw("<section>"),
9123 WebUIFragment::signal("globalPrefix", false),
9124 WebUIFragment::signal("outerItem.outerLabel", false),
9125 WebUIFragment::if_cond(
9126 ConditionExpr::identifier("outerItem.include"),
9127 "ifTemplate",
9128 ),
9129 WebUIFragment::raw("</section>"),
9130 ],
9131 contains_boundary: false,
9132 },
9133 );
9134 fragments.insert(
9135 "ifTemplate".to_string(),
9136 FragmentList {
9137 fragments: vec![
9138 WebUIFragment::raw("<div>"),
9139 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9140 WebUIFragment::raw("</div>"),
9141 ],
9142 contains_boundary: false,
9143 },
9144 );
9145 fragments.insert(
9146 "innerTemplate".to_string(),
9147 FragmentList {
9148 fragments: vec![
9149 WebUIFragment::raw("<p>"),
9150 WebUIFragment::signal("globalSuffix", false),
9151 WebUIFragment::raw(": "),
9152 WebUIFragment::signal("innerItem.innerLabel", false),
9153 WebUIFragment::raw("</p>"),
9154 ],
9155 contains_boundary: false,
9156 },
9157 );
9158 let protocol = WebUIProtocol::new(fragments);
9159 let state = test_json!({
9160 "globalPrefix": "Prefix: ",
9161 "globalSuffix": "Suffix",
9162 "outerItems": [
9163 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
9164 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
9165 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
9166 ]
9167 });
9168 let mut writer = TestWriter::new();
9169 handle(
9170 &protocol,
9171 &state,
9172 &RenderOptions::new("index.html", "/"),
9173 &mut writer,
9174 )
9175 .unwrap();
9176 assert_eq!(
9177 writer.get_content(),
9178 "<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>"
9179 );
9180 }
9181
9182 #[test]
9183 fn test_nested_for_if_outer_state() {
9184 let mut fragments = HashMap::new();
9185 fragments.insert(
9186 "index.html".to_string(),
9187 FragmentList {
9188 fragments: vec![
9189 WebUIFragment::raw("<div>"),
9190 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9191 WebUIFragment::raw("</div>"),
9192 ],
9193 contains_boundary: false,
9194 },
9195 );
9196 fragments.insert(
9197 "outerTemplate".to_string(),
9198 FragmentList {
9199 fragments: vec![
9200 WebUIFragment::raw("<section>"),
9201 WebUIFragment::signal("globalPrefix", false),
9202 WebUIFragment::signal("outerItem.label", false),
9203 WebUIFragment::for_loop(
9204 "middleItem",
9205 "outerItem.middleItems",
9206 "middleTemplate",
9207 ),
9208 WebUIFragment::raw("</section>"),
9209 ],
9210 contains_boundary: false,
9211 },
9212 );
9213 fragments.insert(
9214 "middleTemplate".to_string(),
9215 FragmentList {
9216 fragments: vec![
9217 WebUIFragment::raw("<div>"),
9218 WebUIFragment::if_cond(
9219 ConditionExpr::identifier("outerItem.active"),
9220 "ifTemplate",
9221 ),
9222 WebUIFragment::raw("</div>"),
9223 ],
9224 contains_boundary: false,
9225 },
9226 );
9227 fragments.insert(
9228 "ifTemplate".to_string(),
9229 FragmentList {
9230 fragments: vec![
9231 WebUIFragment::raw("<p>"),
9232 WebUIFragment::signal("middleItem.value", false),
9233 WebUIFragment::raw("</p>"),
9234 ],
9235 contains_boundary: false,
9236 },
9237 );
9238 let protocol = WebUIProtocol::new(fragments);
9239 let state = test_json!({
9240 "globalPrefix": "GP-",
9241 "outerItems": [
9242 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9243 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9244 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9245 ]
9246 });
9247 let mut writer = TestWriter::new();
9248 handle(
9249 &protocol,
9250 &state,
9251 &RenderOptions::new("index.html", "/"),
9252 &mut writer,
9253 )
9254 .unwrap();
9255 assert_eq!(
9256 writer.get_content(),
9257 "<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>"
9258 );
9259 }
9260
9261 #[test]
9262 fn test_nested_for_if_inner_state() {
9263 let mut fragments = HashMap::new();
9264 fragments.insert(
9265 "index.html".to_string(),
9266 FragmentList {
9267 fragments: vec![
9268 WebUIFragment::raw("<div>"),
9269 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9270 WebUIFragment::raw("</div>"),
9271 ],
9272 contains_boundary: false,
9273 },
9274 );
9275 fragments.insert(
9276 "outerTemplate".to_string(),
9277 FragmentList {
9278 fragments: vec![
9279 WebUIFragment::raw("<section>"),
9280 WebUIFragment::signal("outerItem.label", false),
9281 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9282 WebUIFragment::raw("</section>"),
9283 ],
9284 contains_boundary: false,
9285 },
9286 );
9287 fragments.insert(
9288 "innerTemplate".to_string(),
9289 FragmentList {
9290 fragments: vec![
9291 WebUIFragment::raw("<article>"),
9292 WebUIFragment::if_cond(
9293 ConditionExpr::identifier("innerItem.show"),
9294 "ifTemplate",
9295 ),
9296 WebUIFragment::raw("</article>"),
9297 ],
9298 contains_boundary: false,
9299 },
9300 );
9301 fragments.insert(
9302 "ifTemplate".to_string(),
9303 FragmentList {
9304 fragments: vec![
9305 WebUIFragment::raw("<p>"),
9306 WebUIFragment::signal("innerItem.detail", false),
9307 WebUIFragment::raw("</p>"),
9308 ],
9309 contains_boundary: false,
9310 },
9311 );
9312 let protocol = WebUIProtocol::new(fragments);
9313 let state = test_json!({
9314 "outerItems": [
9315 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9316 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9317 ]
9318 });
9319 let mut writer = TestWriter::new();
9320 handle(
9321 &protocol,
9322 &state,
9323 &RenderOptions::new("index.html", "/"),
9324 &mut writer,
9325 )
9326 .unwrap();
9327 assert_eq!(
9328 writer.get_content(),
9329 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9330 );
9331 }
9332
9333 #[test]
9334 fn test_for_merge_local_global_monikers() {
9335 let mut fragments = HashMap::new();
9336 fragments.insert(
9337 "index.html".to_string(),
9338 FragmentList {
9339 fragments: vec![
9340 WebUIFragment::raw("<div>"),
9341 WebUIFragment::for_loop("item", "items", "template1"),
9342 WebUIFragment::raw("</div>"),
9343 ],
9344 contains_boundary: false,
9345 },
9346 );
9347 fragments.insert(
9348 "template1".to_string(),
9349 FragmentList {
9350 fragments: vec![
9351 WebUIFragment::raw("<span>"),
9352 WebUIFragment::signal("item.name", false),
9353 WebUIFragment::raw("-"),
9354 WebUIFragment::signal("item.globalValue", false),
9355 WebUIFragment::raw("-"),
9356 WebUIFragment::signal("item.localOnly", false),
9357 WebUIFragment::raw("-"),
9358 WebUIFragment::signal("item.otherVal", false),
9359 WebUIFragment::raw("</span>"),
9360 ],
9361 contains_boundary: false,
9362 },
9363 );
9364 let protocol = WebUIProtocol::new(fragments);
9365 let state = test_json!({
9366 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9367 "items": [
9368 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9369 {"name": "Local2", "localOnly": "Only2"}
9370 ]
9371 });
9372 let mut writer = TestWriter::new();
9373 handle(
9374 &protocol,
9375 &state,
9376 &RenderOptions::new("index.html", "/"),
9377 &mut writer,
9378 )
9379 .unwrap();
9380 assert_eq!(
9381 writer.get_content(),
9382 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9383 );
9384 }
9385
9386 #[test]
9387 fn test_component_in_for_global_moniker_shadow() {
9388 let mut fragments = HashMap::new();
9389 fragments.insert(
9390 "index.html".to_string(),
9391 FragmentList {
9392 fragments: vec![
9393 WebUIFragment::raw("<div>"),
9394 WebUIFragment::for_loop("item", "items", "templateComponent"),
9395 WebUIFragment::raw("</div>"),
9396 ],
9397 contains_boundary: false,
9398 },
9399 );
9400 fragments.insert(
9401 "templateComponent".to_string(),
9402 FragmentList {
9403 fragments: vec![
9404 WebUIFragment::raw("<component-tag>"),
9405 WebUIFragment::component("my-component"),
9406 WebUIFragment::raw("</component-tag>"),
9407 ],
9408 contains_boundary: false,
9409 },
9410 );
9411 fragments.insert(
9412 "my-component".to_string(),
9413 FragmentList {
9414 fragments: vec![
9415 WebUIFragment::raw("<span>"),
9416 WebUIFragment::signal("name", false),
9417 WebUIFragment::raw("-"),
9418 WebUIFragment::signal("item.globalValue", false),
9419 WebUIFragment::raw("-"),
9420 WebUIFragment::signal("localOnly", false),
9421 WebUIFragment::raw("-"),
9422 WebUIFragment::signal("item.otherVal", false),
9423 WebUIFragment::raw("</span>"),
9424 ],
9425 contains_boundary: false,
9426 },
9427 );
9428 let protocol = WebUIProtocol::new(fragments);
9429 let state = test_json!({
9430 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9431 "items": [
9432 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9433 {"name": "Local2", "localOnly": "Only2"}
9434 ]
9435 });
9436 let mut writer = TestWriter::new();
9437 handle(
9438 &protocol,
9439 &state,
9440 &RenderOptions::new("index.html", "/"),
9441 &mut writer,
9442 )
9443 .unwrap();
9444 assert_eq!(
9445 writer.get_content(),
9446 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9447 );
9448 }
9449
9450 #[test]
9451 fn test_if_in_nested_for_local_flag() {
9452 let mut fragments = HashMap::new();
9453 fragments.insert(
9454 "index.html".to_string(),
9455 FragmentList {
9456 fragments: vec![
9457 WebUIFragment::raw("<div>"),
9458 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9459 WebUIFragment::raw("</div>"),
9460 ],
9461 contains_boundary: false,
9462 },
9463 );
9464 fragments.insert(
9465 "outerTemplate".to_string(),
9466 FragmentList {
9467 fragments: vec![
9468 WebUIFragment::raw("<section>"),
9469 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9470 WebUIFragment::raw("</section>"),
9471 ],
9472 contains_boundary: false,
9473 },
9474 );
9475 fragments.insert(
9476 "innerTemplate".to_string(),
9477 FragmentList {
9478 fragments: vec![WebUIFragment::if_cond(
9479 ConditionExpr::identifier("inner_item.flag"),
9480 "ifInner",
9481 )],
9482 contains_boundary: false,
9483 },
9484 );
9485 fragments.insert(
9486 "ifInner".to_string(),
9487 FragmentList {
9488 fragments: vec![
9489 WebUIFragment::raw("<p>"),
9490 WebUIFragment::signal("inner_item.value", false),
9491 WebUIFragment::raw("</p>"),
9492 ],
9493 contains_boundary: false,
9494 },
9495 );
9496 let protocol = WebUIProtocol::new(fragments);
9497 let state = test_json!({
9498 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9499 "inner_item": {"flag": false}
9500 });
9501 let mut writer = TestWriter::new();
9502 handle(
9503 &protocol,
9504 &state,
9505 &RenderOptions::new("index.html", "/"),
9506 &mut writer,
9507 )
9508 .unwrap();
9509 assert_eq!(
9510 writer.get_content(),
9511 "<div><section><p>X</p></section></div>"
9512 );
9513 }
9514
9515 #[test]
9516 fn test_if_in_nested_for_global_fallback() {
9517 let mut fragments = HashMap::new();
9518 fragments.insert(
9519 "index.html".to_string(),
9520 FragmentList {
9521 fragments: vec![
9522 WebUIFragment::raw("<div>"),
9523 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9524 WebUIFragment::raw("</div>"),
9525 ],
9526 contains_boundary: false,
9527 },
9528 );
9529 fragments.insert(
9530 "outerTemplate".to_string(),
9531 FragmentList {
9532 fragments: vec![
9533 WebUIFragment::raw("<section>"),
9534 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9535 WebUIFragment::raw("</section>"),
9536 ],
9537 contains_boundary: false,
9538 },
9539 );
9540 fragments.insert(
9541 "innerTemplate".to_string(),
9542 FragmentList {
9543 fragments: vec![WebUIFragment::if_cond(
9544 ConditionExpr::identifier("inner_item.flag"),
9545 "ifInner",
9546 )],
9547 contains_boundary: false,
9548 },
9549 );
9550 fragments.insert(
9551 "ifInner".to_string(),
9552 FragmentList {
9553 fragments: vec![
9554 WebUIFragment::raw("<p>"),
9555 WebUIFragment::signal("inner_item.value", false),
9556 WebUIFragment::raw("</p>"),
9557 ],
9558 contains_boundary: false,
9559 },
9560 );
9561 let protocol = WebUIProtocol::new(fragments);
9562 let state = test_json!({
9563 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9564 "inner_item": {"flag": true}
9565 });
9566 let mut writer = TestWriter::new();
9567 handle(
9568 &protocol,
9569 &state,
9570 &RenderOptions::new("index.html", "/"),
9571 &mut writer,
9572 )
9573 .unwrap();
9574 assert_eq!(
9575 writer.get_content(),
9576 "<div><section><p>X</p><p>Y</p></section></div>"
9577 );
9578 }
9579
9580 #[test]
9581 fn test_if_mixed_for_monikers() {
9582 let mut fragments = HashMap::new();
9583 fragments.insert(
9584 "index.html".to_string(),
9585 FragmentList {
9586 fragments: vec![
9587 WebUIFragment::raw("<div>"),
9588 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9589 WebUIFragment::raw("</div>"),
9590 ],
9591 contains_boundary: false,
9592 },
9593 );
9594 fragments.insert(
9595 "outerTemplate".to_string(),
9596 FragmentList {
9597 fragments: vec![
9598 WebUIFragment::raw("<section>"),
9599 WebUIFragment::signal("outer.outerLabel", false),
9600 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9601 WebUIFragment::raw("</section>"),
9602 ],
9603 contains_boundary: false,
9604 },
9605 );
9606 fragments.insert(
9607 "innerTemplate".to_string(),
9608 FragmentList {
9609 fragments: vec![WebUIFragment::if_cond(
9610 ConditionExpr::compound(
9611 ConditionExpr::identifier("outer.active"),
9612 LogicalOperator::And,
9613 ConditionExpr::predicate(
9614 "inner.value",
9615 ComparisonOperator::GreaterThan,
9616 "globalLimit",
9617 ),
9618 ),
9619 "ifInner",
9620 )],
9621 contains_boundary: false,
9622 },
9623 );
9624 fragments.insert(
9625 "ifInner".to_string(),
9626 FragmentList {
9627 fragments: vec![
9628 WebUIFragment::raw("<p>"),
9629 WebUIFragment::signal("inner.value", false),
9630 WebUIFragment::raw("</p>"),
9631 ],
9632 contains_boundary: false,
9633 },
9634 );
9635 let protocol = WebUIProtocol::new(fragments);
9636 let state = test_json!({
9637 "globalLimit": 10,
9638 "list": {"outerItems": [
9639 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9640 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9641 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9642 ]}
9643 });
9644 let mut writer = TestWriter::new();
9645 handle(
9646 &protocol,
9647 &state,
9648 &RenderOptions::new("index.html", "/"),
9649 &mut writer,
9650 )
9651 .unwrap();
9652 assert_eq!(
9653 writer.get_content(),
9654 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9655 );
9656 }
9657
9658 fn make_route_protocol() -> WebUIProtocol {
9661 use webui_protocol::WebUiFragmentRoute;
9662
9663 let mut fragments = HashMap::new();
9664
9665 fragments.insert(
9667 "index.html".to_string(),
9668 FragmentList {
9669 fragments: vec![
9670 WebUIFragment::raw("<h1>Shell</h1>"),
9671 WebUIFragment::route_from(WebUiFragmentRoute {
9672 path: "/".into(),
9673 fragment_id: "dash-page".into(),
9674 exact: true,
9675 keep_alive: false,
9676 ..Default::default()
9677 }),
9678 WebUIFragment::route_from(WebUiFragmentRoute {
9679 path: "/contacts/:id".into(),
9680 fragment_id: "detail-page".into(),
9681 exact: true,
9682 keep_alive: true,
9683 ..Default::default()
9684 }),
9685 ],
9686
9687 contains_boundary: false,
9688 },
9689 );
9690
9691 fragments.insert(
9693 "dash-page".to_string(),
9694 FragmentList {
9695 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9696 contains_boundary: false,
9697 },
9698 );
9699
9700 fragments.insert(
9702 "detail-page".to_string(),
9703 FragmentList {
9704 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9705 contains_boundary: false,
9706 },
9707 );
9708
9709 WebUIProtocol::new(fragments)
9710 }
9711
9712 fn make_nested_route_protocol() -> WebUIProtocol {
9713 use webui_protocol::WebUiFragmentRoute;
9714
9715 let mut fragments = HashMap::new();
9716
9717 fragments.insert(
9718 "index.html".to_string(),
9719 FragmentList {
9720 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9721 path: "/".into(),
9722 fragment_id: "app-shell".into(),
9723 exact: false,
9724 children: vec![WebUiFragmentRoute {
9725 path: "sections/:id".into(),
9726 fragment_id: "section-comp".into(),
9727 exact: false,
9728 children: vec![WebUiFragmentRoute {
9729 path: "topics/:topicId".into(),
9730 fragment_id: "topic-comp".into(),
9731 exact: true,
9732 children: vec![],
9733 keep_alive: false,
9734 ..Default::default()
9735 }],
9736 keep_alive: false,
9737 ..Default::default()
9738 }],
9739 keep_alive: false,
9740 ..Default::default()
9741 })],
9742
9743 contains_boundary: false,
9744 },
9745 );
9746
9747 fragments.insert(
9748 "app-shell".to_string(),
9749 FragmentList {
9750 fragments: vec![
9751 WebUIFragment::raw("<h1>Shell</h1>"),
9752 WebUIFragment::outlet(),
9753 ],
9754 contains_boundary: false,
9755 },
9756 );
9757
9758 fragments.insert(
9759 "section-comp".to_string(),
9760 FragmentList {
9761 fragments: vec![
9762 WebUIFragment::raw("<h2>Section</h2>"),
9763 WebUIFragment::outlet(),
9764 ],
9765 contains_boundary: false,
9766 },
9767 );
9768
9769 fragments.insert(
9770 "topic-comp".to_string(),
9771 FragmentList {
9772 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9773 contains_boundary: false,
9774 },
9775 );
9776
9777 WebUIProtocol::new(fragments)
9778 }
9779
9780 #[test]
9781 fn test_route_renders_shell_always() {
9782 let protocol = make_route_protocol();
9783 let state = test_json!({});
9784 let mut writer = TestWriter::new();
9785 handle(
9786 &protocol,
9787 &state,
9788 &RenderOptions::new("index.html", "/"),
9789 &mut writer,
9790 )
9791 .unwrap();
9792 let html = writer.get_content();
9793
9794 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9796 assert!(html.contains(" active>"), "matched route should be active");
9798 assert!(
9800 html.contains("style=\"display:none\""),
9801 "non-matched routes should be hidden"
9802 );
9803 }
9804
9805 #[test]
9806 fn test_route_matched_renders_visible() {
9807 let protocol = make_route_protocol();
9808 let state = test_json!({});
9809 let mut writer = TestWriter::new();
9810 handle(
9811 &protocol,
9812 &state,
9813 &RenderOptions::new("index.html", "/"),
9814 &mut writer,
9815 )
9816 .unwrap();
9817 let html = writer.get_content();
9818
9819 assert!(
9821 html.contains("<webui-route path=\"/\""),
9822 "dashboard route should exist"
9823 );
9824 assert!(
9825 html.contains("active>") && html.contains("<dash-page"),
9826 "matched route should be active with component tag: {html}"
9827 );
9828 assert!(
9829 html.contains("<p>Dashboard</p>"),
9830 "matched route should have content"
9831 );
9832 }
9833
9834 #[test]
9835 fn test_route_non_matched_renders_hidden_empty() {
9836 let protocol = make_route_protocol();
9837 let state = test_json!({});
9838 let mut writer = TestWriter::new();
9839 handle(
9840 &protocol,
9841 &state,
9842 &RenderOptions::new("index.html", "/"),
9843 &mut writer,
9844 )
9845 .unwrap();
9846 let html = writer.get_content();
9847
9848 assert!(
9850 html.contains("<webui-route path=\"/contacts/:id\""),
9851 "detail route element should exist"
9852 );
9853 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9855 let after_detail = &html[detail_start..];
9856 assert!(
9857 after_detail.contains("style=\"display:none\">")
9858 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9859 "non-matched route should be hidden: {after_detail}"
9860 );
9861 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9863 let detail_body = &after_detail[..detail_end];
9864 assert!(
9865 !detail_body.contains("<detail-page"),
9866 "non-matched route should not render component content: {detail_body}"
9867 );
9868 }
9869
9870 #[test]
9871 fn test_route_parameterized_match() {
9872 let protocol = make_route_protocol();
9873 let state = test_json!({});
9874 let mut writer = TestWriter::new();
9875 handle(
9876 &protocol,
9877 &state,
9878 &RenderOptions::new("index.html", "/contacts/42"),
9879 &mut writer,
9880 )
9881 .unwrap();
9882 let html = writer.get_content();
9883
9884 assert!(
9886 html.contains("active>") && html.contains("<detail-page"),
9887 "detail route should be active: {html}"
9888 );
9889 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9890 let dash_start = html
9892 .find("component=\"dash-page\"")
9893 .expect("dashboard route");
9894 let after_dash = &html[dash_start..];
9895 assert!(
9896 after_dash.contains("style=\"display:none\">"),
9897 "dashboard should be hidden when detail matches: {after_dash}"
9898 );
9899 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9900 let dash_body = &after_dash[..dash_end];
9901 assert!(
9902 !dash_body.contains("<dash-page"),
9903 "dashboard should not render component content: {dash_body}"
9904 );
9905 }
9906
9907 #[test]
9908 fn test_route_no_match_all_hidden_empty() {
9909 let protocol = make_route_protocol();
9910 let state = test_json!({});
9911 let mut writer = TestWriter::new();
9912 handle(
9913 &protocol,
9914 &state,
9915 &RenderOptions::new("index.html", "/nonexistent"),
9916 &mut writer,
9917 )
9918 .unwrap();
9919 let html = writer.get_content();
9920
9921 assert!(html.contains("<h1>Shell</h1>"));
9923 assert!(
9925 !html.contains("<p>Dashboard</p>"),
9926 "no route content when nothing matches"
9927 );
9928 assert!(
9929 !html.contains("<p>Detail</p>"),
9930 "no route content when nothing matches"
9931 );
9932 }
9933
9934 #[test]
9935 fn test_route_component_attr_emitted() {
9936 let protocol = make_route_protocol();
9937 let state = test_json!({});
9938 let mut writer = TestWriter::new();
9939 handle(
9940 &protocol,
9941 &state,
9942 &RenderOptions::new("index.html", "/"),
9943 &mut writer,
9944 )
9945 .unwrap();
9946 let html = writer.get_content();
9947 assert!(
9949 html.contains("component=\"dash-page\""),
9950 "component attr should be on webui-route: {html}"
9951 );
9952 assert!(
9953 html.contains("component=\"detail-page\""),
9954 "component attr should be on webui-route: {html}"
9955 );
9956 assert!(
9957 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9958 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9959 );
9960 }
9961
9962 #[test]
9963 fn test_no_plugin_no_state_attributes() {
9964 let protocol = make_route_protocol();
9965 let state = test_json!({
9966 "title": "Fish & Chips",
9967 "cartOpen": true,
9968 "items": [{"name": "A&B"}]
9969 });
9970 let mut writer = TestWriter::new();
9971
9972 handle(
9973 &protocol,
9974 &state,
9975 &RenderOptions::new("index.html", "/"),
9976 &mut writer,
9977 )
9978 .unwrap();
9979
9980 let html = writer.get_content();
9981 assert!(
9983 !html.contains("data-state"),
9984 "no data-state without plugin: {html}"
9985 );
9986 assert!(
9987 !html.contains(r#"title="Fish"#),
9988 "no scalar attrs without plugin: {html}"
9989 );
9990 }
9991
9992 #[test]
9993 fn test_nested_routes_render_webui_route_as_light_dom() {
9994 let protocol = make_nested_route_protocol();
9995 let state = test_json!({"title": "Test"});
9996 let handler = WebUIHandler::new();
9997 let mut writer = TestWriter::new();
9998
9999 handler
10000 .handle(
10001 &protocol,
10002 &state,
10003 &RenderOptions::new("index.html", "/sections/frontend"),
10004 &mut writer,
10005 )
10006 .expect("render failed");
10007
10008 let html = writer.get_content();
10009
10010 assert!(
10011 html.contains("component=\"app-shell\"") && html.contains("active>"),
10012 "root route should be active: {html}"
10013 );
10014 assert!(
10016 !html.contains("<template shadowrootmode"),
10017 "webui-route should be light DOM (no shadow template): {html}"
10018 );
10019 }
10020
10021 #[test]
10022 fn test_nested_routes_render_outlet_as_light_dom() {
10023 let protocol = make_nested_route_protocol();
10024 let state = test_json!({"title": "Test"});
10025 let handler = WebUIHandler::new();
10026 let mut writer = TestWriter::new();
10027
10028 handler
10029 .handle(
10030 &protocol,
10031 &state,
10032 &RenderOptions::new("index.html", "/sections/frontend"),
10033 &mut writer,
10034 )
10035 .expect("render failed");
10036
10037 let html = writer.get_content();
10038
10039 assert!(
10041 !html.contains("<webui-outlet>"),
10042 "should not contain webui-outlet wrapper: {html}"
10043 );
10044 assert!(
10046 html.contains("<webui-route"),
10047 "should contain webui-route elements: {html}"
10048 );
10049 }
10050
10051 #[test]
10052 fn test_nested_routes_match_child_at_outlet() {
10053 let protocol = make_nested_route_protocol();
10054 let state = test_json!({"title": "Test"});
10055 let handler = WebUIHandler::new();
10056 let mut writer = TestWriter::new();
10057
10058 handler
10059 .handle(
10060 &protocol,
10061 &state,
10062 &RenderOptions::new("index.html", "/sections/frontend"),
10063 &mut writer,
10064 )
10065 .expect("render failed");
10066
10067 let html = writer.get_content();
10068
10069 assert!(
10070 html.contains("component=\"section-comp\"") && html.contains("active>"),
10071 "section route should be active: {html}"
10072 );
10073 assert!(
10074 html.contains("<h2>Section</h2>"),
10075 "section content should be present: {html}"
10076 );
10077 }
10078
10079 #[test]
10080 fn test_nested_routes_three_levels_deep() {
10081 let protocol = make_nested_route_protocol();
10082 let state = test_json!({"title": "Test"});
10083 let handler = WebUIHandler::new();
10084 let mut writer = TestWriter::new();
10085
10086 handler
10087 .handle(
10088 &protocol,
10089 &state,
10090 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
10091 &mut writer,
10092 )
10093 .expect("render failed");
10094
10095 let html = writer.get_content();
10096
10097 assert!(
10098 html.contains("component=\"app-shell\"") && html.contains("active>"),
10099 "root active: {html}"
10100 );
10101 assert!(
10102 html.contains("component=\"section-comp\"") && html.contains("active>"),
10103 "section active: {html}"
10104 );
10105 assert!(
10106 html.contains("component=\"topic-comp\"")
10107 && html.contains("exact")
10108 && html.contains("active>"),
10109 "topic active: {html}"
10110 );
10111 assert!(
10112 html.contains("<p>Topic content</p>"),
10113 "leaf content present: {html}"
10114 );
10115 }
10116
10117 #[test]
10118 fn test_nested_routes_nonmatched_siblings_hidden() {
10119 let protocol = make_nested_route_protocol();
10120 let state = test_json!({"title": "Test"});
10121 let handler = WebUIHandler::new();
10122 let mut writer = TestWriter::new();
10123
10124 handler
10125 .handle(
10126 &protocol,
10127 &state,
10128 &RenderOptions::new("index.html", "/sections/frontend"),
10129 &mut writer,
10130 )
10131 .expect("render failed");
10132
10133 let html = writer.get_content();
10134
10135 assert!(
10136 html.contains(r#"component="topic-comp" exact style="display:none">"#),
10137 "topic should be hidden: {html}"
10138 );
10139 }
10140
10141 #[test]
10142 fn test_nested_routes_preserve_sibling_declaration_order() {
10143 let mut protocol = make_nested_route_protocol();
10144 let Some(Fragment::Route(root)) = protocol
10145 .fragments
10146 .get_mut("index.html")
10147 .and_then(|entry| entry.fragments.first_mut())
10148 .and_then(|fragment| fragment.fragment.as_mut())
10149 else {
10150 panic!("nested route fixture must have a root route");
10151 };
10152 root.children.insert(
10153 0,
10154 webui_protocol::WebUiFragmentRoute {
10155 path: String::new(),
10156 fragment_id: "topic-comp".into(),
10157 exact: true,
10158 ..Default::default()
10159 },
10160 );
10161 root.children.push(webui_protocol::WebUiFragmentRoute {
10162 path: "*rest".into(),
10163 fragment_id: "topic-comp".into(),
10164 exact: true,
10165 ..Default::default()
10166 });
10167
10168 for path in ["/", "/sections/frontend", "/missing"] {
10169 let mut writer = TestWriter::new();
10170 WebUIHandler::new()
10171 .handle(
10172 &protocol,
10173 &test_json!({}),
10174 &RenderOptions::new("index.html", path),
10175 &mut writer,
10176 )
10177 .expect("render failed");
10178 let html = writer.get_content();
10179 let first = html.find(r#"<webui-route path="""#).expect("empty route");
10180 let middle = html
10181 .find(r#"<webui-route path="sections/:id""#)
10182 .expect("section route");
10183 let last = html
10184 .find(r#"<webui-route path="*rest""#)
10185 .expect("catch-all route");
10186 assert!(first < middle && middle < last, "{path}: {html}");
10187 }
10188 }
10189
10190 #[test]
10191 fn test_nested_routes_root_only() {
10192 let protocol = make_nested_route_protocol();
10193 let state = test_json!({"title": "Test"});
10194 let handler = WebUIHandler::new();
10195 let mut writer = TestWriter::new();
10196
10197 handler
10198 .handle(
10199 &protocol,
10200 &state,
10201 &RenderOptions::new("index.html", "/"),
10202 &mut writer,
10203 )
10204 .expect("render failed");
10205
10206 let html = writer.get_content();
10207
10208 assert!(
10209 html.contains("component=\"app-shell\"") && html.contains("active>"),
10210 "root active at /: {html}"
10211 );
10212 assert!(
10213 html.contains("<h1>Shell</h1>"),
10214 "shell renders at /: {html}"
10215 );
10216 assert!(
10217 html.contains(r#"component="section-comp" style="display:none">"#),
10218 "section hidden at /: {html}"
10219 );
10220 }
10221
10222 #[test]
10225 fn test_css_module_emitted_once_inline_in_component() {
10226 let template = r#"<p><slot></slot></p>"#;
10229
10230 let mut fragments = HashMap::new();
10231 fragments.insert(
10232 "index.html".to_string(),
10233 FragmentList {
10234 fragments: vec![
10235 WebUIFragment::raw("<html><head>".to_string()),
10236 structural_fragment("head_end"),
10237 WebUIFragment::raw("</head><body><div>".to_string()),
10238 WebUIFragment::component("my-card"),
10239 WebUIFragment::raw("A".to_string()),
10240 WebUIFragment::component("my-card"),
10241 WebUIFragment::raw("B</div>".to_string()),
10242 structural_fragment("body_end"),
10243 WebUIFragment::raw("</body></html>".to_string()),
10244 ],
10245 contains_boundary: false,
10246 },
10247 );
10248 fragments.insert(
10249 "my-card".to_string(),
10250 FragmentList {
10251 fragments: vec![WebUIFragment::raw(template.to_string())],
10252 contains_boundary: false,
10253 },
10254 );
10255
10256 let mut protocol = WebUIProtocol::new(fragments);
10257 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10258 protocol
10259 .components
10260 .entry("my-card".to_string())
10261 .or_default()
10262 .css = "p{color:red}".to_string();
10263 protocol.populate_style_closures(&["index.html"]);
10264 let state = test_json!({});
10265 let mut writer = TestWriter::new();
10266
10267 handle(
10268 &protocol,
10269 &state,
10270 &RenderOptions::new("index.html", "/"),
10271 &mut writer,
10272 )
10273 .unwrap();
10274
10275 let html = writer.get_content();
10276
10277 let count = html.matches(r#"<script type="importmap""#).count();
10279 assert_eq!(
10280 count, 1,
10281 "CSS module importmap should be emitted once, got {count} in: {html}"
10282 );
10283 assert!(
10284 html.contains(r#""my-card":"data:text/css,"#),
10285 "Importmap must register my-card under a data: URI: {html}"
10286 );
10287
10288 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10290 assert_eq!(
10291 tmpl_count, 2,
10292 "Template should render twice, got {tmpl_count} in: {html}"
10293 );
10294
10295 let css_pos = html
10297 .find(r#"<script type="importmap""#)
10298 .expect("CSS module importmap missing");
10299 let body_pos = html.find("<body>").expect("<body> missing");
10300 assert!(
10301 css_pos > body_pos,
10302 "CSS module should be inline in component, not in <head>: {html}"
10303 );
10304 }
10305
10306 #[test]
10307 fn test_component_without_css_renders_normally() {
10308 let mut fragments = HashMap::new();
10310 fragments.insert(
10311 "index.html".to_string(),
10312 FragmentList {
10313 fragments: vec![WebUIFragment::component("my-card")],
10314 contains_boundary: false,
10315 },
10316 );
10317 fragments.insert(
10318 "my-card".to_string(),
10319 FragmentList {
10320 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10321 contains_boundary: false,
10322 },
10323 );
10324
10325 let protocol = WebUIProtocol::new(fragments);
10326 let state = test_json!({});
10327 let mut writer = TestWriter::new();
10328
10329 handle(
10330 &protocol,
10331 &state,
10332 &RenderOptions::new("index.html", "/"),
10333 &mut writer,
10334 )
10335 .unwrap();
10336
10337 let html = writer.get_content();
10338 assert!(
10339 html.contains("<p>hello</p>"),
10340 "Non-module component should render normally: {html}"
10341 );
10342 }
10343
10344 #[test]
10345 fn test_non_module_strategy_no_css_in_head() {
10346 let template = r#"<p>hello</p>"#;
10349
10350 let mut fragments = HashMap::new();
10351 fragments.insert(
10352 "index.html".to_string(),
10353 FragmentList {
10354 fragments: vec![
10355 WebUIFragment::raw("<html><head>".to_string()),
10356 structural_fragment("head_end"),
10357 WebUIFragment::raw("</head><body>".to_string()),
10358 WebUIFragment::component("my-card"),
10359 WebUIFragment::raw("</body></html>".to_string()),
10360 ],
10361 contains_boundary: false,
10362 },
10363 );
10364 fragments.insert(
10365 "my-card".to_string(),
10366 FragmentList {
10367 fragments: vec![WebUIFragment::raw(template.to_string())],
10368 contains_boundary: false,
10369 },
10370 );
10371
10372 let protocol = WebUIProtocol::new(fragments);
10374 let state = test_json!({});
10375 let mut writer = TestWriter::new();
10376
10377 handle(
10378 &protocol,
10379 &state,
10380 &RenderOptions::new("index.html", "/"),
10381 &mut writer,
10382 )
10383 .unwrap();
10384
10385 let html = writer.get_content();
10386
10387 assert!(
10388 !html.contains(r#"<style type="module""#),
10389 "Non-module strategy should not emit CSS module tags in <head>: {html}"
10390 );
10391 assert!(
10392 !html.contains(r#"<script type="importmap""#),
10393 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10394 );
10395 assert!(
10396 html.contains("<p>hello</p>"),
10397 "Component should still render: {html}"
10398 );
10399 }
10400
10401 #[test]
10402 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10403 let mut fragments = HashMap::new();
10404 fragments.insert(
10405 "index.html".to_string(),
10406 FragmentList {
10407 fragments: vec![
10408 WebUIFragment::raw("<html><head>".to_string()),
10409 structural_fragment("head_end"),
10410 WebUIFragment::raw("</head><body><my-card>".to_string()),
10411 WebUIFragment::component("my-card"),
10412 WebUIFragment::raw("</my-card></body></html>".to_string()),
10413 ],
10414 contains_boundary: false,
10415 },
10416 );
10417 fragments.insert(
10418 "my-card".to_string(),
10419 FragmentList {
10420 fragments: vec![WebUIFragment::raw(
10421 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10422 .to_string(),
10423 )],
10424 contains_boundary: false,
10425},
10426 );
10427
10428 let protocol = WebUIProtocol::new(fragments);
10429 let state = test_json!({});
10430 let mut writer = TestWriter::new();
10431
10432 handle(
10433 &protocol,
10434 &state,
10435 &RenderOptions::new("index.html", "/"),
10436 &mut writer,
10437 )
10438 .unwrap();
10439
10440 let html = writer.get_content();
10441
10442 assert!(
10443 html.contains("<style>.card{color:red}</style>"),
10444 "Style strategy should embed inline CSS in shadow template: {html}"
10445 );
10446 assert!(
10447 !html.contains(r#"<style type="module""#),
10448 "Style strategy should not emit legacy module CSS in <head>: {html}"
10449 );
10450 assert!(
10451 !html.contains(r#"<script type="importmap""#),
10452 "Style strategy should not emit CSS module importmaps in <head>: {html}"
10453 );
10454 }
10455
10456 #[test]
10457 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10458 let mut fragments = HashMap::new();
10461 fragments.insert(
10462 "index.html".to_string(),
10463 FragmentList {
10464 fragments: vec![
10465 WebUIFragment::raw("<html><head>".to_string()),
10466 structural_fragment("head_end"),
10467 WebUIFragment::raw("</head><body><my-card>".to_string()),
10468 WebUIFragment::component("my-card"),
10469 WebUIFragment::raw("</my-card>".to_string()),
10470 structural_fragment("body_end"),
10471 WebUIFragment::raw("</body></html>".to_string()),
10472 ],
10473 contains_boundary: false,
10474 },
10475 );
10476 fragments.insert(
10477 "my-card".to_string(),
10478 FragmentList {
10479 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10480 contains_boundary: false,
10481 },
10482 );
10483
10484 let mut protocol = WebUIProtocol::new(fragments);
10485 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10486
10487 let comp = protocol
10488 .components
10489 .entry("my-card".to_string())
10490 .or_default();
10491 comp.css_href = "my-card.css".to_string();
10492 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10493 protocol.populate_style_closures(&["index.html"]);
10494
10495 let state = test_json!({});
10496 let mut writer = TestWriter::new();
10497
10498 handle(
10499 &protocol,
10500 &state,
10501 &RenderOptions::new("index.html", "/"),
10502 &mut writer,
10503 )
10504 .unwrap();
10505
10506 let html = writer.get_content();
10507
10508 let head_end = html.find("</head>").expect("</head> missing");
10509 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10510 assert!(
10511 link_pos.is_some_and(|p| p < head_end),
10512 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10513 );
10514 assert!(
10515 !html.contains(r#"<link rel="preload""#),
10516 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10517 );
10518 assert!(
10519 !html.contains(r#"<style type="module""#),
10520 "Link strategy should not emit legacy module CSS: {html}"
10521 );
10522 assert!(
10523 !html.contains(r#"<script type="importmap""#),
10524 "Link strategy should not emit CSS module importmaps: {html}"
10525 );
10526 }
10527
10528 #[test]
10529 fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10530 let mut fragments = HashMap::new();
10537 fragments.insert(
10538 "index.html".to_string(),
10539 FragmentList {
10540 fragments: vec![
10541 WebUIFragment::raw("<html><head>".to_string()),
10542 structural_fragment("head_end"),
10543 WebUIFragment::raw(
10544 "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10545 .to_string(),
10546 ),
10547 WebUIFragment::component("comp-a"),
10548 WebUIFragment::component("comp-b"),
10549 WebUIFragment::component("comp-c"),
10550 structural_fragment("body_end"),
10551 WebUIFragment::raw("</body></html>".to_string()),
10552 ],
10553 contains_boundary: false,
10554 },
10555 );
10556 for tag in ["comp-a", "comp-b", "comp-c"] {
10557 fragments.insert(
10558 tag.to_string(),
10559 FragmentList {
10560 fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10561 contains_boundary: false,
10562 },
10563 );
10564 }
10565
10566 let mut protocol = WebUIProtocol::new(fragments);
10567 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10568 for tag in ["comp-a", "comp-b", "comp-c"] {
10569 let comp = protocol.components.entry(tag.to_string()).or_default();
10570 comp.css_href = format!("{tag}.css");
10571 comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10572 }
10573 protocol.populate_style_closures(&["index.html"]);
10574
10575 let state = test_json!({});
10576 let mut writer = TestWriter::new();
10577 handle(
10578 &protocol,
10579 &state,
10580 &RenderOptions::new("index.html", "/"),
10581 &mut writer,
10582 )
10583 .unwrap();
10584 let html = writer.get_content();
10585
10586 let pos_a = html
10587 .find(r#"href="comp-a.css""#)
10588 .expect("comp-a.css link missing");
10589 let pos_b = html
10590 .find(r#"href="comp-b.css""#)
10591 .expect("comp-b.css link missing");
10592 let pos_c = html
10593 .find(r#"href="comp-c.css""#)
10594 .expect("comp-c.css link missing");
10595 assert!(
10596 pos_a < pos_b && pos_b < pos_c,
10597 "stylesheet <link> tags must follow document/traversal order \
10598 (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10599 );
10600 }
10601
10602 #[test]
10603 fn link_strategy_preloads_static_shadow_css_in_head() {
10604 let mut fragments = HashMap::new();
10605 fragments.insert(
10606 "index.html".to_string(),
10607 FragmentList {
10608 fragments: vec![
10609 WebUIFragment::raw("<html><head>".to_string()),
10610 structural_fragment("head_end"),
10611 WebUIFragment::raw("</head><body><my-card>".to_string()),
10612 WebUIFragment::component("my-card"),
10613 WebUIFragment::raw("</my-card></body></html>".to_string()),
10614 ],
10615 contains_boundary: false,
10616 },
10617 );
10618 fragments.insert(
10619 "o-loading-state".to_string(),
10620 FragmentList {
10621 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10622 contains_boundary: false,
10623 },
10624 );
10625 fragments.insert(
10626 "my-card".to_string(),
10627 FragmentList {
10628 fragments: vec![
10629 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10630 structural_fragment("shadow_styles:my-card"),
10631 WebUIFragment::raw("<div>card</div></template>".to_string()),
10632 ],
10633 contains_boundary: false,
10634 },
10635 );
10636
10637 let mut protocol = WebUIProtocol::new(fragments);
10638 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10639 let component = protocol
10640 .components
10641 .entry("my-card".to_string())
10642 .or_default();
10643 component.css_href = "my-card.css".to_string();
10644 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10645 component.uses_shadow_dom = true;
10646 protocol.populate_style_closures(&["index.html"]);
10647
10648 let mut writer = TestWriter::new();
10649 handle(
10650 &protocol,
10651 &test_json!({}),
10652 &RenderOptions::new("index.html", "/"),
10653 &mut writer,
10654 )
10655 .unwrap();
10656
10657 let html = writer.get_content();
10658 let head_end = html.find("</head>").expect("</head> missing");
10659 let preload = html
10660 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10661 .expect("Shadow preload missing");
10662 let stylesheet = html
10663 .find(r#"<link rel="stylesheet" href="my-card.css""#)
10664 .expect("Shadow stylesheet missing");
10665 assert!(preload < head_end);
10666 assert!(stylesheet > head_end);
10667 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10668 }
10669
10670 #[test]
10671 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10672 let fragments = HashMap::from([
10673 (
10674 "index.html".to_string(),
10675 FragmentList {
10676 fragments: vec![
10677 WebUIFragment::raw("<html><head>".to_string()),
10678 structural_fragment("head_end"),
10679 WebUIFragment::raw("</head><body><outer-box>".to_string()),
10680 WebUIFragment::component("outer-box"),
10681 WebUIFragment::raw("</outer-box></body></html>".to_string()),
10682 ],
10683 contains_boundary: false,
10684 },
10685 ),
10686 (
10687 "outer-box".to_string(),
10688 FragmentList {
10689 fragments: vec![
10690 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10691 structural_fragment("shadow_styles:outer-box"),
10692 WebUIFragment::raw("<light-card>".to_string()),
10693 WebUIFragment::component("light-card"),
10694 WebUIFragment::raw("</light-card><light-card>".to_string()),
10695 WebUIFragment::component("light-card"),
10696 WebUIFragment::raw("</light-card><inner-box>".to_string()),
10697 WebUIFragment::component("inner-box"),
10698 WebUIFragment::raw("</inner-box><light-card>".to_string()),
10699 WebUIFragment::component("light-card"),
10700 WebUIFragment::raw("</light-card></template>".to_string()),
10701 ],
10702 contains_boundary: false,
10703 },
10704 ),
10705 (
10706 "inner-box".to_string(),
10707 FragmentList {
10708 fragments: vec![
10709 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10710 structural_fragment("shadow_styles:inner-box"),
10711 WebUIFragment::raw("<light-card>".to_string()),
10712 WebUIFragment::component("light-card"),
10713 WebUIFragment::raw("</light-card></template>".to_string()),
10714 ],
10715 contains_boundary: false,
10716 },
10717 ),
10718 (
10719 "light-card".to_string(),
10720 FragmentList {
10721 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10722 contains_boundary: false,
10723 },
10724 ),
10725 ]);
10726 let mut protocol = WebUIProtocol::new(fragments);
10727 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10728 for (tag, css, uses_shadow_dom) in [
10729 ("outer-box", ".outer{}", true),
10730 ("inner-box", ".inner{}", true),
10731 ("light-card", ".card{}", false),
10732 ] {
10733 let component = protocol.components.entry(tag.to_string()).or_default();
10734 component.css = css.to_string();
10735 component.uses_shadow_dom = uses_shadow_dom;
10736 }
10737 protocol.populate_style_closures(&["index.html"]);
10738
10739 let mut writer = TestWriter::new();
10740 handle(
10741 &protocol,
10742 &test_json!({}),
10743 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10744 &mut writer,
10745 )
10746 .unwrap();
10747 let html = writer.get_content();
10748
10749 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10750 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10751 assert_eq!(
10752 html.matches("data-webui-resource=\"light-card\"").count(),
10753 2,
10754 "the Light resource installs once in each Shadow tree: {html}"
10755 );
10756 assert_eq!(
10757 html.matches("data-webui-resource=").count(),
10758 4,
10759 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10760 );
10761 assert_eq!(
10762 html.matches("nonce=\"css-nonce\"").count(),
10763 4,
10764 "each inline style resource carries the nonce"
10765 );
10766 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10767 let inner_end = html.find("</inner-box>").expect("inner host close");
10768 let caller_continuation = &html[inner_end..];
10769 let trailing_card = caller_continuation
10770 .find("<p>card</p>")
10771 .expect("caller continues after nested Shadow root");
10772 assert!(trailing_card > 0);
10773 assert!(
10774 !caller_continuation.contains("data-webui-resource=\"light-card\""),
10775 "returning from the nested Shadow root must restore the outer frame's delivered set"
10776 );
10777 }
10778
10779 #[test]
10780 fn routed_document_styles_are_hoisted_for_the_active_chain() {
10781 let route = WebUiFragmentRoute {
10782 path: "/".to_string(),
10783 fragment_id: "app-shell".to_string(),
10784 children: vec![
10785 WebUiFragmentRoute {
10786 path: String::new(),
10787 fragment_id: "dashboard-page".to_string(),
10788 exact: true,
10789 ..Default::default()
10790 },
10791 WebUiFragmentRoute {
10792 path: "details".to_string(),
10793 fragment_id: "detail-page".to_string(),
10794 exact: true,
10795 ..Default::default()
10796 },
10797 ],
10798 ..Default::default()
10799 };
10800 let fragments = HashMap::from([
10801 (
10802 "index.html".to_string(),
10803 FragmentList {
10804 fragments: vec![
10805 WebUIFragment::raw("<html><head>"),
10806 structural_fragment("head_start"),
10807 structural_fragment("head_end"),
10808 WebUIFragment::raw("</head><body>"),
10809 structural_fragment("body_start"),
10810 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10811 WebUIFragment::route_from(route),
10812 WebUIFragment::boundary_end(0),
10813 structural_fragment("body_end"),
10814 WebUIFragment::raw("</body></html>"),
10815 ],
10816 contains_boundary: true,
10817 },
10818 ),
10819 (
10820 "app-shell".to_string(),
10821 FragmentList {
10822 fragments: vec![
10823 WebUIFragment::raw("<header>App</header>"),
10824 WebUIFragment::outlet(),
10825 ],
10826 contains_boundary: false,
10827 },
10828 ),
10829 (
10830 "dashboard-page".to_string(),
10831 FragmentList {
10832 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10833 contains_boundary: false,
10834 },
10835 ),
10836 (
10837 "detail-page".to_string(),
10838 FragmentList {
10839 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10840 contains_boundary: false,
10841 },
10842 ),
10843 ]);
10844 let mut protocol = WebUIProtocol::new(fragments);
10845 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10846 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10847 let component = protocol.components.entry(tag.to_string()).or_default();
10848 component.css_href = format!("/{tag}.css");
10849 component.template_json = r#"{"h":""}"#.to_string();
10850 }
10851 protocol.populate_style_closures(&["index.html"]);
10852
10853 let mut writer = TestWriter::new();
10854 handle(
10855 &protocol,
10856 &test_json!({}),
10857 &RenderOptions::new("index.html", "/"),
10858 &mut writer,
10859 )
10860 .unwrap();
10861 let html = writer.get_content();
10862
10863 assert_eq!(
10864 html.matches(r#"data-webui-resource="app-shell""#).count(),
10865 1,
10866 "the matched route shell must install once: {html}"
10867 );
10868 assert_eq!(
10869 html.matches(r#"data-webui-resource="dashboard-page""#)
10870 .count(),
10871 1,
10872 "the matched child route must install once: {html}"
10873 );
10874 assert!(
10875 !html.contains(r#"data-webui-resource="detail-page""#),
10876 "inactive route CSS must not be delivered: {html}"
10877 );
10878 let head_end = html.find("</head>").expect("head close");
10879 let head = &html[..head_end];
10880 for (resource, href) in [
10881 ("app-shell", "/app-shell.css"),
10882 ("dashboard-page", "/dashboard-page.css"),
10883 ] {
10884 let encoded_href = crate::html_encode::encode_safe(href);
10885 assert!(
10886 head.contains(&format!(
10887 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10888 )),
10889 "Document-targeted route CSS must be render-blocking in head: {html}"
10890 );
10891 assert!(
10892 !head.contains(&format!(
10893 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10894 )),
10895 "a hoisted stylesheet must not also be preloaded: {html}"
10896 );
10897 }
10898 assert!(
10899 !head.contains("detail-page.css"),
10900 "inactive route CSS must not be preloaded: {html}"
10901 );
10902 let app_style = html
10903 .find(r#"data-webui-resource="app-shell""#)
10904 .expect("app style");
10905 let dashboard_style = html
10906 .find(r#"data-webui-resource="dashboard-page""#)
10907 .expect("dashboard style");
10908 let app_host = html.find("<app-shell").expect("app route host");
10909 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10910 assert!(
10911 app_style < dashboard_style
10912 && dashboard_style < head_end
10913 && head_end < app_host
10914 && app_host < dashboard_host,
10915 "matched Document route closures must be hoisted in chain order: {html}"
10916 );
10917
10918 let protocol = Protocol::new(protocol);
10919 let mut streamed = FlushTestWriter::default();
10920 WebUIHandler::new()
10921 .render_streaming(
10922 &protocol,
10923 &test_json!({}),
10924 &RenderOptions::new("index.html", "/"),
10925 &mut streamed,
10926 )
10927 .unwrap();
10928 let streamed_head_end = streamed
10929 .output
10930 .find("</head>")
10931 .expect("streamed head close");
10932 for resource in ["app-shell", "dashboard-page"] {
10933 let marker = format!(r#"data-webui-resource="{resource}""#);
10934 let position = streamed.output.find(&marker).expect("streamed route style");
10935 assert!(
10936 position < streamed_head_end,
10937 "streaming must hoist {resource} into head: {}",
10938 streamed.output
10939 );
10940 assert_eq!(
10941 streamed.output.matches(&marker).count(),
10942 1,
10943 "streaming must not emit {resource} twice: {}",
10944 streamed.output
10945 );
10946 }
10947 }
10948
10949 #[test]
10950 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10951 let route = WebUiFragmentRoute {
10952 path: "/".to_string(),
10953 fragment_id: "dashboard-page".to_string(),
10954 exact: true,
10955 ..Default::default()
10956 };
10957 let mut protocol = WebUIProtocol::new(HashMap::from([
10958 (
10959 "index.html".to_string(),
10960 FragmentList {
10961 fragments: vec![
10962 WebUIFragment::raw("<html><head>"),
10963 structural_fragment("head_start"),
10964 structural_fragment("head_end"),
10965 WebUIFragment::raw("</head><body>"),
10966 WebUIFragment::route_from(route),
10967 structural_fragment("body_end"),
10968 WebUIFragment::raw("</body></html>"),
10969 ],
10970 contains_boundary: false,
10971 },
10972 ),
10973 (
10974 "dashboard-page".to_string(),
10975 FragmentList {
10976 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10977 contains_boundary: false,
10978 },
10979 ),
10980 ]));
10981 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10982 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10983 protocol
10984 .components
10985 .entry(tag.to_string())
10986 .or_default()
10987 .css_href = format!("/{tag}.css");
10988 }
10989 protocol.style_closures.insert(
10990 "index.html".to_string(),
10991 webui_protocol::ComponentStyleClosure::default(),
10992 );
10993 protocol.style_closures.insert(
10994 "dashboard-page".to_string(),
10995 webui_protocol::ComponentStyleClosure {
10996 component_tags: vec![
10997 "dashboard-page".to_string(),
10998 "summary-card".to_string(),
10999 "activity-list".to_string(),
11000 ],
11001 style_chunks: vec![0],
11002 },
11003 );
11004 protocol.style_chunks.push(webui_protocol::StyleChunk {
11005 name: "_chunk-dashboard-page-3".to_string(),
11006 css: String::new(),
11007 css_href: "/_chunk-dashboard-page-3.css".to_string(),
11008 component_tags: vec![
11009 "dashboard-page".to_string(),
11010 "summary-card".to_string(),
11011 "activity-list".to_string(),
11012 ],
11013 });
11014
11015 let mut writer = TestWriter::new();
11016 handle(
11017 &protocol,
11018 &test_json!({}),
11019 &RenderOptions::new("index.html", "/"),
11020 &mut writer,
11021 )
11022 .unwrap();
11023 let html = writer.get_content();
11024 let head_end = html.find("</head>").expect("head close");
11025 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
11026 let style = html.find(marker).expect("bundled route stylesheet");
11027
11028 assert!(
11029 style < head_end,
11030 "bundled route CSS must be in head: {html}"
11031 );
11032 assert_eq!(
11033 html.matches(marker).count(),
11034 1,
11035 "the route host must not emit the hoisted chunk again: {html}"
11036 );
11037 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
11038 assert!(
11039 !html.contains(&format!(
11040 r#"<link rel="preload" as="style" href="{bundled_href}">"#
11041 )),
11042 "the applied bundled stylesheet must not also be preloaded: {html}"
11043 );
11044 }
11045
11046 #[test]
11047 fn routed_document_inline_styles_are_hoisted() {
11048 for strategy in [
11049 webui_protocol::CssStrategy::Style,
11050 webui_protocol::CssStrategy::Module,
11051 ] {
11052 let route = WebUiFragmentRoute {
11053 path: "/".to_string(),
11054 fragment_id: "dashboard-page".to_string(),
11055 exact: true,
11056 ..Default::default()
11057 };
11058 let mut protocol = WebUIProtocol::new(HashMap::from([
11059 (
11060 "index.html".to_string(),
11061 FragmentList {
11062 fragments: vec![
11063 WebUIFragment::raw("<html><head>"),
11064 structural_fragment("head_start"),
11065 structural_fragment("head_end"),
11066 WebUIFragment::raw("</head><body>"),
11067 WebUIFragment::route_from(route),
11068 structural_fragment("body_end"),
11069 WebUIFragment::raw("</body></html>"),
11070 ],
11071 contains_boundary: false,
11072 },
11073 ),
11074 (
11075 "dashboard-page".to_string(),
11076 FragmentList {
11077 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11078 contains_boundary: false,
11079 },
11080 ),
11081 ]));
11082 protocol.set_css_strategy(strategy);
11083 protocol
11084 .components
11085 .entry("dashboard-page".to_string())
11086 .or_default()
11087 .css = ".dashboard{display:grid}".to_string();
11088 protocol.populate_style_closures(&["index.html"]);
11089
11090 let mut writer = TestWriter::new();
11091 handle(
11092 &protocol,
11093 &test_json!({}),
11094 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
11095 &mut writer,
11096 )
11097 .unwrap();
11098 let html = writer.get_content();
11099 let head_end = html.find("</head>").expect("head close");
11100 let marker = r#"data-webui-resource="dashboard-page""#;
11101 let style = html.find(marker).expect("route style fallback");
11102 let host = html.find("<dashboard-page").expect("dashboard route host");
11103 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
11104 "module"
11105 } else {
11106 "style"
11107 };
11108 let style_marker = format!(
11109 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
11110 );
11111
11112 assert!(
11113 style < head_end && head_end < host,
11114 "{strategy:?} route CSS must be applied in head before its host: {html}"
11115 );
11116 assert_eq!(
11117 html.matches(&style_marker).count(),
11118 1,
11119 "{strategy:?} route CSS must not be emitted twice: {html}"
11120 );
11121 }
11122 }
11123
11124 #[test]
11125 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
11126 let route = WebUiFragmentRoute {
11127 path: "/".to_string(),
11128 fragment_id: "dashboard-page".to_string(),
11129 exact: true,
11130 ..Default::default()
11131 };
11132 let mut protocol = WebUIProtocol::new(HashMap::from([
11133 (
11134 "index.html".to_string(),
11135 FragmentList {
11136 fragments: vec![
11137 WebUIFragment::raw("<html><head>"),
11138 structural_fragment("head_start"),
11139 structural_fragment("head_end"),
11140 WebUIFragment::raw("</head><body><outer-box>"),
11141 WebUIFragment::component("outer-box"),
11142 WebUIFragment::raw("</outer-box></body></html>"),
11143 ],
11144 contains_boundary: false,
11145 },
11146 ),
11147 (
11148 "outer-box".to_string(),
11149 FragmentList {
11150 fragments: vec![
11151 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11152 structural_fragment("shadow_styles:outer-box"),
11153 WebUIFragment::route_from(route),
11154 WebUIFragment::raw("</template>"),
11155 ],
11156 contains_boundary: false,
11157 },
11158 ),
11159 (
11160 "dashboard-page".to_string(),
11161 FragmentList {
11162 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11163 contains_boundary: false,
11164 },
11165 ),
11166 ]));
11167 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11168 for tag in ["outer-box", "dashboard-page"] {
11169 protocol
11170 .components
11171 .entry(tag.to_string())
11172 .or_default()
11173 .css_href = format!("/{tag}.css");
11174 }
11175 protocol
11176 .components
11177 .get_mut("outer-box")
11178 .expect("outer-box component")
11179 .uses_shadow_dom = true;
11180 protocol.populate_style_closures(&["index.html"]);
11181
11182 let mut writer = TestWriter::new();
11183 handle(
11184 &protocol,
11185 &test_json!({}),
11186 &RenderOptions::new("index.html", "/"),
11187 &mut writer,
11188 )
11189 .unwrap();
11190 let html = writer.get_content();
11191 let head_end = html.find("</head>").expect("head close");
11192 let head = &html[..head_end];
11193 let dashboard_style = html
11194 .find(r#"data-webui-resource="dashboard-page""#)
11195 .expect("tree-local dashboard stylesheet");
11196 let shadow_start = html
11197 .find("<template shadowrootmode=\"open\">")
11198 .expect("Shadow template");
11199 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11200 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
11201
11202 assert!(
11203 head.contains(&format!(
11204 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
11205 )),
11206 "Shadow-targeted route CSS must start loading from head: {html}"
11207 );
11208 assert!(
11209 !head.contains(r#"data-webui-resource="dashboard-page""#),
11210 "Shadow-targeted route CSS must not be applied to Document: {html}"
11211 );
11212 assert!(
11213 shadow_start < dashboard_style && dashboard_style < dashboard_host,
11214 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
11215 );
11216 }
11217
11218 #[test]
11219 fn isolated_shadow_entry_does_not_emit_document_preloads() {
11220 let route = WebUiFragmentRoute {
11221 path: "/".to_string(),
11222 fragment_id: "dashboard-page".to_string(),
11223 exact: true,
11224 ..Default::default()
11225 };
11226 let mut protocol = WebUIProtocol::new(HashMap::from([
11227 (
11228 "outer-box".to_string(),
11229 FragmentList {
11230 fragments: vec![
11231 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11232 structural_fragment("shadow_styles:outer-box"),
11233 WebUIFragment::route_from(route),
11234 WebUIFragment::raw("</template>"),
11235 ],
11236 contains_boundary: false,
11237 },
11238 ),
11239 (
11240 "dashboard-page".to_string(),
11241 FragmentList {
11242 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11243 contains_boundary: false,
11244 },
11245 ),
11246 ]));
11247 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11248 for tag in ["outer-box", "dashboard-page"] {
11249 protocol
11250 .components
11251 .entry(tag.to_string())
11252 .or_default()
11253 .css_href = format!("/{tag}.css");
11254 }
11255 protocol
11256 .components
11257 .get_mut("outer-box")
11258 .expect("outer-box component")
11259 .uses_shadow_dom = true;
11260 protocol.populate_style_closures(&["outer-box"]);
11261
11262 let mut writer = TestWriter::new();
11263 handle(
11264 &protocol,
11265 &test_json!({}),
11266 &RenderOptions::new("outer-box", "/"),
11267 &mut writer,
11268 )
11269 .unwrap();
11270 let html = writer.get_content();
11271
11272 assert!(
11273 html.starts_with("<template shadowrootmode=\"open\">"),
11274 "isolated component output must not be prefixed with document metadata: {html}"
11275 );
11276 assert!(
11277 !html.contains(r#"<link rel="preload""#),
11278 "an isolated Shadow entry has no Document head for preloads: {html}"
11279 );
11280 assert!(
11281 html.contains(r#"data-webui-resource="dashboard-page""#),
11282 "the route stylesheet must still install inside the ShadowRoot: {html}"
11283 );
11284 }
11285
11286 #[test]
11287 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11288 let route = WebUiFragmentRoute {
11289 path: "/".to_string(),
11290 fragment_id: "app-shell".to_string(),
11291 children: vec![
11292 WebUiFragmentRoute {
11293 path: String::new(),
11294 fragment_id: "dashboard-page".to_string(),
11295 exact: true,
11296 ..Default::default()
11297 },
11298 WebUiFragmentRoute {
11299 path: "details".to_string(),
11300 fragment_id: "detail-page".to_string(),
11301 exact: true,
11302 ..Default::default()
11303 },
11304 ],
11305 ..Default::default()
11306 };
11307 let fragments = HashMap::from([
11308 (
11309 "index.html".to_string(),
11310 FragmentList {
11311 fragments: vec![
11312 WebUIFragment::raw("<html><head>"),
11313 structural_fragment("head_start"),
11314 structural_fragment("head_end"),
11315 WebUIFragment::raw("</head><body>"),
11316 structural_fragment("body_start"),
11317 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11318 WebUIFragment::route_from(route),
11319 WebUIFragment::boundary_end(0),
11320 structural_fragment("body_end"),
11321 WebUIFragment::raw("</body></html>"),
11322 ],
11323 contains_boundary: true,
11324 },
11325 ),
11326 (
11327 "app-shell".to_string(),
11328 FragmentList {
11329 fragments: vec![
11330 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11331 structural_fragment("shadow_styles:app-shell"),
11332 WebUIFragment::raw("<shared-card"),
11333 structural_fragment("streaming_root:shared-card"),
11334 WebUIFragment::raw(">"),
11335 WebUIFragment::component("shared-card"),
11336 WebUIFragment::raw("</shared-card>"),
11337 WebUIFragment::outlet(),
11338 WebUIFragment::raw("</template>"),
11339 ],
11340 contains_boundary: false,
11341 },
11342 ),
11343 (
11344 "dashboard-page".to_string(),
11345 FragmentList {
11346 fragments: vec![
11347 WebUIFragment::raw("<shared-card"),
11348 structural_fragment("streaming_root:shared-card"),
11349 WebUIFragment::raw(">"),
11350 WebUIFragment::component("shared-card"),
11351 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11352 ],
11353 contains_boundary: false,
11354 },
11355 ),
11356 (
11357 "detail-page".to_string(),
11358 FragmentList {
11359 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11360 contains_boundary: false,
11361 },
11362 ),
11363 (
11364 "shared-card".to_string(),
11365 FragmentList {
11366 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11367 contains_boundary: false,
11368 },
11369 ),
11370 ]);
11371 let mut protocol = WebUIProtocol::new(fragments);
11372 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11373 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11374 let component = protocol.components.entry(tag.to_string()).or_default();
11375 component.css = format!(".{tag}{{display:block}}");
11376 component.template_json = r#"{"h":""}"#.to_string();
11377 }
11378 protocol
11379 .components
11380 .get_mut("app-shell")
11381 .expect("app shell component")
11382 .uses_shadow_dom = true;
11383 protocol.populate_style_closures(&["index.html"]);
11384
11385 let mut writer = TestWriter::new();
11386 handle(
11387 &protocol,
11388 &test_json!({}),
11389 &RenderOptions::new("index.html", "/"),
11390 &mut writer,
11391 )
11392 .unwrap();
11393 let html = writer.get_content();
11394
11395 for resource in ["app-shell", "shared-card", "dashboard-page"] {
11396 assert_eq!(
11397 html.matches(&format!(r#"data-webui-resource="{resource}""#))
11398 .count(),
11399 1,
11400 "{resource} must install once in the active ShadowRoot: {html}"
11401 );
11402 }
11403 assert!(
11404 !html.contains(r#"data-webui-resource="detail-page""#),
11405 "inactive route CSS must not enter the ShadowRoot: {html}"
11406 );
11407 let shadow_start = html
11408 .find("<template shadowrootmode=\"open\">")
11409 .expect("Shadow template");
11410 let dashboard_style = html
11411 .find(r#"data-webui-resource="dashboard-page""#)
11412 .expect("dashboard style");
11413 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11414 let shadow_end = html.find("</template>").expect("Shadow template close");
11415 assert!(
11416 shadow_start < dashboard_style
11417 && dashboard_style < dashboard_host
11418 && dashboard_host < shadow_end,
11419 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11420 );
11421
11422 let protocol = Protocol::new(protocol);
11423 let mut streamed = FlushTestWriter::default();
11424 WebUIHandler::new()
11425 .render_streaming(
11426 &protocol,
11427 &test_json!({}),
11428 &RenderOptions::new("index.html", "/"),
11429 &mut streamed,
11430 )
11431 .unwrap();
11432 for resource in ["app-shell", "shared-card"] {
11433 assert_eq!(
11434 streamed
11435 .output
11436 .matches(&format!(r#"data-webui-resource="{resource}""#))
11437 .count(),
11438 1,
11439 "streaming must preserve tree-local delivery for {resource}: {}",
11440 streamed.output
11441 );
11442 }
11443 assert!(
11444 !streamed
11445 .output
11446 .contains(r#"data-webui-resource="dashboard-page""#),
11447 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11448 streamed.output
11449 );
11450 assert!(
11451 streamed
11452 .output
11453 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11454 && streamed
11455 .output
11456 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11457 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11458 streamed.output
11459 );
11460 assert!(
11461 !streamed
11462 .output
11463 .contains(r#"data-webui-resource="detail-page""#),
11464 "streaming must omit inactive route CSS: {}",
11465 streamed.output
11466 );
11467 }
11468
11469 #[test]
11470 fn document_styles_follow_doctype_before_headless_document_content() {
11471 for entry_fragments in [
11472 vec![
11473 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11474 structural_fragment("body_start"),
11475 WebUIFragment::raw("<my-card>"),
11476 WebUIFragment::component("my-card"),
11477 WebUIFragment::raw("</my-card></body></html>"),
11478 ],
11479 vec![
11480 WebUIFragment::raw("<!doctype html><html><my-card>"),
11481 WebUIFragment::component("my-card"),
11482 WebUIFragment::raw("</my-card></html>"),
11483 ],
11484 ] {
11485 let mut protocol = WebUIProtocol::new(HashMap::from([
11486 (
11487 "index.html".to_string(),
11488 FragmentList {
11489 fragments: entry_fragments,
11490 contains_boundary: false,
11491 },
11492 ),
11493 (
11494 "my-card".to_string(),
11495 FragmentList {
11496 fragments: vec![WebUIFragment::raw("<p>card</p>")],
11497 contains_boundary: false,
11498 },
11499 ),
11500 ]));
11501 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11502 let component = protocol
11503 .components
11504 .entry("my-card".to_string())
11505 .or_default();
11506 component.css = ".card{color:red}".to_string();
11507 component.uses_shadow_dom = false;
11508 protocol.populate_style_closures(&["index.html"]);
11509
11510 let mut writer = TestWriter::new();
11511 handle(
11512 &protocol,
11513 &test_json!({}),
11514 &RenderOptions::new("index.html", "/"),
11515 &mut writer,
11516 )
11517 .expect("render");
11518 let html = writer.get_content();
11519
11520 assert!(
11521 html.to_ascii_lowercase().starts_with("<!doctype html>"),
11522 "the doctype must remain the first document token: {html}"
11523 );
11524 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11525 let document = html.find("<html").expect("document root");
11526 let component = html.find("<my-card>").expect("component host");
11527 assert!(
11528 style < document && style < component,
11529 "headless document styles must precede document content: {html}"
11530 );
11531 }
11532 }
11533
11534 #[test]
11535 fn authored_shadow_component_can_render_as_entry_fragment() {
11536 let mut protocol = WebUIProtocol::new(HashMap::from([(
11537 "my-card".to_string(),
11538 FragmentList {
11539 fragments: vec![
11540 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11541 structural_fragment("shadow_styles:my-card"),
11542 WebUIFragment::raw("<p>card</p></template>"),
11543 ],
11544 contains_boundary: false,
11545 },
11546 )]));
11547 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11548 let component = protocol
11549 .components
11550 .entry("my-card".to_string())
11551 .or_default();
11552 component.css = ".card{color:red}".to_string();
11553 component.uses_shadow_dom = true;
11554 protocol.populate_style_closures(&["my-card"]);
11555
11556 let mut writer = TestWriter::new();
11557 handle(
11558 &protocol,
11559 &test_json!({}),
11560 &RenderOptions::new("my-card", "/"),
11561 &mut writer,
11562 )
11563 .expect("render");
11564 let html = writer.get_content();
11565
11566 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11567 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11568 assert!(html.ends_with("<p>card</p></template>"));
11569 }
11570
11571 #[test]
11572 fn component_style_closures_escape_mixed_case_style_end_tags() {
11573 for strategy in [
11574 webui_protocol::CssStrategy::Style,
11575 webui_protocol::CssStrategy::Module,
11576 ] {
11577 let mut protocol = WebUIProtocol::new(HashMap::from([
11578 (
11579 "index.html".to_string(),
11580 FragmentList {
11581 fragments: vec![
11582 WebUIFragment::raw("<html><head>"),
11583 structural_fragment("head_end"),
11584 WebUIFragment::raw("</head><body><safe-card>"),
11585 WebUIFragment::component("safe-card"),
11586 WebUIFragment::raw("</safe-card></body></html>"),
11587 ],
11588 contains_boundary: false,
11589 },
11590 ),
11591 (
11592 "safe-card".to_string(),
11593 FragmentList {
11594 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11595 contains_boundary: false,
11596 },
11597 ),
11598 ]));
11599 protocol.set_css_strategy(strategy);
11600 protocol.components.insert(
11601 "safe-card".to_string(),
11602 webui_protocol::ComponentData {
11603 css: ".safe{content:'</StYlE>'}".to_string(),
11604 ..Default::default()
11605 },
11606 );
11607 protocol.populate_style_closures(&["index.html"]);
11608 let mut writer = TestWriter::new();
11609
11610 handle(
11611 &protocol,
11612 &test_json!({}),
11613 &RenderOptions::new("index.html", "/"),
11614 &mut writer,
11615 )
11616 .unwrap();
11617
11618 let html = writer.get_content();
11619 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11620 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11621 }
11622 }
11623
11624 #[test]
11625 fn shadow_closure_supports_link_and_module_ssr_paths() {
11626 for strategy in [
11627 webui_protocol::CssStrategy::Link,
11628 webui_protocol::CssStrategy::Module,
11629 ] {
11630 let mut protocol = WebUIProtocol::new(HashMap::from([
11631 (
11632 "index.html".to_string(),
11633 FragmentList {
11634 fragments: vec![
11635 WebUIFragment::raw("<html><head>".to_string()),
11636 structural_fragment("head_end"),
11637 WebUIFragment::raw("</head><body><my-card>".to_string()),
11638 WebUIFragment::component("my-card"),
11639 WebUIFragment::raw("</my-card></body></html>".to_string()),
11640 ],
11641 contains_boundary: false,
11642 },
11643 ),
11644 (
11645 "my-card".to_string(),
11646 FragmentList {
11647 fragments: vec![
11648 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11649 structural_fragment("shadow_styles:my-card"),
11650 WebUIFragment::raw("<p>card</p></template>".to_string()),
11651 ],
11652 contains_boundary: false,
11653 },
11654 ),
11655 ]));
11656 protocol.set_css_strategy(strategy);
11657 let component = protocol
11658 .components
11659 .entry("my-card".to_string())
11660 .or_default();
11661 component.css = ".card{color:red}".to_string();
11662 component.css_href = "/my-card.css".to_string();
11663 component.uses_shadow_dom = true;
11664 protocol.populate_style_closures(&["index.html"]);
11665
11666 let mut writer = TestWriter::new();
11667 handle(
11668 &protocol,
11669 &test_json!({}),
11670 &RenderOptions::new("index.html", "/"),
11671 &mut writer,
11672 )
11673 .unwrap();
11674 let html = writer.get_content();
11675 match strategy {
11676 webui_protocol::CssStrategy::Link => {
11677 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11678 assert!(html.contains(&format!(
11679 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11680 )));
11681 }
11682 webui_protocol::CssStrategy::Module => {
11683 assert!(html.contains(
11684 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11685 ));
11686 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11687 }
11688 webui_protocol::CssStrategy::Style => unreachable!(),
11689 }
11690 }
11691 }
11692
11693 #[test]
11694 fn test_module_preloads_emit_in_head_in_compiler_order() {
11695 let mut fragments = HashMap::new();
11699 fragments.insert(
11700 "index.html".to_string(),
11701 FragmentList {
11702 fragments: vec![
11703 WebUIFragment::raw(
11704 r#"<html><head><script type="module" async src="/index.js"></script>"#
11705 .to_string(),
11706 ),
11707 structural_fragment("head_end"),
11708 WebUIFragment::raw("</head><body>".to_string()),
11709 structural_fragment("body_end"),
11710 WebUIFragment::raw("</body></html>".to_string()),
11711 ],
11712 contains_boundary: false,
11713 },
11714 );
11715
11716 let mut protocol = WebUIProtocol::new(fragments);
11717 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11718
11719 let state = test_json!({});
11720 let mut writer = TestWriter::new();
11721 handle(
11722 &protocol,
11723 &state,
11724 &RenderOptions::new("index.html", "/"),
11725 &mut writer,
11726 )
11727 .unwrap();
11728
11729 let html = writer.get_content();
11730 let head_end = html.find("</head>").expect("</head> missing");
11731 let head = &html[..head_end];
11732 assert!(
11733 head.contains(
11734 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11735 ),
11736 "hints must appear in <head> in the compiler's order: {html}"
11737 );
11738 }
11739
11740 #[test]
11741 fn test_no_module_preloads_emits_nothing() {
11742 let mut fragments = HashMap::new();
11743 fragments.insert(
11744 "index.html".to_string(),
11745 FragmentList {
11746 fragments: vec![
11747 WebUIFragment::raw("<html><head>".to_string()),
11748 structural_fragment("head_end"),
11749 WebUIFragment::raw("</head><body>".to_string()),
11750 structural_fragment("body_end"),
11751 WebUIFragment::raw("</body></html>".to_string()),
11752 ],
11753 contains_boundary: false,
11754 },
11755 );
11756 let protocol = WebUIProtocol::new(fragments);
11757
11758 let state = test_json!({});
11759 let mut writer = TestWriter::new();
11760 handle(
11761 &protocol,
11762 &state,
11763 &RenderOptions::new("index.html", "/"),
11764 &mut writer,
11765 )
11766 .unwrap();
11767
11768 assert!(
11769 !writer.get_content().contains("modulepreload"),
11770 "a build without hints must be byte-identical to before"
11771 );
11772 }
11773
11774 #[test]
11775 fn test_link_strategy_head_links_follow_document_order() {
11776 let mut fragments = HashMap::new();
11781 fragments.insert(
11782 "index.html".to_string(),
11783 FragmentList {
11784 fragments: vec![
11785 WebUIFragment::raw("<html><head>".to_string()),
11786 structural_fragment("head_end"),
11787 WebUIFragment::raw("</head><body><z-widget>".to_string()),
11788 WebUIFragment::component("z-widget"),
11789 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11790 WebUIFragment::component("a-widget"),
11791 WebUIFragment::raw("</a-widget>".to_string()),
11792 structural_fragment("body_end"),
11793 WebUIFragment::raw("</body></html>".to_string()),
11794 ],
11795 contains_boundary: false,
11796 },
11797 );
11798 fragments.insert(
11799 "z-widget".to_string(),
11800 FragmentList {
11801 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11802 contains_boundary: false,
11803 },
11804 );
11805 fragments.insert(
11806 "a-widget".to_string(),
11807 FragmentList {
11808 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11809 contains_boundary: false,
11810 },
11811 );
11812
11813 let mut protocol = WebUIProtocol::new(fragments);
11814 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11815
11816 let z = protocol
11817 .components
11818 .entry("z-widget".to_string())
11819 .or_default();
11820 z.css_href = "z-widget.css".to_string();
11821 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11822
11823 let a = protocol
11824 .components
11825 .entry("a-widget".to_string())
11826 .or_default();
11827 a.css_href = "a-widget.css".to_string();
11828 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11829 protocol.populate_style_closures(&["index.html"]);
11830
11831 let state = test_json!({});
11832 let mut writer = TestWriter::new();
11833
11834 handle(
11835 &protocol,
11836 &state,
11837 &RenderOptions::new("index.html", "/"),
11838 &mut writer,
11839 )
11840 .unwrap();
11841
11842 let html = writer.get_content();
11843 let head_end = html.find("</head>").expect("</head> missing");
11844 let head_section = &html[..head_end];
11845
11846 let z_pos = head_section
11847 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11848 .expect("z-widget stylesheet link missing from <head>");
11849 let a_pos = head_section
11850 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11851 .expect("a-widget stylesheet link missing from <head>");
11852
11853 assert!(
11854 z_pos < a_pos,
11855 "CSS <link> tags must follow document order (z-widget before \
11856 a-widget), not alphabetical order: {html}"
11857 );
11858 }
11859
11860 #[test]
11861 fn test_css_module_emitted_in_component_light_dom() {
11862 let mut fragments = HashMap::new();
11866 fragments.insert(
11867 "index.html".to_string(),
11868 FragmentList {
11869 fragments: vec![
11870 WebUIFragment::raw("<html><head>".to_string()),
11871 structural_fragment("head_end"),
11872 WebUIFragment::raw("</head><body><my-card>".to_string()),
11873 WebUIFragment::component("my-card"),
11874 WebUIFragment::raw("</my-card>".to_string()),
11875 WebUIFragment::raw("</body></html>".to_string()),
11876 ],
11877 contains_boundary: false,
11878 },
11879 );
11880 fragments.insert(
11881 "my-card".to_string(),
11882 FragmentList {
11883 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11884 contains_boundary: false,
11885 },
11886 );
11887
11888 let mut protocol = WebUIProtocol::new(fragments);
11889 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11890 protocol
11891 .components
11892 .entry("my-card".to_string())
11893 .or_default()
11894 .css = "p{color:red}".to_string();
11895 protocol.populate_style_closures(&["index.html"]);
11896 let state = test_json!({});
11897 let mut writer = TestWriter::new();
11898
11899 handle(
11900 &protocol,
11901 &state,
11902 &RenderOptions::new("index.html", "/"),
11903 &mut writer,
11904 )
11905 .unwrap();
11906
11907 let html = writer.get_content();
11908
11909 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11911 let css_pos = html
11912 .find(r#"<script type="importmap""#)
11913 .expect("CSS module importmap missing");
11914 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11915 assert!(
11916 css_pos > tag_open && css_pos < tag_close,
11917 "CSS module should be inside component light DOM: {html}"
11918 );
11919
11920 let head_end = html.find("</head>").expect("</head> missing");
11922 assert!(
11923 css_pos > head_end,
11924 "CSS module should not be in <head>: {html}"
11925 );
11926 }
11927
11928 #[test]
11929 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11930 let mut protocol = WebUIProtocol::default();
11931 protocol.fragments = HashMap::from([
11932 (
11933 "index.html".to_string(),
11934 FragmentList {
11935 fragments: vec![
11936 WebUIFragment::raw("<legacy-card>"),
11937 WebUIFragment::component("legacy-card"),
11938 WebUIFragment::raw("</legacy-card>"),
11939 ],
11940 contains_boundary: false,
11941 },
11942 ),
11943 (
11944 "legacy-card".to_string(),
11945 FragmentList {
11946 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11947 contains_boundary: false,
11948 },
11949 ),
11950 ]);
11951 protocol.components.insert(
11952 "legacy-card".to_string(),
11953 webui_protocol::ComponentData {
11954 css: ".legacy{color:red}".to_string(),
11955 ..Default::default()
11956 },
11957 );
11958 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11959 let mut writer = TestWriter::new();
11960
11961 let error = handle(
11962 &protocol,
11963 &test_json!({}),
11964 &RenderOptions::new("index.html", "/"),
11965 &mut writer,
11966 )
11967 .expect_err("styled protocols require closure metadata");
11968 assert!(error
11969 .to_string()
11970 .contains("component style closure metadata is required"));
11971 }
11972
11973 #[test]
11974 fn test_css_module_emitted_for_route_components() {
11975 let template = r#"<h1>Dashboard</h1>"#;
11977
11978 let mut fragments = HashMap::new();
11979 fragments.insert(
11980 "index.html".to_string(),
11981 FragmentList {
11982 fragments: vec![
11983 WebUIFragment::raw("<html><head>".to_string()),
11984 structural_fragment("head_end"),
11985 WebUIFragment::raw("</head><body>".to_string()),
11986 WebUIFragment::route("/", "dash-page"),
11987 structural_fragment("body_end"),
11988 WebUIFragment::raw("</body></html>".to_string()),
11989 ],
11990 contains_boundary: false,
11991 },
11992 );
11993 fragments.insert(
11994 "dash-page".to_string(),
11995 FragmentList {
11996 fragments: vec![WebUIFragment::raw(template.to_string())],
11997 contains_boundary: false,
11998 },
11999 );
12000
12001 let mut protocol = WebUIProtocol::new(fragments);
12002 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12003 let comp = protocol
12004 .components
12005 .entry("dash-page".to_string())
12006 .or_default();
12007 comp.css = "h1{font-size:2rem}".to_string();
12008 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12009 protocol.populate_style_closures(&["index.html"]);
12010 let state = test_json!({});
12011 let mut writer = TestWriter::new();
12012
12013 handle(
12014 &protocol,
12015 &state,
12016 &RenderOptions::new("index.html", "/"),
12017 &mut writer,
12018 )
12019 .unwrap();
12020
12021 let html = writer.get_content();
12022
12023 assert!(
12024 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
12025 "Route component should have CSS module importmap with data: URI: {html}"
12026 );
12027 assert!(
12028 html.contains("<h1>Dashboard</h1>"),
12029 "Route component should render content: {html}"
12030 );
12031 let fallback = html
12032 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
12033 .expect("route Module fallback");
12034 let host_open = html.find("<dash-page>").expect("route host");
12035 let content = html.find("<h1>Dashboard</h1>").expect("route content");
12036 assert!(
12037 fallback < host_open && host_open < content,
12038 "the Module fallback must precede the generated Light route host: {html}"
12039 );
12040 let importmap = html
12046 .find(r#"<script type="importmap""#)
12047 .expect("route CSS module importmap");
12048 assert!(
12049 host_open < importmap,
12050 "the CSS module importmap must live inside the route host, not as a route child: {html}"
12051 );
12052 }
12053
12054 #[test]
12055 fn test_head_css_link_skipped_for_components_without_css() {
12056 let mut fragments = HashMap::new();
12059 fragments.insert(
12060 "index.html".to_string(),
12061 FragmentList {
12062 fragments: vec![
12063 WebUIFragment::raw("<html><head>".to_string()),
12064 structural_fragment("head_end"),
12065 WebUIFragment::raw("</head><body>".to_string()),
12066 WebUIFragment::component("has-css"),
12067 WebUIFragment::component("no-css"),
12068 structural_fragment("body_end"),
12069 WebUIFragment::raw("</body></html>".to_string()),
12070 ],
12071 contains_boundary: false,
12072 },
12073 );
12074 fragments.insert(
12075 "has-css".to_string(),
12076 FragmentList {
12077 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
12078 contains_boundary: false,
12079 },
12080 );
12081 fragments.insert(
12082 "no-css".to_string(),
12083 FragmentList {
12084 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
12085 contains_boundary: false,
12086 },
12087 );
12088
12089 let mut protocol = WebUIProtocol::new(fragments);
12090 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12091
12092 protocol
12094 .components
12095 .entry("has-css".to_string())
12096 .or_default()
12097 .css_href = "has-css.css".to_string();
12098 protocol
12099 .components
12100 .entry("no-css".to_string())
12101 .or_default()
12102 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
12103 protocol.populate_style_closures(&["index.html"]);
12104
12105 let state = test_json!({});
12106 let mut writer = TestWriter::new();
12107
12108 let handler = WebUIHandler::with_plugin(|| {
12109 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12110 });
12111 handler
12112 .handle(
12113 &protocol,
12114 &state,
12115 &RenderOptions::new("index.html", "/"),
12116 &mut writer,
12117 )
12118 .unwrap();
12119
12120 let html = writer.get_content();
12121 assert!(
12122 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
12123 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
12124 );
12125 assert!(
12126 !html.contains("no-css.css"),
12127 "Component without CSS must NOT get a <link> in <head>: {html}"
12128 );
12129 }
12130
12131 #[test]
12132 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
12133 let mut fragments = HashMap::new();
12141 fragments.insert(
12142 "index.html".to_string(),
12143 FragmentList {
12144 fragments: vec![
12145 WebUIFragment::raw("<html><head>".to_string()),
12146 structural_fragment("head_end"),
12147 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12148 WebUIFragment::component("app-shell"),
12149 WebUIFragment::raw("</app-shell>".to_string()),
12150 structural_fragment("body_end"),
12151 WebUIFragment::raw("</body></html>".to_string()),
12152 ],
12153 contains_boundary: false,
12154 },
12155 );
12156 fragments.insert(
12158 "app-shell".to_string(),
12159 FragmentList {
12160 fragments: vec![
12161 WebUIFragment::raw("<div>Shell</div>".to_string()),
12162 WebUIFragment::component("cart-panel"),
12163 ],
12164 contains_boundary: false,
12165 },
12166 );
12167 fragments.insert(
12169 "cart-panel".to_string(),
12170 FragmentList {
12171 fragments: vec![
12172 WebUIFragment::raw("<aside>".to_string()),
12173 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
12174 WebUIFragment::raw("</aside>".to_string()),
12175 ],
12176 contains_boundary: false,
12177 },
12178 );
12179 fragments.insert(
12181 "cart-items".to_string(),
12182 FragmentList {
12183 fragments: vec![WebUIFragment::component("product-card")],
12184 contains_boundary: false,
12185 },
12186 );
12187 fragments.insert(
12188 "product-card".to_string(),
12189 FragmentList {
12190 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12191 contains_boundary: false,
12192 },
12193 );
12194
12195 let mut protocol = WebUIProtocol::new(fragments);
12196 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12197 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12198 for name in ["app-shell", "cart-panel", "product-card"] {
12199 let comp = protocol.components.entry(name.to_string()).or_default();
12200 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12201 comp.css = format!(".{name}{{display:block}}");
12202 if name == "cart-panel" {
12203 comp.hydration_mode = StateProjectionMode::Keys as i32;
12204 comp.hydration_keys = vec!["hasItems".to_string()];
12205 }
12206 if name == "product-card" {
12207 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
12208 }
12209 }
12210 protocol.populate_style_closures(&["index.html"]);
12211
12212 let state = test_json!({ "hasItems": false });
12214 let mut writer = TestWriter::new();
12215
12216 let handler = WebUIHandler::with_plugin(|| {
12217 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12218 });
12219 handler
12220 .handle(
12221 &protocol,
12222 &state,
12223 &RenderOptions::new("index.html", "/"),
12224 &mut writer,
12225 )
12226 .unwrap();
12227
12228 let html = writer.get_content();
12229
12230 assert!(
12231 html.contains(r#"<script type="application/json" id="webui-data">"#),
12232 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
12233 );
12234 assert!(
12235 html.contains(r#""state":{"hasItems":false}"#),
12236 "SSR state should live in the JSON data block: {html}"
12237 );
12238 assert!(
12239 html.contains(r#""inventory":"#),
12240 "SSR inventory should live in the JSON data block: {html}"
12241 );
12242 assert!(
12243 !html.contains("window.__webui={\""),
12244 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12245 );
12246 assert!(
12247 !html.contains(r#"document.getElementById("webui-data")"#),
12248 "SSR must not parse webui-data; client packages own that lazy load: {html}"
12249 );
12250 assert!(
12251 !html.contains("window.__webui=w;"),
12252 "executable bootstrap must not replace existing window.__webui registrations: {html}"
12253 );
12254 assert!(
12255 !html.contains("w.templateFns={\""),
12256 "template function emission must not replace existing templateFns registrations: {html}"
12257 );
12258 assert!(
12259 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12260 "template functions should merge into the flat templateFns registry: {html}"
12261 );
12262
12263 assert!(
12266 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12267 "product-card template should be emitted even when unrendered: {html}"
12268 );
12269
12270 assert!(
12273 html.contains(r#""product-card":"data:text/css,"#),
12274 "reachable product-card CSS module importmap should be emitted: {html}"
12275 );
12276
12277 assert!(
12279 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12280 "rendered app-shell template should be emitted: {html}"
12281 );
12282 assert!(
12283 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12284 "rendered cart-panel template should be emitted: {html}"
12285 );
12286 }
12287
12288 #[test]
12289 fn component_asset_manifest_falls_back_to_body_start() {
12290 let fragments = HashMap::from([(
12291 "index.html".to_string(),
12292 FragmentList {
12293 fragments: vec![
12294 WebUIFragment::raw("<body>".to_string()),
12295 structural_fragment("body_start"),
12296 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12297 ],
12298 contains_boundary: false,
12299 },
12300 )]);
12301 let mut protocol = WebUIProtocol::new(fragments);
12302 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12303 root: "lazy-panel".to_string(),
12304 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12305 }];
12306 let mut writer = TestWriter::new();
12307
12308 handle(
12309 &protocol,
12310 &Value::Null,
12311 &RenderOptions::new("index.html", "/"),
12312 &mut writer,
12313 )
12314 .unwrap();
12315
12316 assert_eq!(
12317 writer.get_content(),
12318 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12319 );
12320 }
12321
12322 #[test]
12323 fn component_asset_styles_emit_once_when_body_precedes_head() {
12324 let fragments = HashMap::from([(
12325 "index.html".to_string(),
12326 FragmentList {
12327 fragments: vec![
12328 WebUIFragment::raw("<body>".to_string()),
12329 structural_fragment("body_start"),
12330 WebUIFragment::raw("</body><head>".to_string()),
12331 structural_fragment("head_end"),
12332 WebUIFragment::raw("</head>".to_string()),
12333 ],
12334 contains_boundary: false,
12335 },
12336 )]);
12337 let mut protocol = WebUIProtocol::new(fragments);
12338 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12339 root: "lazy-panel".to_string(),
12340 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12341 }];
12342 let mut writer = TestWriter::new();
12343
12344 handle(
12345 &protocol,
12346 &Value::Null,
12347 &RenderOptions::new("index.html", "/"),
12348 &mut writer,
12349 )
12350 .unwrap();
12351
12352 assert_eq!(
12353 writer
12354 .get_content()
12355 .matches(r#"id="webui-component-assets""#)
12356 .count(),
12357 1
12358 );
12359 }
12360
12361 #[test]
12362 fn light_component_asset_styles_apply_at_head_end() {
12363 let fragments = HashMap::from([(
12364 "index.html".to_string(),
12365 FragmentList {
12366 fragments: vec![
12367 WebUIFragment::raw("<html><head>".to_string()),
12368 structural_fragment("head_end"),
12369 WebUIFragment::raw("</head><body></body></html>".to_string()),
12370 ],
12371 contains_boundary: false,
12372 },
12373 )]);
12374 let mut protocol = WebUIProtocol::new(fragments);
12375 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12376 for root in ["lazy-panel", "secondary-panel"] {
12377 protocol.components.insert(
12378 root.to_string(),
12379 webui_protocol::ComponentData {
12380 uses_shadow_dom: false,
12381 ..Default::default()
12382 },
12383 );
12384 }
12385 protocol.component_asset_style_preloads = vec![
12386 ComponentAssetStylePreload {
12387 root: "lazy-panel".to_string(),
12388 style_hrefs: vec![
12389 "/assets/lazy-panel.css".to_string(),
12390 "/assets/shared.css".to_string(),
12391 ],
12392 },
12393 ComponentAssetStylePreload {
12394 root: "secondary-panel".to_string(),
12395 style_hrefs: vec![
12396 "/assets/secondary-panel.css".to_string(),
12397 "/assets/shared.css".to_string(),
12398 ],
12399 },
12400 ];
12401 let mut writer = TestWriter::new();
12402
12403 handle(
12404 &protocol,
12405 &Value::Null,
12406 &RenderOptions::new("index.html", "/"),
12407 &mut writer,
12408 )
12409 .unwrap();
12410
12411 let html = writer.get_content();
12412 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12413 assert_eq!(html.matches("/assets/shared.css").count(), 1);
12414 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12415 let links_end = html
12416 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12417 .expect("asset stylesheet must be emitted");
12418 let head_end = html.find("</head>").expect("head must close");
12419 assert!(links_end < head_end);
12420 }
12421
12422 #[test]
12432 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12433 let template = r#"<h1>Dashboard</h1>"#;
12435
12436 let mut fragments = HashMap::new();
12437 fragments.insert(
12438 "index.html".to_string(),
12439 FragmentList {
12440 fragments: vec![
12441 WebUIFragment::raw("<html><head>".to_string()),
12442 structural_fragment("head_end"),
12443 WebUIFragment::raw("</head><body>".to_string()),
12444 WebUIFragment::route("/", "dash-page"),
12445 structural_fragment("body_end"),
12446 WebUIFragment::raw("</body></html>".to_string()),
12447 ],
12448 contains_boundary: false,
12449 },
12450 );
12451 fragments.insert(
12452 "dash-page".to_string(),
12453 FragmentList {
12454 fragments: vec![WebUIFragment::raw(template.to_string())],
12455 contains_boundary: false,
12456 },
12457 );
12458
12459 let mut protocol = WebUIProtocol::new(fragments);
12460 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12461 let comp = protocol
12462 .components
12463 .entry("dash-page".to_string())
12464 .or_default();
12465 comp.css = "h1{font-size:2rem}".to_string();
12466 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12467 protocol.populate_style_closures(&["index.html"]);
12468 let state = test_json!({});
12469 let mut writer = TestWriter::new();
12470
12471 handle(
12472 &protocol,
12473 &state,
12474 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12475 &mut writer,
12476 )
12477 .unwrap();
12478
12479 let html = writer.get_content();
12480
12481 assert!(
12482 html.contains(
12483 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12484 ),
12485 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12486 );
12487 }
12488
12489 #[test]
12490 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12491 let mut fragments = HashMap::new();
12496 fragments.insert(
12497 "index.html".to_string(),
12498 FragmentList {
12499 fragments: vec![
12500 WebUIFragment::raw("<html><head>".to_string()),
12501 structural_fragment("head_end"),
12502 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12503 WebUIFragment::component("app-shell"),
12504 WebUIFragment::raw("</app-shell>".to_string()),
12505 structural_fragment("body_end"),
12506 WebUIFragment::raw("</body></html>".to_string()),
12507 ],
12508 contains_boundary: false,
12509 },
12510 );
12511 fragments.insert(
12512 "app-shell".to_string(),
12513 FragmentList {
12514 fragments: vec![WebUIFragment::if_cond(
12515 ConditionExpr::identifier("hasItems"),
12516 "cart-items",
12517 )],
12518 contains_boundary: false,
12519 },
12520 );
12521 fragments.insert(
12522 "cart-items".to_string(),
12523 FragmentList {
12524 fragments: vec![WebUIFragment::component("product-card")],
12525 contains_boundary: false,
12526 },
12527 );
12528 fragments.insert(
12529 "product-card".to_string(),
12530 FragmentList {
12531 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12532 contains_boundary: false,
12533 },
12534 );
12535
12536 let mut protocol = WebUIProtocol::new(fragments);
12537 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12538 for name in ["app-shell", "product-card"] {
12539 let comp = protocol.components.entry(name.to_string()).or_default();
12540 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12541 comp.css = format!(".{name}{{display:block}}");
12542 }
12543 protocol.populate_style_closures(&["index.html"]);
12544
12545 let state = test_json!({ "hasItems": false });
12548 let mut writer = TestWriter::new();
12549
12550 let handler = WebUIHandler::with_plugin(|| {
12551 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12552 });
12553 handler
12554 .handle(
12555 &protocol,
12556 &state,
12557 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12558 &mut writer,
12559 )
12560 .unwrap();
12561
12562 let html = writer.get_content();
12563
12564 assert!(
12565 html.contains(
12566 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12567 ),
12568 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12569 );
12570 }
12571
12572 #[test]
12573 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12574 let mut fragments = HashMap::new();
12575 fragments.insert(
12576 "index.html".to_string(),
12577 FragmentList {
12578 fragments: vec![
12579 WebUIFragment::raw("<html><body><style>".to_string()),
12580 WebUIFragment::signal("tokens.light", true),
12581 WebUIFragment::raw("</style>".to_string()),
12582 structural_fragment("body_end"),
12583 WebUIFragment::raw("</body></html>".to_string()),
12584 ],
12585 contains_boundary: false,
12586 },
12587 );
12588 fragments.insert(
12589 "app-shell".to_string(),
12590 FragmentList {
12591 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12592 contains_boundary: false,
12593 },
12594 );
12595 let index_fragments = fragments
12596 .get_mut("index.html")
12597 .expect("index fixture should exist");
12598 index_fragments
12599 .fragments
12600 .insert(1, WebUIFragment::component("app-shell"));
12601 let mut protocol = WebUIProtocol::new(fragments);
12602 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12603 protocol.components.insert(
12608 "app-shell".to_string(),
12609 webui_protocol::ComponentData {
12610 hydration_mode: StateProjectionMode::Keys as i32,
12611 hydration_keys: vec!["name".to_string()],
12612 ..Default::default()
12613 },
12614 );
12615 let state = test_json!({
12616 "name": "Alice",
12617 "tokens": {
12618 "light": "--color-brand: red;"
12619 }
12620 });
12621 let mut writer = TestWriter::new();
12622 let handler = WebUIHandler::with_plugin(|| {
12623 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12624 });
12625 handler.handle(
12626 &protocol,
12627 &state,
12628 &RenderOptions::new("index.html", "/"),
12629 &mut writer,
12630 )?;
12631 let output = writer.get_content();
12632
12633 assert!(output.contains("--color-brand: red;"));
12635 assert!(output.contains(r#""name":"Alice""#));
12637 assert!(!output.contains(r#""tokens""#));
12638 Ok(())
12639 }
12640
12641 #[test]
12642 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12643 let mut fragments = HashMap::new();
12644 fragments.insert(
12645 "index.html".to_string(),
12646 FragmentList {
12647 fragments: vec![
12648 WebUIFragment::raw("<html><body>"),
12649 structural_fragment("body_end"),
12650 WebUIFragment::raw("</body></html>"),
12651 ],
12652 contains_boundary: false,
12653 },
12654 );
12655 let protocol = WebUIProtocol::new(fragments);
12656 let state = test_json!({
12657 "client": "visible",
12658 "serverOnly": "also preserved",
12659 });
12660 let handler = WebUIHandler::with_plugin(|| {
12661 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12662 });
12663 let mut writer = TestWriter::new();
12664 handler.handle(
12665 &protocol,
12666 &state,
12667 &RenderOptions::new("index.html", "/"),
12668 &mut writer,
12669 )?;
12670 let output = writer.get_content();
12671 assert!(output.contains(r#""client":"visible""#));
12672 assert!(output.contains(r#""serverOnly":"also preserved""#));
12673 Ok(())
12674 }
12675
12676 #[test]
12677 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12678 let mut fragments = HashMap::new();
12679 fragments.insert(
12680 "index.html".to_string(),
12681 FragmentList {
12682 fragments: vec![
12683 WebUIFragment::raw("<html><body>"),
12684 WebUIFragment::component("app-shell"),
12685 structural_fragment("body_end"),
12686 WebUIFragment::raw("</body></html>"),
12687 ],
12688 contains_boundary: false,
12689 },
12690 );
12691 fragments.insert(
12692 "app-shell".to_string(),
12693 FragmentList {
12694 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12695 contains_boundary: false,
12696 },
12697 );
12698 let mut protocol = WebUIProtocol::new(fragments);
12699 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12700 protocol.components.insert(
12701 "app-shell".to_string(),
12702 webui_protocol::ComponentData {
12703 hydration_mode: StateProjectionMode::All as i32,
12704 ..Default::default()
12705 },
12706 );
12707 let state = test_json!({
12708 "known": "value",
12709 "possiblyInherited": "must not be dropped",
12710 });
12711 let handler = WebUIHandler::with_plugin(|| {
12712 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12713 });
12714 let mut writer = TestWriter::new();
12715 handler.handle(
12716 &protocol,
12717 &state,
12718 &RenderOptions::new("index.html", "/"),
12719 &mut writer,
12720 )?;
12721 let output = writer.get_content();
12722 assert!(output.contains(r#""known":"value""#));
12723 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12724 Ok(())
12725 }
12726
12727 #[test]
12728 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12729 let mut fragments = HashMap::new();
12730 fragments.insert(
12731 "index.html".to_string(),
12732 FragmentList {
12733 fragments: vec![
12734 WebUIFragment::raw("<html><body>"),
12735 WebUIFragment::component("app-shell"),
12736 structural_fragment("body_end"),
12737 WebUIFragment::raw("</body></html>"),
12738 ],
12739 contains_boundary: false,
12740 },
12741 );
12742 fragments.insert(
12743 "app-shell".to_string(),
12744 FragmentList {
12745 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12746 contains_boundary: false,
12747 },
12748 );
12749 let mut protocol = WebUIProtocol::new(fragments);
12750 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12751 let state = test_json!({
12752 "known": "value",
12753 "serverOnly": "must not be dropped",
12754 });
12755 let handler = WebUIHandler::with_plugin(|| {
12756 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12757 });
12758 let mut writer = TestWriter::new();
12759 handler.handle(
12760 &protocol,
12761 &state,
12762 &RenderOptions::new("index.html", "/"),
12763 &mut writer,
12764 )?;
12765 let output = writer.get_content();
12766 assert!(output.contains(r#""known":"value""#));
12767 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12768 Ok(())
12769 }
12770
12771 #[test]
12772 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12773 let mut fragments = HashMap::new();
12774 fragments.insert(
12775 "index.html".to_string(),
12776 FragmentList {
12777 fragments: vec![
12778 WebUIFragment::raw("<html><body>"),
12779 WebUIFragment::component("app-shell"),
12780 structural_fragment("body_end"),
12781 WebUIFragment::raw("</body></html>"),
12782 ],
12783 contains_boundary: false,
12784 },
12785 );
12786 fragments.insert(
12787 "app-shell".to_string(),
12788 FragmentList {
12789 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12790 contains_boundary: false,
12791 },
12792 );
12793 let mut protocol = WebUIProtocol::new(fragments);
12794 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12795 protocol.components.insert(
12796 "app-shell".to_string(),
12797 webui_protocol::ComponentData {
12798 hydration_mode: i32::MAX,
12799 ..Default::default()
12800 },
12801 );
12802 let state = test_json!({
12803 "known": "value",
12804 "serverOnly": "must not be dropped",
12805 });
12806 let handler = WebUIHandler::with_plugin(|| {
12807 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12808 });
12809 let mut writer = TestWriter::new();
12810 handler.handle(
12811 &protocol,
12812 &state,
12813 &RenderOptions::new("index.html", "/"),
12814 &mut writer,
12815 )?;
12816 let output = writer.get_content();
12817 assert!(output.contains(r#""known":"value""#));
12818 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12819 Ok(())
12820 }
12821
12822 #[test]
12823 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12824 let mut protocol = WebUIProtocol::new(HashMap::new());
12825 protocol.components.insert(
12826 "app-shell".to_string(),
12827 webui_protocol::ComponentData {
12828 navigation_keys: vec!["selected".to_string()],
12829 ..Default::default()
12830 },
12831 );
12832
12833 match collect_navigation_state(&protocol, ["app-shell"]) {
12834 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12835 StateSelection::Full
12836 | StateSelection::KeyIds(_)
12837 | StateSelection::FullExceptKeyIds(_) => {
12838 panic!("legacy navigation keys should remain owned and projected")
12839 }
12840 }
12841 }
12842
12843 #[test]
12844 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12845 let mut protocol = WebUIProtocol::new(HashMap::new());
12846 protocol.components.insert(
12847 "app-shell".to_string(),
12848 webui_protocol::ComponentData::default(),
12849 );
12850
12851 assert!(matches!(
12852 collect_navigation_state(&protocol, ["app-shell"]),
12853 StateSelection::Full
12854 ));
12855 }
12856
12857 #[test]
12858 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12859 let mut fragments = HashMap::new();
12860 fragments.insert(
12861 "index.html".to_string(),
12862 FragmentList {
12863 fragments: vec![
12864 WebUIFragment::raw("<html><body>".to_string()),
12865 structural_fragment("body_end"),
12866 WebUIFragment::raw("</body></html>".to_string()),
12867 ],
12868 contains_boundary: false,
12869 },
12870 );
12871 let mut protocol = WebUIProtocol::new(fragments);
12872 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12873 let state = test_json!({
12874 "title": "Legacy state",
12875 "serverOnly": "preserved",
12876 });
12877 let handler = WebUIHandler::with_plugin(|| {
12878 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12879 });
12880
12881 let mut writer = TestWriter::new();
12882 handler.handle(
12883 &protocol,
12884 &state,
12885 &RenderOptions::new("index.html", "/"),
12886 &mut writer,
12887 )?;
12888 assert!(writer.get_content().contains(r#""state":{}"#));
12889 assert!(!writer.get_content().contains("Legacy state"));
12890 assert!(!writer.get_content().contains("preserved"));
12891 Ok(())
12892 }
12893
12894 #[test]
12895 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12896 let mut fragments = HashMap::new();
12897 fragments.insert(
12898 "index.html".to_string(),
12899 FragmentList {
12900 fragments: vec![
12901 WebUIFragment::raw("<html><body>"),
12902 WebUIFragment::component("items-page"),
12903 structural_fragment("body_end"),
12904 WebUIFragment::raw("</body></html>"),
12905 ],
12906 contains_boundary: false,
12907 },
12908 );
12909 fragments.insert(
12910 "items-page".to_string(),
12911 FragmentList {
12912 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12913 contains_boundary: false,
12914 },
12915 );
12916 let mut protocol = WebUIProtocol::new(fragments);
12917 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12918 protocol.components.insert(
12919 "items-page".to_string(),
12920 webui_protocol::ComponentData {
12921 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12922 navigation_mode: Some(StateProjectionMode::Keys as i32),
12923 navigation_keys: vec!["items".into()],
12924 ..Default::default()
12925 },
12926 );
12927 let state = test_json!({
12928 "items": ["STATE_SENTINEL"],
12929 "serverOnly": "SECRET_SENTINEL",
12930 });
12931 let handler = WebUIHandler::with_plugin(|| {
12932 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12933 });
12934 let mut writer = TestWriter::new();
12935
12936 handler.handle(
12937 &protocol,
12938 &state,
12939 &RenderOptions::new("index.html", "/"),
12940 &mut writer,
12941 )?;
12942
12943 let output = writer.get_content();
12944 assert!(output.contains(r#""state":{}"#));
12945 assert!(!output.contains("STATE_SENTINEL"));
12946 assert!(!output.contains("SECRET_SENTINEL"));
12947 Ok(())
12948 }
12949
12950 #[test]
12951 fn write_selected_state_projects_and_escapes() {
12952 let state = test_json!({
12955 "drop": "secret",
12956 "keep": "</script><b>"
12957 });
12958 let keys = ["keep"];
12959 let selection = StateSelection::Keys(keys.to_vec());
12960 let mut sink = TestWriter::new();
12961 let mut scratch = Vec::new();
12962 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12963 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12964 }
12965
12966 #[test]
12967 fn write_selected_state_non_object_projection_emits_empty_object() {
12968 let state = test_json!("scalar state has nothing hydratable");
12969 let keys: [&str; 0] = [];
12970 let selection = StateSelection::Keys(keys.to_vec());
12971 let mut sink = TestWriter::new();
12972 let mut scratch = Vec::new();
12973 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12974 assert_eq!(sink.get_content(), "{}");
12975 }
12976
12977 #[test]
12978 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12979 let state = test_json!({
12980 "keptA": 1,
12981 "keptB": 2,
12982 "serverOnlyA": 3,
12983 "serverOnlyB": 4,
12984 });
12985 let keys = ["keptA", "keptA", "keptB", "missing"];
12986 let selection = StateSelection::Keys(keys.to_vec());
12987 let mut sink = TestWriter::new();
12988 let mut scratch = Vec::new();
12989 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12990 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12991 }
12992
12993 #[test]
12994 fn write_selected_state_map_first_matches_schema_first_output() {
12995 let state = test_json!({
12996 "keptA": 1,
12997 "keptB": 2,
12998 });
12999 let keys = ["keptA", "keptB", "missingA", "missingB"];
13000 let selection = StateSelection::Keys(keys.to_vec());
13001 let mut sink = TestWriter::new();
13002 let mut scratch = Vec::new();
13003 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
13004 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
13005 }
13006
13007 #[test]
13008 fn write_selected_state_full_preserves_and_escapes_state() {
13009 let state = test_json!({
13010 "serverOnly": "</script><b>",
13011 "value": 42,
13012 });
13013 let mut sink = TestWriter::new();
13014 let mut scratch = Vec::new();
13015 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13016 assert_eq!(
13017 sink.get_content(),
13018 r#"{"serverOnly":"<\/script><b>","value":42}"#
13019 );
13020 }
13021
13022 #[test]
13023 fn script_safe_json_str_escapes_only_end_tag_openers() -> Result<()> {
13024 let cases = [
13025 ("", ""),
13026 ("</", "<\\/"),
13027 ("</a</b", "<\\/a<\\/b"),
13028 ("<<//", "<<\\//"),
13029 ("a<b>c<", "a<b>c<"),
13030 ("x</", "x<\\/"),
13031 ("a<b>c</d>e</f", "a<b>c<\\/d>e<\\/f"),
13032 ("<img src=\"/a.png\"><br/>", "<img src=\"/a.png\"><br/>"),
13033 ];
13034 for (json, expected) in cases {
13035 let mut sink = TestWriter::new();
13036 write_script_safe_json_str(&mut sink, json)?;
13037 assert_eq!(sink.get_content(), expected, "input {json:?}");
13038 }
13039 Ok(())
13040 }
13041
13042 #[test]
13043 fn script_safe_json_str_finds_end_tag_openers_at_every_offset() -> Result<()> {
13044 let mut json = String::with_capacity(512);
13047 for gap in 0..130 {
13048 json.clear();
13049 json.push_str("{\"h\":\"<img src=\\\"/a.png\\\">");
13050 json.push_str(&"x".repeat(gap));
13051 json.push_str("</p><br/>");
13052 json.push_str(&"y".repeat(gap % 37));
13053 json.push_str("</");
13054 let mut sink = TestWriter::new();
13055 write_script_safe_json_str(&mut sink, &json)?;
13056 assert_eq!(sink.get_content(), json.replace("</", "<\\/"), "gap {gap}");
13057 }
13058 Ok(())
13059 }
13060
13061 #[test]
13062 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
13063 let preloads = vec![ComponentAssetStylePreload {
13064 root: "lazy-panel".to_string(),
13065 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
13066 }];
13067
13068 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
13069
13070 assert_eq!(
13071 manifest,
13072 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
13073 );
13074 }
13075
13076 #[test]
13077 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
13078 let mut sink = TestWriter::new();
13084 let mut scratch: Vec<u8> = Vec::new();
13085 assert_eq!(scratch.capacity(), 0);
13087
13088 let mut large = serde_json::Map::new();
13090 for index in 0..64 {
13091 large.insert(
13092 format!("component_{index:04}"),
13093 Value::String("x".repeat(48)),
13094 );
13095 }
13096 let large = Value::Object(large);
13097 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
13098 let high_water = scratch.capacity();
13099 assert!(high_water > 0);
13100
13101 for seq in 0..8 {
13104 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
13105 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
13106 assert_eq!(
13107 scratch.capacity(),
13108 high_water,
13109 "scratch reallocated on checkpoint {seq}"
13110 );
13111 }
13112 }
13113
13114 #[test]
13115 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
13116 let mut fragments = HashMap::new();
13117 fragments.insert(
13118 "index.html".to_string(),
13119 FragmentList {
13120 fragments: vec![
13121 WebUIFragment::raw("<html><body>"),
13122 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13123 path: "/".to_string(),
13124 fragment_id: "home-page".to_string(),
13125 exact: true,
13126 ..Default::default()
13127 }),
13128 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
13129 path: "/admin".to_string(),
13130 fragment_id: "admin-page".to_string(),
13131 exact: true,
13132 ..Default::default()
13133 }),
13134 structural_fragment("body_end"),
13135 WebUIFragment::raw("</body></html>"),
13136 ],
13137
13138 contains_boundary: false,
13139 },
13140 );
13141 fragments.insert(
13142 "home-page".to_string(),
13143 FragmentList {
13144 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
13145 contains_boundary: false,
13146 },
13147 );
13148 fragments.insert(
13149 "admin-page".to_string(),
13150 FragmentList {
13151 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
13152 contains_boundary: false,
13153 },
13154 );
13155
13156 let mut protocol = WebUIProtocol::new(fragments);
13157 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13158 protocol.components.insert(
13159 "home-page".to_string(),
13160 webui_protocol::ComponentData {
13161 template_json: "{}".to_string(),
13162 hydration_mode: StateProjectionMode::Keys as i32,
13163 hydration_keys: vec!["homeTitle".to_string()],
13164 ..Default::default()
13165 },
13166 );
13167 protocol.components.insert(
13168 "admin-page".to_string(),
13169 webui_protocol::ComponentData {
13170 template_json: "{}".to_string(),
13171 hydration_mode: StateProjectionMode::Keys as i32,
13172 hydration_keys: vec!["adminToken".to_string()],
13173 ..Default::default()
13174 },
13175 );
13176 let state = test_json!({
13177 "homeTitle": "Welcome",
13178 "adminToken": "TOP_SECRET_SENTINEL",
13179 });
13180 let handler = WebUIHandler::with_plugin(|| {
13181 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13182 });
13183 let mut writer = TestWriter::new();
13184 handler.handle(
13185 &protocol,
13186 &state,
13187 &RenderOptions::new("index.html", "/"),
13188 &mut writer,
13189 )?;
13190 let output = writer.get_content();
13191 assert!(output.contains(r#""homeTitle":"Welcome""#));
13192 assert!(!output.contains("TOP_SECRET_SENTINEL"));
13193 assert!(!output.contains(r#""adminToken""#));
13194 Ok(())
13195 }
13196
13197 #[test]
13198 fn test_component_attr_name_aria() {
13199 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
13201 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
13202 assert_eq!(
13203 component_attr_name("aria-activedescendant"),
13204 "ariaActiveDescendant"
13205 );
13206 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
13207 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
13208 }
13209
13210 #[test]
13211 fn test_component_attr_name_html_global() {
13212 assert_eq!(component_attr_name("readonly"), "readOnly");
13213 assert_eq!(component_attr_name("tabindex"), "tabIndex");
13214 assert_eq!(component_attr_name("accesskey"), "accessKey");
13215 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
13216 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
13217 assert_eq!(component_attr_name("inputmode"), "inputMode");
13218 assert_eq!(component_attr_name("maxlength"), "maxLength");
13219 assert_eq!(component_attr_name("minlength"), "minLength");
13220 assert_eq!(component_attr_name("novalidate"), "noValidate");
13221 assert_eq!(component_attr_name("formaction"), "formAction");
13222 assert_eq!(component_attr_name("ismap"), "isMap");
13223 assert_eq!(component_attr_name("usemap"), "useMap");
13224 }
13225
13226 #[test]
13227 fn test_component_attr_name_strips_colon() {
13228 assert_eq!(component_attr_name(":readonly"), "readOnly");
13229 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
13230 assert_eq!(component_attr_name(":data-title"), "dataTitle");
13231 }
13232
13233 #[test]
13234 fn test_component_attr_name_regular() {
13235 assert_eq!(component_attr_name("data-title"), "dataTitle");
13236 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
13237 assert_eq!(component_attr_name("simple"), "simple");
13238 }
13239
13240 fn make_query_route_protocol() -> WebUIProtocol {
13243 use webui_protocol::WebUiFragmentRoute;
13244
13245 let mut fragments = HashMap::new();
13246
13247 fragments.insert(
13248 "index.html".to_string(),
13249 FragmentList {
13250 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
13251 path: "/".into(),
13252 fragment_id: "app-shell".into(),
13253 exact: false,
13254 children: vec![
13255 WebUiFragmentRoute {
13256 path: "compose".into(),
13257 fragment_id: "compose-page".into(),
13258 exact: true,
13259 allowed_query: "action,to,subject".into(),
13260 keep_alive: false,
13261 ..Default::default()
13262 },
13263 WebUiFragmentRoute {
13264 path: "settings".into(),
13265 fragment_id: "settings-page".into(),
13266 exact: true,
13267 keep_alive: false,
13268 ..Default::default()
13269 },
13270 ],
13271 keep_alive: false,
13272 ..Default::default()
13273 })],
13274
13275 contains_boundary: false,
13276 },
13277 );
13278
13279 fragments.insert(
13280 "app-shell".to_string(),
13281 FragmentList {
13282 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13283 contains_boundary: false,
13284 },
13285 );
13286 fragments.insert(
13287 "compose-page".to_string(),
13288 FragmentList {
13289 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13290 contains_boundary: false,
13291 },
13292 );
13293 fragments.insert(
13294 "settings-page".to_string(),
13295 FragmentList {
13296 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13297 contains_boundary: false,
13298 },
13299 );
13300
13301 WebUIProtocol::new(fragments)
13302 }
13303
13304 #[test]
13305 fn test_matched_route_omits_query_attr_from_dom() {
13306 let protocol = make_query_route_protocol();
13307 let state = test_json!({});
13308 let handler = WebUIHandler::new();
13309 let mut writer = TestWriter::new();
13310
13311 handler
13312 .handle(
13313 &protocol,
13314 &state,
13315 &RenderOptions::new("index.html", "/compose"),
13316 &mut writer,
13317 )
13318 .expect("render failed");
13319
13320 let html = writer.get_content();
13321 assert!(
13323 !html.contains(r#"query="action,to,subject""#),
13324 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13325 );
13326 }
13327
13328 #[test]
13329 fn test_nonmatched_route_omits_query_attr_from_dom() {
13330 let protocol = make_query_route_protocol();
13331 let state = test_json!({});
13332 let handler = WebUIHandler::new();
13333 let mut writer = TestWriter::new();
13334
13335 handler
13336 .handle(
13337 &protocol,
13338 &state,
13339 &RenderOptions::new("index.html", "/settings"),
13340 &mut writer,
13341 )
13342 .expect("render failed");
13343
13344 let html = writer.get_content();
13345 assert!(
13347 !html.contains(r#"query="#),
13348 "hidden route should not have query attr: {html}"
13349 );
13350 }
13351
13352 #[test]
13353 fn test_route_without_query_has_no_query_attr() {
13354 let protocol = make_query_route_protocol();
13355 let state = test_json!({});
13356 let handler = WebUIHandler::new();
13357 let mut writer = TestWriter::new();
13358
13359 handler
13360 .handle(
13361 &protocol,
13362 &state,
13363 &RenderOptions::new("index.html", "/settings"),
13364 &mut writer,
13365 )
13366 .expect("render failed");
13367
13368 let html = writer.get_content();
13369 let settings_idx = html
13371 .find(r#"component="settings-page""#)
13372 .expect("settings route should exist");
13373 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13374 assert!(
13375 !settings_tag.contains("query="),
13376 "route without allowed_query should not emit query attr: {settings_tag}"
13377 );
13378 }
13379
13380 fn build_head_body_protocol() -> WebUIProtocol {
13385 let mut fragments = HashMap::new();
13386 fragments.insert(
13387 "index.html".to_string(),
13388 FragmentList {
13389 fragments: vec![
13390 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13391 structural_fragment("head_end"),
13392 WebUIFragment::raw("</head><body>hello".to_string()),
13393 structural_fragment("body_end"),
13394 WebUIFragment::raw("</body></html>".to_string()),
13395 ],
13396 contains_boundary: false,
13397 },
13398 );
13399 WebUIProtocol::new(fragments)
13400 }
13401
13402 #[test]
13403 fn head_inject_emits_at_head_end_boundary() {
13404 let protocol = build_head_body_protocol();
13405 let state = test_json!({});
13406 let mut writer = TestWriter::new();
13407 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13408 handle(&protocol, &state, &opts, &mut writer).unwrap();
13409 let html = writer.get_content();
13410 let inject_idx = html
13412 .find("<link rel=preload>")
13413 .expect("inject HTML missing");
13414 let head_close_idx = html.find("</head>").expect("</head> missing");
13415 assert!(
13416 inject_idx < head_close_idx,
13417 "head_inject must appear before </head>: {html}"
13418 );
13419 assert_eq!(html.matches("<link rel=preload>").count(), 1);
13421 }
13422
13423 #[test]
13424 fn component_render_policy_css_emits_once_in_head_with_nonce() {
13425 let mut protocol = build_head_body_protocol();
13426 protocol.component_render_css = concat!(
13427 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13428 "contain-intrinsic-block-size:auto 18rem;}"
13429 )
13430 .to_string();
13431 let state = test_json!({});
13432 let mut writer = TestWriter::new();
13433 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13434 handle(&protocol, &state, &opts, &mut writer).unwrap();
13435 let html = writer.get_content();
13436
13437 let style = concat!(
13438 r#"<style data-webui-render-policy nonce="test-nonce">"#,
13439 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13440 "contain-intrinsic-block-size:auto 18rem;}</style>"
13441 );
13442 let style_index = html.find(style).expect("render policy style missing");
13443 let head_close = html.find("</head>").expect("</head> missing");
13444 assert!(style_index < head_close);
13445 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13446 }
13447
13448 #[test]
13449 fn body_inject_emits_at_body_end_boundary() {
13450 let protocol = build_head_body_protocol();
13451 let state = test_json!({});
13452 let mut writer = TestWriter::new();
13453 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13454 handle(&protocol, &state, &opts, &mut writer).unwrap();
13455 let html = writer.get_content();
13456 let inject_idx = html
13457 .find("<script>lr</script>")
13458 .expect("inject HTML missing");
13459 let body_close_idx = html.find("</body>").expect("</body> missing");
13460 assert!(
13461 inject_idx < body_close_idx,
13462 "body_inject must appear before </body>: {html}"
13463 );
13464 assert_eq!(html.matches("<script>lr</script>").count(), 1);
13465 }
13466
13467 fn build_all_boundaries_protocol() -> WebUIProtocol {
13472 let mut fragments = HashMap::new();
13473 fragments.insert(
13474 "index.html".to_string(),
13475 FragmentList {
13476 fragments: vec![
13477 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13478 structural_fragment("head_end"),
13479 WebUIFragment::raw("</head><body>".to_string()),
13480 structural_fragment("body_start"),
13481 WebUIFragment::raw("hello".to_string()),
13482 structural_fragment("body_end"),
13483 WebUIFragment::raw("</body></html>".to_string()),
13484 ],
13485 contains_boundary: false,
13486 },
13487 );
13488 WebUIProtocol::new(fragments)
13489 }
13490
13491 fn state_inject_options<'a>() -> RenderOptions<'a> {
13492 RenderOptions::new("index.html", "/")
13493 }
13494
13495 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13496 let mut writer = TestWriter::new();
13497 handle(protocol, state, options, &mut writer).unwrap();
13498 writer.get_content().to_string()
13499 }
13500
13501 #[test]
13502 fn state_inject_emits_at_every_structural_boundary() {
13503 let protocol = build_all_boundaries_protocol();
13504 let state = test_json!({
13505 "$webui": {
13506 "headEnd": "<meta name=he>",
13507 "bodyStart": "<span id=bs></span>",
13508 "bodyEnd": "<script>be</script>",
13509 }
13510 });
13511 let html = render_with(&protocol, &state, &state_inject_options());
13512
13513 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13514 let head_close = html.find("</head>").expect("</head> missing");
13515 let body_open = html.find("<body>").expect("<body> missing");
13516 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13517 let hello = html.find("hello").expect("body content missing");
13518 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13519 let body_close = html.find("</body>").expect("</body> missing");
13520
13521 assert!(
13522 head_end < head_close,
13523 "headEnd must precede </head>: {html}"
13524 );
13525 assert!(
13526 body_open < body_start && body_start < hello,
13527 "bodyStart must sit immediately after <body>: {html}"
13528 );
13529 assert!(
13530 hello < body_end && body_end < body_close,
13531 "bodyEnd must precede </body>: {html}"
13532 );
13533
13534 for needle in [
13535 "<meta name=he>",
13536 "<span id=bs></span>",
13537 "<script>be</script>",
13538 ] {
13539 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13540 }
13541 }
13542
13543 #[test]
13544 fn state_inject_follows_render_options_inject() {
13545 let protocol = build_all_boundaries_protocol();
13546 let state = test_json!({
13547 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13548 });
13549 let options = RenderOptions::new("index.html", "/")
13550 .with_head_inject("<!--opt-he-->")
13551 .with_body_inject("<!--opt-be-->");
13552 let html = render_with(&protocol, &state, &options);
13553
13554 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13555 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13556 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13557 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13558
13559 assert!(
13560 opt_he < state_he,
13561 "RenderOptions head_inject must precede the state-supplied value: {html}"
13562 );
13563 assert!(
13564 opt_be < state_be,
13565 "RenderOptions body_inject must precede the state-supplied value: {html}"
13566 );
13567 }
13568
13569 #[test]
13570 fn malformed_state_inject_values_are_inert() {
13571 let protocol = build_all_boundaries_protocol();
13572 for state in [
13575 test_json!({}),
13576 test_json!({ "$webui": "not-an-object" }),
13577 test_json!({ "$webui": [] }),
13578 test_json!({ "$webui": null }),
13579 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13580 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13581 ] {
13582 let html = render_with(&protocol, &state, &state_inject_options());
13583 assert!(
13584 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13585 "malformed reserved state must render the document unchanged: {html}"
13586 );
13587 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13588 assert!(!html.contains("42"), "non-string member leaked: {html}");
13589 }
13590 }
13591
13592 #[test]
13593 fn state_inject_never_reaches_the_hydration_payload() {
13594 let protocol = build_all_boundaries_protocol();
13595 let state = test_json!({
13596 "visible": "keep",
13597 "$webui": { "bodyEnd": "<script>secret</script>" }
13598 });
13599 let handler = WebUIHandler::with_plugin(|| {
13600 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13601 });
13602 let runtime = Protocol::new(protocol.clone());
13603 let mut writer = TestWriter::new();
13604 handler
13605 .render(&runtime, &state, &state_inject_options(), &mut writer)
13606 .unwrap();
13607 let html = writer.get_content();
13608
13609 let data_start = html
13610 .find(r#"<script type="application/json" id="webui-data""#)
13611 .expect("hydration block missing");
13612 let data_end = html[data_start..]
13613 .find("</script>")
13614 .map(|offset| data_start + offset)
13615 .expect("hydration block never closes");
13616 let payload = &html[data_start..data_end];
13617 assert!(
13618 !payload.contains("$webui"),
13619 "reserved key must be stripped from the hydration payload: {payload}"
13620 );
13621 assert!(
13622 payload.contains("visible"),
13623 "ordinary state must survive the filter: {payload}"
13624 );
13625 let inject = html
13628 .find("<script>secret</script>")
13629 .expect("inject missing");
13630 let body_close = html.find("</body>").expect("</body> missing");
13631 assert!(
13632 data_end < inject && inject < body_close,
13633 "state bodyEnd must follow the hydration block and precede </body>: {html}"
13634 );
13635 }
13636
13637 #[test]
13638 fn projected_hydration_strips_reserved_state_inject_key() {
13639 let mut fragments = HashMap::new();
13640 fragments.insert(
13641 "index.html".to_string(),
13642 FragmentList {
13643 fragments: vec![
13644 WebUIFragment::raw("<html><body>"),
13645 WebUIFragment::component("app-shell"),
13646 structural_fragment("body_end"),
13647 WebUIFragment::raw("</body></html>"),
13648 ],
13649 contains_boundary: false,
13650 },
13651 );
13652 fragments.insert(
13653 "app-shell".to_string(),
13654 FragmentList {
13655 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13656 contains_boundary: false,
13657 },
13658 );
13659 let mut document = WebUIProtocol::new(fragments);
13660 document.initial_state_strategy = InitialStateStrategy::Components as i32;
13661 document.components.insert(
13662 "app-shell".to_string(),
13663 webui_protocol::ComponentData {
13664 hydration_mode: StateProjectionMode::Keys as i32,
13665 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13666 ..Default::default()
13667 },
13668 );
13669 let state = test_json!({
13670 "$webui": { "bodyEnd": "<script>secret</script>" },
13671 "serverOnly": "drop",
13672 "visible": "keep",
13673 });
13674 let handler = WebUIHandler::with_plugin(|| {
13675 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13676 });
13677 let mut writer = TestWriter::new();
13678
13679 handler
13680 .render(
13681 &Protocol::new(document),
13682 &state,
13683 &RenderOptions::new("index.html", "/"),
13684 &mut writer,
13685 )
13686 .unwrap();
13687 let html = writer.get_content();
13688 let data_start = html
13689 .find(r#"<script type="application/json" id="webui-data""#)
13690 .expect("hydration block missing");
13691 let data_end = html[data_start..]
13692 .find("</script>")
13693 .map(|offset| data_start + offset)
13694 .expect("hydration block never closes");
13695 let payload = &html[data_start..data_end];
13696
13697 assert!(
13698 !payload.contains(STATE_INJECT_KEY),
13699 "reserved key leaked: {payload}"
13700 );
13701 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13702 assert!(!payload.contains("serverOnly"), "{payload}");
13703 assert!(html.contains("<script>secret</script>"), "{html}");
13704 }
13705
13706 #[test]
13707 fn write_selected_state_strips_reserved_key_from_full_state() {
13708 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13709 let mut sink = TestWriter::new();
13710 let mut scratch = Vec::new();
13711 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13712 let json = sink.get_content();
13713 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13714 assert!(
13715 json.contains("\"a\":1") && json.contains("\"z\":2"),
13716 "{json}"
13717 );
13718 }
13719
13720 #[test]
13721 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13722 let mut protocol = WebUIProtocol::new(HashMap::new());
13725 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13726 protocol.fragments.insert(
13727 "keep-card".to_string(),
13728 webui_protocol::FragmentList::default(),
13729 );
13730 protocol.components.insert(
13731 "keep-card".to_string(),
13732 webui_protocol::ComponentData {
13733 hydration_mode: StateProjectionMode::Keys as i32,
13734 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13735 ..Default::default()
13736 },
13737 );
13738 let protocol = Protocol::new(protocol);
13739 let index = protocol.component_reachability();
13740 let component = protocol.component_index()["keep-card"];
13741 let mut ids = Vec::new();
13742 assert!(!collect_hydration_key_ids_into(
13743 protocol.protocol(),
13744 index,
13745 [component],
13746 &mut ids
13747 ));
13748
13749 let state = test_json!({
13750 "$webui": { "bodyEnd": "<b>x</b>" },
13751 "keep": 1,
13752 });
13753 let mut sink = TestWriter::new();
13754 let mut scratch = Vec::new();
13755
13756 write_selected_state(
13757 &mut sink,
13758 &mut scratch,
13759 &state,
13760 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13761 )
13762 .unwrap();
13763
13764 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13765 }
13766
13767 #[test]
13768 fn write_selected_state_full_is_unchanged_without_reserved_key() {
13769 let state = test_json!({ "a": 1, "z": 2 });
13770 let mut sink = TestWriter::new();
13771 let mut scratch = Vec::new();
13772 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13773 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13774 }
13775
13776 #[test]
13777 fn body_start_hook_dedupes_on_malformed_protocol() {
13778 let mut fragments = HashMap::new();
13779 fragments.insert(
13780 "index.html".to_string(),
13781 FragmentList {
13782 fragments: vec![
13783 WebUIFragment::raw("<html><head></head><body>".to_string()),
13784 structural_fragment("body_start"),
13785 structural_fragment("body_start"),
13786 WebUIFragment::raw("</body></html>".to_string()),
13787 ],
13788 contains_boundary: false,
13789 },
13790 );
13791 let protocol = WebUIProtocol::new(fragments);
13792 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13793 let html = render_with(&protocol, &state, &state_inject_options());
13794 assert_eq!(
13795 html.matches("<i>once</i>").count(),
13796 1,
13797 "duplicate body_start signals must not duplicate the inject: {html}"
13798 );
13799 }
13800
13801 #[test]
13802 fn injects_are_no_op_when_unset() {
13803 let protocol = build_head_body_protocol();
13804 let state = test_json!({});
13805 let mut writer = TestWriter::new();
13806 handle(
13807 &protocol,
13808 &state,
13809 &RenderOptions::new("index.html", "/"),
13810 &mut writer,
13811 )
13812 .unwrap();
13813 let html = writer.get_content();
13814 assert!(!html.contains("<link rel=preload>"));
13815 assert!(!html.contains("<script>lr</script>"));
13816 }
13817
13818 #[test]
13819 fn empty_inject_string_treated_as_unset() {
13820 let protocol = build_head_body_protocol();
13821 let state = test_json!({});
13822 let mut writer = TestWriter::new();
13823 let opts = RenderOptions::new("index.html", "/")
13824 .with_head_inject("")
13825 .with_body_inject("");
13826 handle(&protocol, &state, &opts, &mut writer).unwrap();
13827 let html = writer.get_content();
13830 assert!(html.contains("</head>"));
13831 assert!(html.contains("</body>"));
13832 }
13833
13834 #[test]
13835 fn inject_html_is_passed_through_verbatim() {
13836 let protocol = build_head_body_protocol();
13840 let state = test_json!({});
13841 let mut writer = TestWriter::new();
13842 let opts =
13843 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13844 handle(&protocol, &state, &opts, &mut writer).unwrap();
13845 assert!(writer.get_content().contains("<script>var x=1;</script>"));
13846 }
13847
13848 #[test]
13855 fn injects_robust_against_marker_literals_in_content() {
13856 let mut fragments = HashMap::new();
13857 fragments.insert(
13861 "index.html".to_string(),
13862 FragmentList {
13863 fragments: vec![
13864 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13865 structural_fragment("head_end"),
13866 WebUIFragment::raw(
13867 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13868 ),
13869 structural_fragment("body_end"),
13870 WebUIFragment::raw("</body></html>".to_string()),
13871 ],
13872 contains_boundary: false,
13873 },
13874 );
13875 let protocol = WebUIProtocol::new(fragments);
13876 let state = test_json!({});
13877 let mut writer = TestWriter::new();
13878 let opts = RenderOptions::new("index.html", "/")
13879 .with_head_inject("<HEAD-INJ>")
13880 .with_body_inject("<BODY-INJ>");
13881 handle(&protocol, &state, &opts, &mut writer).unwrap();
13882 let html = writer.get_content();
13883 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13886 let head_close_idx = html.find("</head>").expect("</head> missing");
13887 assert!(head_inj_idx < head_close_idx);
13888 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13892 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13894 assert!(
13895 body_inj_idx < body_close_idx,
13896 "body_inject must precede the structural </body>: {html}"
13897 );
13898 assert!(html.contains("<!-- </body> </head> -->"));
13900 }
13901
13902 #[test]
13906 fn both_injects_fire_at_correct_boundaries() {
13907 let protocol = build_head_body_protocol();
13908 let state = test_json!({});
13909 let mut writer = TestWriter::new();
13910 let opts = RenderOptions::new("index.html", "/")
13911 .with_head_inject("<META-HEAD>")
13912 .with_body_inject("<SCRIPT-BODY>");
13913 handle(&protocol, &state, &opts, &mut writer).unwrap();
13914 let html = writer.get_content();
13915 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13916 let head_close = html.find("</head>").expect("</head> missing");
13917 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13918 let body_close = html.find("</body>").expect("</body> missing");
13919 assert!(head_idx < head_close, "head_inject before </head>");
13920 assert!(head_close < body_idx, "body_inject after </head>");
13921 assert!(body_idx < body_close, "body_inject before </body>");
13922 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13923 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13924 }
13925
13926 #[test]
13932 fn injects_dedupe_against_duplicate_signals() {
13933 let mut fragments = HashMap::new();
13934 fragments.insert(
13935 "index.html".to_string(),
13936 FragmentList {
13937 fragments: vec![
13938 WebUIFragment::raw("<html><head>".to_string()),
13939 structural_fragment("head_end"),
13940 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13943 structural_fragment("body_end"),
13944 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13946 ],
13947 contains_boundary: false,
13948 },
13949 );
13950 let protocol = WebUIProtocol::new(fragments);
13951 let state = test_json!({});
13952 let mut writer = TestWriter::new();
13953 let opts = RenderOptions::new("index.html", "/")
13954 .with_head_inject("<HINJ>")
13955 .with_body_inject("<BINJ>");
13956 handle(&protocol, &state, &opts, &mut writer).unwrap();
13957 let html = writer.get_content();
13958 assert_eq!(
13959 html.matches("<HINJ>").count(),
13960 1,
13961 "head_inject must emit exactly once even with duplicate head_end signals"
13962 );
13963 assert_eq!(
13964 html.matches("<BINJ>").count(),
13965 1,
13966 "body_inject must emit exactly once even with duplicate body_end signals"
13967 );
13968 }
13969
13970 #[test]
13974 fn injects_no_op_when_no_head_or_body_signals() {
13975 let mut fragments = HashMap::new();
13976 fragments.insert(
13977 "index.html".to_string(),
13978 FragmentList {
13979 fragments: vec![WebUIFragment::raw(
13980 "<my-component>hi</my-component>".to_string(),
13981 )],
13982 contains_boundary: false,
13983 },
13984 );
13985 let protocol = WebUIProtocol::new(fragments);
13986 let state = test_json!({});
13987 let mut writer = TestWriter::new();
13988 let opts = RenderOptions::new("index.html", "/")
13989 .with_head_inject("<HINJ>")
13990 .with_body_inject("<BINJ>");
13991 handle(&protocol, &state, &opts, &mut writer).unwrap();
13992 let html = writer.get_content();
13993 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13994 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13995 assert!(html.contains("<my-component>"));
13996 }
13997
13998 #[test]
14004 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
14005 let protocol = std::sync::Arc::new(build_head_body_protocol());
14006 let state = std::sync::Arc::new(test_json!({}));
14007 let handler = std::sync::Arc::new(WebUIHandler::new());
14008
14009 const N_THREADS: usize = 16;
14010 let mut handles = Vec::with_capacity(N_THREADS);
14011 for tid in 0..N_THREADS {
14012 let h = std::sync::Arc::clone(&handler);
14013 let p = std::sync::Arc::clone(&protocol);
14014 let s = std::sync::Arc::clone(&state);
14015 handles.push(std::thread::spawn(move || {
14016 let head = format!("<HEAD-T{tid}>");
14017 let body = format!("<BODY-T{tid}>");
14018 let mut writer = TestWriter::new();
14019 let opts = RenderOptions::new("index.html", "/")
14020 .with_head_inject(&head)
14021 .with_body_inject(&body);
14022 h.handle(&p, &s, &opts, &mut writer).unwrap();
14023 let html = writer.get_content();
14024 assert_eq!(html.matches(&head).count(), 1);
14026 assert_eq!(html.matches(&body).count(), 1);
14027 for other in 0..N_THREADS {
14029 if other == tid {
14030 continue;
14031 }
14032 let other_head = format!("<HEAD-T{other}>");
14033 let other_body = format!("<BODY-T{other}>");
14034 assert!(
14035 !html.contains(&other_head),
14036 "tid {tid} saw {other}'s head_inject"
14037 );
14038 assert!(
14039 !html.contains(&other_body),
14040 "tid {tid} saw {other}'s body_inject"
14041 );
14042 }
14043 }));
14044 }
14045 for h in handles {
14046 h.join().expect("worker panicked");
14047 }
14048 }
14049
14050 #[test]
14055 fn large_inject_roundtrips_without_truncation() {
14056 let protocol = build_head_body_protocol();
14057 let state = test_json!({});
14058 let mut writer = TestWriter::new();
14059 let big = "x".repeat(1024 * 1024);
14060 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
14061 handle(&protocol, &state, &opts, &mut writer).unwrap();
14062 let html = writer.get_content();
14063 assert!(
14064 html.contains(&big),
14065 "large head_inject must be present verbatim ({} bytes)",
14066 big.len()
14067 );
14068 assert_eq!(html.matches(&big).count(), 1);
14070 }
14071
14072 #[test]
14077 fn empty_nonce_treated_as_unset() {
14078 let protocol = build_head_body_protocol();
14079 let state = test_json!({});
14080 let mut writer = TestWriter::new();
14081 let opts = RenderOptions::new("index.html", "/").with_nonce("");
14082 handle(&protocol, &state, &opts, &mut writer).unwrap();
14083 assert!(
14084 !writer.get_content().contains("webui-nonce"),
14085 "empty nonce must not emit <meta name=\"webui-nonce\">"
14086 );
14087 }
14088
14089 #[test]
14107 fn empty_field_bypass_is_normalised_at_handler_init() {
14108 let protocol = build_head_body_protocol();
14109 let state = test_json!({});
14110
14111 let opts_with_empty_nonce = RenderOptions {
14113 nonce: Some(""),
14114 ..RenderOptions::new("index.html", "/")
14115 };
14116 let mut writer = TestWriter::new();
14117 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
14118 let html = writer.get_content();
14119 assert!(
14120 !html.contains("webui-nonce"),
14121 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
14122 );
14123 assert!(
14124 !html.contains("nonce=\"\""),
14125 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
14126 );
14127
14128 let opts_with_empty_injects = RenderOptions {
14130 head_inject: Some(""),
14131 body_inject: Some(""),
14132 ..RenderOptions::new("index.html", "/")
14133 };
14134 let mut writer = TestWriter::new();
14135 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
14136 }
14140
14141 #[test]
14174 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
14175 let mut fragments = HashMap::new();
14176 fragments.insert(
14177 "index.html".to_string(),
14178 FragmentList {
14179 fragments: vec![
14180 WebUIFragment::raw("["),
14181 WebUIFragment::for_loop("item", "outer", "outer_body"),
14182 WebUIFragment::raw("]"),
14183 ],
14184 contains_boundary: false,
14185 },
14186 );
14187 fragments.insert(
14188 "outer_body".to_string(),
14189 FragmentList {
14190 fragments: vec![
14191 WebUIFragment::raw("(O="),
14192 WebUIFragment::signal("item.tag", false),
14193 WebUIFragment::for_loop("item", "inner", "inner_body"),
14194 WebUIFragment::raw(",O="),
14195 WebUIFragment::signal("item.tag", false),
14196 WebUIFragment::raw(")"),
14197 ],
14198 contains_boundary: false,
14199 },
14200 );
14201 fragments.insert(
14202 "inner_body".to_string(),
14203 FragmentList {
14204 fragments: vec![
14205 WebUIFragment::raw("[I="),
14206 WebUIFragment::signal("item.tag", false),
14207 WebUIFragment::raw("]"),
14208 ],
14209 contains_boundary: false,
14210 },
14211 );
14212 let protocol = WebUIProtocol::new(fragments);
14213 let state = test_json!({
14214 "outer": [{"tag": "A"}, {"tag": "B"}],
14215 "inner": [{"tag": "X"}, {"tag": "Y"}],
14216 });
14217 let mut writer = TestWriter::new();
14218 handle(
14219 &protocol,
14220 &state,
14221 &RenderOptions::new("index.html", "/"),
14222 &mut writer,
14223 )
14224 .unwrap();
14225 assert_eq!(
14237 writer.get_content(),
14238 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
14239 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
14240 );
14241 }
14242 #[derive(Default)]
14243 struct FlushTestWriter {
14244 output: String,
14245 flushes: Vec<usize>,
14246 fail_flush: bool,
14247 fail_flush_at: Option<usize>,
14248 flush_attempts: usize,
14249 ended: bool,
14250 }
14251
14252 impl ResponseWriter for FlushTestWriter {
14253 fn write(&mut self, content: &str) -> Result<()> {
14254 self.output.push_str(content);
14255 Ok(())
14256 }
14257
14258 fn end(&mut self) -> Result<()> {
14259 self.ended = true;
14260 Ok(())
14261 }
14262 }
14263
14264 impl FlushWriter for FlushTestWriter {
14265 fn flush(&mut self) -> Result<()> {
14266 let attempt = self.flush_attempts;
14267 self.flush_attempts += 1;
14268 if self.fail_flush || self.fail_flush_at == Some(attempt) {
14269 return Err(HandlerError::ClientDisconnected);
14270 }
14271 self.flushes.push(self.output.len());
14272 Ok(())
14273 }
14274 }
14275
14276 fn streaming_protocol(with_boundaries: bool) -> Protocol {
14277 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14278 }
14279
14280 fn streaming_protocol_with_state_strategy(
14281 with_boundaries: bool,
14282 state_strategy: InitialStateStrategy,
14283 ) -> Protocol {
14284 let mut fragments = HashMap::new();
14285 let mut entry = vec![
14286 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14287 structural_fragment("head_start"),
14288 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14289 structural_fragment("head_end"),
14290 WebUIFragment::raw("</HEAD><body>"),
14291 structural_fragment("body_start"),
14292 ];
14293 if with_boundaries {
14294 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14295 }
14296 entry.extend([
14297 WebUIFragment::raw("<my-counter"),
14298 structural_fragment("streaming_root:my-counter"),
14299 WebUIFragment::raw(">"),
14300 WebUIFragment::component("my-counter"),
14301 WebUIFragment::raw("</my-counter>"),
14302 ]);
14303 if with_boundaries {
14304 entry.push(WebUIFragment::boundary_end(0));
14305 }
14306 entry.extend([
14307 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14308 structural_fragment("body_end"),
14309 WebUIFragment::raw("</body></html>"),
14310 ]);
14311 fragments.insert(
14312 "index.html".to_string(),
14313 FragmentList {
14314 fragments: entry,
14315 contains_boundary: with_boundaries,
14316 },
14317 );
14318 fragments.insert(
14319 "my-counter".to_string(),
14320 FragmentList {
14321 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14322 contains_boundary: false,
14323 },
14324 );
14325
14326 let mut document = WebUIProtocol::new(fragments);
14327 document.initial_state_strategy = state_strategy as i32;
14328 document.components.insert(
14329 "my-counter".to_string(),
14330 webui_protocol::ComponentData {
14331 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14332 template_functions: "[function(){return true}]".to_string(),
14333 hydration_mode: StateProjectionMode::Keys as i32,
14334 hydration_keys: vec!["count".to_string()],
14335 ..Default::default()
14336 },
14337 );
14338 Protocol::new(document)
14339 }
14340
14341 #[test]
14342 fn handler_error_stays_small() {
14343 assert!(
14350 std::mem::size_of::<HandlerError>()
14351 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14352 "HandlerError grew to {} bytes",
14353 std::mem::size_of::<HandlerError>()
14354 );
14355 }
14356
14357 #[test]
14358 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14359 let protocol = streaming_protocol(true);
14360 let handler = WebUIHandler::with_plugin(|| {
14361 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14362 });
14363 let state = test_json!({ "count": 1, "serverOnly": "secret" });
14364 let mut writer = FlushTestWriter::default();
14365
14366 handler
14367 .render_streaming(
14368 &protocol,
14369 &state,
14370 &RenderOptions::new("index.html", "/"),
14371 &mut writer,
14372 )
14373 .unwrap();
14374
14375 assert!(writer.ended);
14376 assert_eq!(
14377 writer.flushes.len(),
14378 3,
14379 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14380 );
14381 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14382 assert!(boundary_flush.contains("<!--wb:0-->"));
14383 assert!(boundary_flush.contains("<!--/wb:0-->"));
14384 assert!(
14385 boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14386 "boundary flush: {boundary_flush}"
14387 );
14388 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14389 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14390 assert!(!boundary_flush.contains("slow tail"));
14391 assert!(!writer.output.contains("id=\"webui-data\""));
14392 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14395 assert!(terminal_flush.contains("slow tail"));
14396 assert!(writer.output.contains("[1,4,0,{}]"));
14397 assert!(!writer.output.contains("[1,0,1,"));
14398 assert!(!writer.output.contains("[2,4,0,{}]"));
14399
14400 let marker = writer
14401 .output
14402 .find(STREAMING_MARKER)
14403 .expect("streaming marker");
14404 let attributed_head = writer
14405 .output
14406 .find("<HEAD data-shell=\"main\">")
14407 .expect("attributed mixed-case head");
14408 let authored_script = writer
14409 .output
14410 .find("src=\"/index.js\"")
14411 .expect("entry script");
14412 assert!(attributed_head < marker && marker < authored_script);
14413 }
14414
14415 #[test]
14416 fn streaming_terminal_tail_never_resends_full_state() {
14417 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14418 let handler = WebUIHandler::with_plugin(|| {
14419 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14420 });
14421 let mut writer = FlushTestWriter::default();
14422
14423 handler
14424 .render_streaming(
14425 &protocol,
14426 &test_json!({ "count": 1, "serverOnly": "secret" }),
14427 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14428 &mut writer,
14429 )
14430 .unwrap();
14431
14432 assert_eq!(
14433 writer.output.matches("serverOnly").count(),
14434 1,
14435 "full state belongs only to the interactive boundary"
14436 );
14437 assert!(writer.output.contains("[1,4,0,{}]"));
14438 }
14439
14440 #[test]
14441 fn streaming_terminal_record_emits_before_body_close() {
14442 let fragments = HashMap::from([(
14443 "index.html".to_string(),
14444 FragmentList {
14445 fragments: vec![
14446 WebUIFragment::raw("<html><head>"),
14447 structural_fragment("head_start"),
14448 structural_fragment("head_end"),
14449 WebUIFragment::raw("</head><body>"),
14450 structural_fragment("body_start"),
14451 WebUIFragment::raw("<main>static</main>"),
14452 structural_fragment("body_end"),
14453 WebUIFragment::raw("</body></html>"),
14454 ],
14455 contains_boundary: false,
14456 },
14457 )]);
14458 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14459 let mut writer = FlushTestWriter::default();
14460
14461 WebUIHandler::new()
14462 .render_streaming(
14463 &protocol,
14464 &test_json!({}),
14465 &RenderOptions::new("index.html", "/"),
14466 &mut writer,
14467 )
14468 .unwrap();
14469
14470 let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14471 let body_close = writer.output.find("</body>").expect("body close");
14472 assert!(
14473 terminal < body_close,
14474 "terminal boundary record must stay inside <body>: {}",
14475 writer.output
14476 );
14477 }
14478
14479 #[test]
14483 fn state_inject_placement_matches_between_render_modes() {
14484 let entry = vec![
14485 WebUIFragment::raw("<html><head>"),
14486 structural_fragment("head_start"),
14487 structural_fragment("head_end"),
14488 WebUIFragment::raw("</head><body>"),
14489 structural_fragment("body_start"),
14490 WebUIFragment::raw("<main>static</main>"),
14491 structural_fragment("body_end"),
14492 WebUIFragment::raw("</body></html>"),
14493 ];
14494 let fragments = HashMap::from([(
14495 "index.html".to_string(),
14496 FragmentList {
14497 fragments: entry,
14498 contains_boundary: false,
14499 },
14500 )]);
14501 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14502 let state = test_json!({
14503 "$webui": {
14504 "headEnd": "<meta name=he>",
14505 "bodyStart": "<span id=bs></span>",
14506 "bodyEnd": "<script>be</script>",
14507 }
14508 });
14509 let options = RenderOptions::new("index.html", "/");
14510
14511 let mut ordinary = TestWriter::new();
14512 WebUIHandler::new()
14513 .render(&protocol, &state, &options, &mut ordinary)
14514 .unwrap();
14515 let ordinary_html = ordinary.get_content().to_string();
14516
14517 let mut streamed = FlushTestWriter::default();
14518 WebUIHandler::new()
14519 .render_streaming(&protocol, &state, &options, &mut streamed)
14520 .unwrap();
14521 let streamed_html = &streamed.output;
14522
14523 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14524 let head_end = html.find("<meta name=he>").expect("headEnd missing");
14525 let head_close = html.find("</head>").expect("</head> missing");
14526 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14527 let main = html.find("<main>static</main>").expect("content missing");
14528 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14529 let body_close = html.find("</body>").expect("</body> missing");
14530 assert!(head_end < head_close, "headEnd misplaced: {html}");
14531 assert!(body_start < main, "bodyStart misplaced: {html}");
14532 assert!(
14533 main < body_end && body_end < body_close,
14534 "bodyEnd misplaced: {html}"
14535 );
14536 }
14537
14538 assert!(
14541 streamed_html.contains(",4,0,{}]"),
14542 "streaming must still end in one empty terminal record: {streamed_html}"
14543 );
14544 }
14545
14546 #[test]
14547 fn streaming_state_inject_emits_and_strips_reserved_key() {
14548 let entry = vec![
14549 WebUIFragment::raw("<html><head>"),
14550 structural_fragment("head_start"),
14551 structural_fragment("head_end"),
14552 WebUIFragment::raw("</head><body>"),
14553 structural_fragment("body_start"),
14554 structural_fragment("body_end"),
14555 WebUIFragment::raw("</body></html>"),
14556 ];
14557 let fragments = HashMap::from([(
14558 "index.html".to_string(),
14559 FragmentList {
14560 fragments: entry,
14561 contains_boundary: false,
14562 },
14563 )]);
14564 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14565 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14566
14567 let mut writer = FlushTestWriter::default();
14568 WebUIHandler::new()
14569 .render_streaming(
14570 &protocol,
14571 &state,
14572 &RenderOptions::new("index.html", "/"),
14573 &mut writer,
14574 )
14575 .unwrap();
14576 assert!(writer.output.contains("<script>be</script>"));
14577 assert!(!writer.output.contains("$webui"));
14578 }
14579
14580 #[test]
14581 fn static_streaming_document_uses_one_empty_terminal_record() {
14582 let fragments = HashMap::from([(
14583 "index.html".to_string(),
14584 FragmentList {
14585 fragments: vec![
14586 WebUIFragment::raw("<html><head>"),
14587 structural_fragment("head_start"),
14588 structural_fragment("head_end"),
14589 WebUIFragment::raw("</head><body>"),
14590 structural_fragment("body_start"),
14591 WebUIFragment::raw("<main>static</main>"),
14592 structural_fragment("body_end"),
14593 WebUIFragment::raw("</body></html>"),
14594 ],
14595 contains_boundary: false,
14596 },
14597 )]);
14598 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14599 let mut writer = FlushTestWriter::default();
14600
14601 WebUIHandler::new()
14602 .render_streaming(
14603 &protocol,
14604 &test_json!({ "serverOnly": "secret" }),
14605 &RenderOptions::new("index.html", "/"),
14606 &mut writer,
14607 )
14608 .unwrap();
14609
14610 assert_eq!(writer.flushes.len(), 1);
14611 assert!(writer.output.contains(STREAMING_MARKER));
14612 assert!(writer.output.contains("[0,4,0,{}]"));
14613 assert!(!writer.output.contains("serverOnly"));
14614 assert!(!writer.output.contains("id=\"webui-data\""));
14615 assert!(!writer.output.contains("<!--wb:"));
14616 }
14617
14618 #[test]
14619 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14620 let protocol = streaming_protocol(true);
14621 let handler = WebUIHandler::with_plugin(|| {
14622 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14623 });
14624 let mut writer = FlushTestWriter::default();
14625 handler
14626 .render_streaming(
14627 &protocol,
14628 &test_json!({ "count": 1 }),
14629 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14630 &mut writer,
14631 )
14632 .unwrap();
14633
14634 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14635 let envelope = writer.output[boundary_end..]
14636 .find("data-webui-boundary nonce=\"test-nonce-123\"")
14637 .map(|index| index + boundary_end)
14638 .expect("nonce-bearing envelope");
14639 let functions = writer
14640 .output
14641 .find("templateFns")
14642 .expect("function side channel");
14643 let sentinel = writer
14644 .output
14645 .find("<webui-hydrate>")
14646 .expect("hydration sentinel");
14647 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14648 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14649 }
14650
14651 #[test]
14652 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14653 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14654 let mut fragments = vec![structural_fragment("head_start")];
14655 fragments.extend(boundaries);
14656 let fragments = HashMap::from([(
14657 "index.html".to_string(),
14658 FragmentList {
14659 fragments,
14660 contains_boundary: true,
14661 },
14662 )]);
14663 Protocol::new(WebUIProtocol::new(fragments))
14664 }
14665
14666 let handler = WebUIHandler::new();
14667 let mut writer = FlushTestWriter::default();
14668 let result = handler.render_streaming(
14669 &protocol(vec![WebUIFragment::boundary_end(0)]),
14670 &test_json!({}),
14671 &RenderOptions::new("index.html", "/"),
14672 &mut writer,
14673 );
14674 match result {
14675 Err(HandlerError::StreamingBoundary(err)) => {
14676 assert_eq!(err.signal, "boundary");
14677 assert!(err.reason.contains("no active occurrence"));
14678 }
14679 other => panic!("expected ordered-boundary error, got {other:?}"),
14680 }
14681
14682 let mut writer = FlushTestWriter::default();
14683 let result = handler.render_streaming(
14684 &protocol(vec![
14685 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14686 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14687 ]),
14688 &test_json!({}),
14689 &RenderOptions::new("index.html", "/"),
14690 &mut writer,
14691 );
14692 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14693 }
14694
14695 #[test]
14696 fn streaming_render_propagates_boundary_flush_disconnect() {
14697 let protocol = streaming_protocol(true);
14698 let handler = WebUIHandler::new();
14699 let mut writer = FlushTestWriter {
14700 fail_flush: true,
14701 ..FlushTestWriter::default()
14702 };
14703 let result = handler.render_streaming(
14704 &protocol,
14705 &test_json!({}),
14706 &RenderOptions::new("index.html", "/"),
14707 &mut writer,
14708 );
14709 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14710 assert!(!writer.ended);
14711 }
14712
14713 #[test]
14714 fn streaming_render_requires_structural_head_start_before_writing() {
14715 let fragments = HashMap::from([(
14716 "index.html".to_string(),
14717 FragmentList {
14718 fragments: vec![
14719 WebUIFragment::raw(
14720 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14721 ),
14722 structural_fragment("head_end"),
14723 WebUIFragment::raw("</HEAD><body>"),
14724 structural_fragment("body_start"),
14725 structural_fragment("body_end"),
14726 ],
14727 contains_boundary: false,
14728 },
14729 )]);
14730 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14731 let mut writer = FlushTestWriter::default();
14732
14733 let result = WebUIHandler::new().render_streaming(
14734 &protocol,
14735 &test_json!({}),
14736 &RenderOptions::new("index.html", "/"),
14737 &mut writer,
14738 );
14739
14740 assert!(matches!(
14741 result,
14742 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14743 ));
14744 assert_eq!(
14745 writer.output,
14746 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14747 );
14748 assert!(writer.flushes.is_empty());
14749 assert!(!writer.ended);
14750 }
14751
14752 #[test]
14753 fn streaming_render_rejects_duplicate_head_start() {
14754 let fragments = HashMap::from([(
14755 "index.html".to_string(),
14756 FragmentList {
14757 fragments: vec![
14758 WebUIFragment::raw("<html><head>"),
14759 structural_fragment("head_start"),
14760 structural_fragment("head_start"),
14761 structural_fragment("head_end"),
14762 WebUIFragment::raw("</head><body>"),
14763 structural_fragment("body_start"),
14764 structural_fragment("body_end"),
14765 ],
14766 contains_boundary: false,
14767 },
14768 )]);
14769 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14770 let mut writer = FlushTestWriter::default();
14771
14772 let result = WebUIHandler::new().render_streaming(
14773 &protocol,
14774 &test_json!({}),
14775 &RenderOptions::new("index.html", "/"),
14776 &mut writer,
14777 );
14778
14779 assert!(matches!(
14780 result,
14781 Err(HandlerError::DuplicateStreamingHeadStart)
14782 ));
14783 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14784 assert!(writer.flushes.is_empty());
14785 }
14786
14787 #[test]
14788 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14789 let handler = WebUIHandler::with_plugin(|| {
14790 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14791 });
14792 let state = test_json!({
14793 "count": 1,
14794 "head_start": "must not render",
14795 "boundary_start:0": "must not render",
14796 "boundary_end:0": "must not render",
14797 });
14798 let mut with_boundaries = TestWriter::new();
14799 let mut without_boundaries = TestWriter::new();
14800 handler
14801 .render(
14802 &streaming_protocol(true),
14803 &state,
14804 &RenderOptions::new("index.html", "/"),
14805 &mut with_boundaries,
14806 )
14807 .unwrap();
14808 handler
14809 .render(
14810 &streaming_protocol(false),
14811 &state,
14812 &RenderOptions::new("index.html", "/"),
14813 &mut without_boundaries,
14814 )
14815 .unwrap();
14816 assert_eq!(
14817 with_boundaries.get_content(),
14818 without_boundaries.get_content()
14819 );
14820 }
14821
14822 #[test]
14823 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14824 for source in [
14825 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14826 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14827 ] {
14828 let mut parser = HtmlParser::new();
14829 parser
14830 .parse("index.html", source)
14831 .expect("parse head fixture");
14832 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14833 let mut writer = TestWriter::new();
14834 WebUIHandler::new()
14835 .render(
14836 &protocol,
14837 &test_json!({ "theme": "light" }),
14838 &RenderOptions::new("index.html", "/"),
14839 &mut writer,
14840 )
14841 .unwrap();
14842
14843 assert_eq!(
14844 writer.get_content(),
14845 "<html><head><title>T</title></head><body>x</body></html>"
14846 );
14847 }
14848 }
14849
14850 #[test]
14851 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14852 let source =
14853 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14854 let mut parser = HtmlParser::new();
14855 parser.parse("index.html", source).expect("parse fixture");
14856 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14857
14858 let mut ordinary = TestWriter::new();
14859 WebUIHandler::new()
14860 .render(
14861 &protocol,
14862 &test_json!({}),
14863 &RenderOptions::new("index.html", "/"),
14864 &mut ordinary,
14865 )
14866 .unwrap();
14867 assert_eq!(ordinary.get_content(), source);
14868
14869 let mut streaming = FlushTestWriter::default();
14870 WebUIHandler::new()
14871 .render_streaming(
14872 &protocol,
14873 &test_json!({}),
14874 &RenderOptions::new("index.html", "/"),
14875 &mut streaming,
14876 )
14877 .unwrap();
14878 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14879 assert!(streaming.output.contains("</HEAD><BODY>x"));
14880 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14881 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14882 let title = streaming.output.find("<title>").expect("title");
14883 assert!(opening < marker && marker < title);
14884 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14885 }
14886
14887 #[test]
14890 fn unnamespaced_signal_is_ordinary_content() {
14891 let fragments = HashMap::from([(
14892 "index.html".to_string(),
14893 FragmentList {
14894 fragments: vec![
14895 WebUIFragment::raw("<html><head>"),
14896 structural_fragment("head_end"),
14897 WebUIFragment::raw("</head><body>"),
14898 WebUIFragment::signal("body_end".to_string(), false),
14899 structural_fragment("body_end"),
14900 WebUIFragment::raw("</body></html>"),
14901 ],
14902 contains_boundary: false,
14903 },
14904 )]);
14905 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14906 let mut writer = TestWriter::new();
14907 WebUIHandler::new()
14908 .render(
14909 &protocol,
14910 &test_json!({ "body_end": "content" }),
14911 &RenderOptions::new("index.html", "/"),
14912 &mut writer,
14913 )
14914 .expect("current protocol must render");
14915 assert!(writer.get_content().contains("content"));
14916 }
14917
14918 #[test]
14919 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14920 let source = concat!(
14921 "<html><head></head><body>",
14922 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14923 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14924 "</body></html>",
14925 );
14926 let state = test_json!({
14927 "head_start": "hs",
14928 "head_end": "he",
14929 "body_start": "bs",
14930 "body_end": "be",
14931 "boundary_start:0": "b0s",
14932 "boundary_end:0": "b0e",
14933 "streaming_root:forged": "root",
14934 });
14935 let mut parser = HtmlParser::new();
14936 parser.parse("index.html", source).expect("parse fixture");
14937 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14938 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14939
14940 let mut ordinary = TestWriter::new();
14941 WebUIHandler::new()
14942 .render(
14943 &protocol,
14944 &state,
14945 &RenderOptions::new("index.html", "/"),
14946 &mut ordinary,
14947 )
14948 .unwrap();
14949 assert_eq!(ordinary.get_content(), expected);
14950
14951 let mut streaming = FlushTestWriter::default();
14952 WebUIHandler::new()
14953 .render_streaming(
14954 &protocol,
14955 &state,
14956 &RenderOptions::new("index.html", "/"),
14957 &mut streaming,
14958 )
14959 .unwrap();
14960 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14961 assert!(!streaming.output.contains("<!--wb:"));
14962 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14963 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14964 }
14965
14966 #[test]
14967 fn streaming_rejects_structural_signals_after_terminal() {
14968 let fragments = HashMap::from([(
14969 "index.html".to_string(),
14970 FragmentList {
14971 fragments: vec![
14972 WebUIFragment::raw("<html><head>"),
14973 structural_fragment("head_start"),
14974 structural_fragment("head_end"),
14975 WebUIFragment::raw("</head><body>"),
14976 structural_fragment("body_start"),
14977 structural_fragment("body_end"),
14978 WebUIFragment::raw("</body>"),
14979 structural_fragment("boundary_start:0"),
14980 WebUIFragment::raw("</html>"),
14981 ],
14982 contains_boundary: false,
14983 },
14984 )]);
14985 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14986 let mut streaming = FlushTestWriter::default();
14987 let result = WebUIHandler::new().render_streaming(
14988 &protocol,
14989 &test_json!({}),
14990 &RenderOptions::new("index.html", "/"),
14991 &mut streaming,
14992 );
14993
14994 match result {
14995 Err(HandlerError::StreamingBoundary(error)) => {
14996 assert_eq!(error.signal, "boundary_start:0");
14997 assert!(
14998 error
14999 .reason
15000 .contains("structural signal arrived after body_end"),
15001 "reason: {}",
15002 error.reason
15003 );
15004 }
15005 other => panic!("expected post-terminal rejection, got {other:?}"),
15006 }
15007 assert_eq!(
15008 streaming.output,
15009 concat!(
15010 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
15011 "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
15012 "<webui-hydrate></webui-hydrate></body>"
15013 )
15014 );
15015 assert!(!streaming.output.contains("<!--wb:0-->"));
15016
15017 let mut ordinary = TestWriter::new();
15018 WebUIHandler::new()
15019 .render(
15020 &protocol,
15021 &test_json!({}),
15022 &RenderOptions::new("index.html", "/"),
15023 &mut ordinary,
15024 )
15025 .unwrap();
15026 assert_eq!(
15027 ordinary.get_content(),
15028 "<html><head></head><body></body></html>"
15029 );
15030 }
15031
15032 fn disjoint_streaming_protocol_ext(
15033 hosts: &[&str],
15034 emit_root_signal: bool,
15035 include_styles: bool,
15036 ) -> Protocol {
15037 let mut fragments = HashMap::new();
15038 let mut entry = vec![
15039 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
15040 structural_fragment("head_start"),
15041 structural_fragment("head_end"),
15042 WebUIFragment::raw("</head><body>"),
15043 structural_fragment("body_start"),
15044 ];
15045 for (sequence, host) in hosts.iter().enumerate() {
15046 entry.push(WebUIFragment::boundary(
15047 sequence as u32,
15048 "index.html",
15049 format!("boundary-{sequence}"),
15050 None,
15051 ));
15052 entry.push(WebUIFragment::raw(format!("<{host}")));
15053 if emit_root_signal {
15054 entry.push(structural_fragment(format!("streaming_root:{host}")));
15055 }
15056 entry.push(WebUIFragment::raw(">"));
15057 entry.push(WebUIFragment::component(*host));
15058 entry.push(WebUIFragment::raw(format!("</{host}>")));
15059 entry.push(WebUIFragment::boundary_end(sequence as u32));
15060 }
15061 entry.push(structural_fragment("body_end"));
15062 entry.push(WebUIFragment::raw("</body></html>"));
15063 fragments.insert(
15064 "index.html".to_string(),
15065 FragmentList {
15066 fragments: entry,
15067 contains_boundary: true,
15068 },
15069 );
15070 fragments.insert(
15071 "comp-a".to_string(),
15072 FragmentList {
15073 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15074 contains_boundary: false,
15075 },
15076 );
15077 fragments.insert(
15078 "comp-b".to_string(),
15079 FragmentList {
15080 fragments: vec![WebUIFragment::raw("<b>B</b>")],
15081 contains_boundary: false,
15082 },
15083 );
15084
15085 let mut document = WebUIProtocol::new(fragments);
15086 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15087 document.components.insert(
15088 "comp-a".to_string(),
15089 webui_protocol::ComponentData {
15090 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
15091 hydration_mode: StateProjectionMode::Keys as i32,
15092 hydration_keys: vec!["a_count".to_string()],
15093 ..Default::default()
15094 },
15095 );
15096 document.components.insert(
15097 "comp-b".to_string(),
15098 webui_protocol::ComponentData {
15099 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
15100 hydration_mode: StateProjectionMode::Keys as i32,
15101 hydration_keys: vec!["b_count".to_string()],
15102 ..Default::default()
15103 },
15104 );
15105 if include_styles {
15106 document.set_css_strategy(webui_protocol::CssStrategy::Style);
15107 document
15108 .components
15109 .get_mut("comp-a")
15110 .expect("comp-a component")
15111 .css = ".comp-a{color:red}".to_string();
15112 document
15113 .components
15114 .get_mut("comp-b")
15115 .expect("comp-b component")
15116 .css = ".comp-b{color:blue}".to_string();
15117 document.populate_style_closures(&["index.html"]);
15118 }
15119 Protocol::new(document)
15120 }
15121
15122 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15123 disjoint_streaming_protocol_ext(hosts, true, false)
15124 }
15125
15126 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
15127 disjoint_streaming_protocol_ext(hosts, true, true)
15128 }
15129
15130 fn streaming_root_validation_protocol(
15131 mut host_fragments: Vec<WebUIFragment>,
15132 tail: Vec<WebUIFragment>,
15133 ) -> Protocol {
15134 let mut entry = vec![
15135 WebUIFragment::raw("<html><head>"),
15136 structural_fragment("head_start"),
15137 structural_fragment("head_end"),
15138 WebUIFragment::raw("</head><body>"),
15139 structural_fragment("body_start"),
15140 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15141 ];
15142 entry.append(&mut host_fragments);
15143 entry.extend(tail);
15144 let fragments = HashMap::from([
15145 (
15146 "index.html".to_string(),
15147 FragmentList {
15148 fragments: entry,
15149 contains_boundary: true,
15150 },
15151 ),
15152 (
15153 "comp-a".to_string(),
15154 FragmentList {
15155 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15156 contains_boundary: false,
15157 },
15158 ),
15159 (
15160 "comp-b".to_string(),
15161 FragmentList {
15162 fragments: vec![WebUIFragment::raw("<b>B</b>")],
15163 contains_boundary: false,
15164 },
15165 ),
15166 ]);
15167 Protocol::new(WebUIProtocol::new(fragments))
15168 }
15169
15170 fn completed_streaming_tail() -> Vec<WebUIFragment> {
15171 vec![
15172 WebUIFragment::raw("</comp-a>"),
15173 WebUIFragment::boundary_end(0),
15174 structural_fragment("body_end"),
15175 WebUIFragment::raw("</body></html>"),
15176 ]
15177 }
15178
15179 fn assert_streaming_root_error(
15180 protocol: &Protocol,
15181 expected_signal: &str,
15182 expected_reason: &str,
15183 ) {
15184 let mut writer = FlushTestWriter::default();
15185 let result = WebUIHandler::new().render_streaming(
15186 protocol,
15187 &test_json!({}),
15188 &RenderOptions::new("index.html", "/"),
15189 &mut writer,
15190 );
15191 match result {
15192 Err(HandlerError::StreamingBoundary(error)) => {
15193 assert_eq!(error.signal, expected_signal);
15194 assert!(
15195 error.reason.contains(expected_reason),
15196 "reason: {}",
15197 error.reason
15198 );
15199 }
15200 other => panic!("expected streaming-root rejection, got {other:?}"),
15201 }
15202 }
15203
15204 #[test]
15205 fn streaming_root_signal_injects_data_ws_inside_boundary() {
15206 let protocol = disjoint_streaming_protocol(&["comp-a"]);
15207 let handler = WebUIHandler::with_plugin(|| {
15208 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15209 });
15210 let mut writer = FlushTestWriter::default();
15211 handler
15212 .render_streaming(
15213 &protocol,
15214 &test_json!({ "a_count": 1 }),
15215 &RenderOptions::new("index.html", "/"),
15216 &mut writer,
15217 )
15218 .unwrap();
15219 assert!(
15222 writer.output.contains("<comp-a data-ws>"),
15223 "streamed host must carry data-ws: {}",
15224 writer.output
15225 );
15226 }
15227
15228 #[test]
15229 fn streaming_root_signal_preserves_ordinary_output_bytes() {
15230 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
15233 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
15234 let state = test_json!({ "a_count": 1, "b_count": 2 });
15235 let plugin = || {
15236 WebUIHandler::with_plugin(
15237 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
15238 )
15239 };
15240 let mut with_writer = TestWriter::new();
15241 let mut without_writer = TestWriter::new();
15242 plugin()
15243 .render(
15244 &with_signal,
15245 &state,
15246 &RenderOptions::new("index.html", "/"),
15247 &mut with_writer,
15248 )
15249 .unwrap();
15250 plugin()
15251 .render(
15252 &without_signal,
15253 &state,
15254 &RenderOptions::new("index.html", "/"),
15255 &mut without_writer,
15256 )
15257 .unwrap();
15258 let dom_prefix = |content: &str| -> String {
15263 content
15264 .split_once(r#"<script type="application/json" id="webui-data""#)
15265 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
15266 };
15267 assert_eq!(
15268 dom_prefix(&with_writer.get_content()),
15269 dom_prefix(&without_writer.get_content())
15270 );
15271 assert!(!with_writer.get_content().contains("data-ws"));
15272 assert!(with_writer.get_content().contains("<comp-a>"));
15273 }
15274
15275 #[test]
15276 fn streaming_component_without_root_signal_is_rejected() {
15277 let protocol = streaming_root_validation_protocol(
15278 vec![
15279 WebUIFragment::raw("<comp-a>"),
15280 WebUIFragment::component("comp-a"),
15281 ],
15282 completed_streaming_tail(),
15283 );
15284
15285 assert_streaming_root_error(
15286 &protocol,
15287 "comp-a",
15288 "has no compiler-owned streaming_root signal",
15289 );
15290 }
15291
15292 #[test]
15293 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15294 let duplicate = streaming_root_validation_protocol(
15295 vec![
15296 WebUIFragment::raw("<comp-a"),
15297 structural_fragment("streaming_root:comp-a"),
15298 structural_fragment("streaming_root:comp-a"),
15299 WebUIFragment::raw(">"),
15300 WebUIFragment::component("comp-a"),
15301 ],
15302 completed_streaming_tail(),
15303 );
15304 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15305
15306 let after_tag_close = streaming_root_validation_protocol(
15307 vec![
15308 WebUIFragment::raw("<comp-a>"),
15309 structural_fragment("streaming_root:comp-a"),
15310 WebUIFragment::component("comp-a"),
15311 ],
15312 completed_streaming_tail(),
15313 );
15314 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15315
15316 let detached_from_opening = streaming_root_validation_protocol(
15317 vec![
15318 WebUIFragment::raw("not an opening tag"),
15319 structural_fragment("streaming_root:comp-a"),
15320 WebUIFragment::raw(">"),
15321 WebUIFragment::component("comp-a"),
15322 ],
15323 completed_streaming_tail(),
15324 );
15325 assert_streaming_root_error(
15326 &detached_from_opening,
15327 "comp-a",
15328 "unclosed component opening-tag close",
15329 );
15330 }
15331
15332 #[test]
15333 fn mismatched_streaming_root_signal_is_rejected() {
15334 let protocol = streaming_root_validation_protocol(
15335 vec![
15336 WebUIFragment::raw("<comp-a"),
15337 structural_fragment("streaming_root:comp-b"),
15338 WebUIFragment::raw(">"),
15339 WebUIFragment::component("comp-a"),
15340 ],
15341 completed_streaming_tail(),
15342 );
15343
15344 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15345 }
15346
15347 #[test]
15348 fn pending_streaming_root_is_rejected_at_structural_ends() {
15349 let protocol = streaming_root_validation_protocol(
15350 vec![
15351 WebUIFragment::raw("<comp-a"),
15352 structural_fragment("streaming_root:comp-a"),
15353 WebUIFragment::raw(">"),
15354 ],
15355 vec![structural_fragment("boundary_end:0")],
15356 );
15357 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15358 }
15359
15360 #[test]
15361 fn streaming_root_outside_boundary_is_rejected() {
15362 let fragments = HashMap::from([
15363 (
15364 "index.html".to_string(),
15365 FragmentList {
15366 fragments: vec![
15367 WebUIFragment::raw("<html><head>"),
15368 structural_fragment("head_start"),
15369 structural_fragment("head_end"),
15370 WebUIFragment::raw("</head><body>"),
15371 structural_fragment("body_start"),
15372 WebUIFragment::raw("<comp-a"),
15373 structural_fragment("streaming_root:comp-a"),
15374 WebUIFragment::raw(">"),
15375 WebUIFragment::component("comp-a"),
15376 WebUIFragment::raw("</comp-a>"),
15377 structural_fragment("body_end"),
15378 WebUIFragment::raw("</body></html>"),
15379 ],
15380 contains_boundary: false,
15381 },
15382 ),
15383 (
15384 "comp-a".to_string(),
15385 FragmentList {
15386 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15387 contains_boundary: false,
15388 },
15389 ),
15390 ]);
15391 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15392 let mut writer = FlushTestWriter::default();
15393 let result = WebUIHandler::new().render_streaming(
15394 &protocol,
15395 &test_json!({}),
15396 &RenderOptions::new("index.html", "/"),
15397 &mut writer,
15398 );
15399 match result {
15400 Err(HandlerError::StreamingBoundary(err)) => {
15401 assert_eq!(err.signal, "comp-a");
15402 assert!(
15403 err.reason
15404 .contains("no boundary or generated component span"),
15405 "reason: {}",
15406 err.reason
15407 );
15408 }
15409 other => panic!("expected outside-boundary rejection, got {other:?}"),
15410 }
15411 }
15412
15413 fn parser_route_protocol(with_boundary: bool) -> Protocol {
15414 let mut parser = HtmlParser::with_options(DomStrategy::Light);
15415 parser
15416 .component_registry_mut()
15417 .register_component(ComponentRegistration::new(
15418 "route-page",
15419 "<p>route content</p>",
15420 None,
15421 true,
15422 ))
15423 .expect("register route component");
15424 let route = r#"<route path="/" component="route-page" exact />"#;
15425 let html = if with_boundary {
15426 format!(
15427 "<html><head></head><body>\
15428 <boundary name=\"route\">{route}</boundary>\
15429 </body></html>"
15430 )
15431 } else {
15432 format!("<html><head></head><body>{route}</body></html>")
15433 };
15434 parser
15435 .parse("index.html", &html)
15436 .expect("parse route streaming fixture");
15437 let mut document = WebUIProtocol::new(parser.into_fragment_records());
15438 document.components.insert(
15439 "route-page".to_string(),
15440 webui_protocol::ComponentData::default(),
15441 );
15442 Protocol::new(document)
15443 }
15444
15445 #[test]
15446 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15447 let protocol = parser_route_protocol(true);
15448 let mut writer = FlushTestWriter::default();
15449 WebUIHandler::new()
15450 .render_streaming(
15451 &protocol,
15452 &test_json!({}),
15453 &RenderOptions::new("index.html", "/"),
15454 &mut writer,
15455 )
15456 .unwrap();
15457
15458 assert!(
15459 writer
15460 .output
15461 .contains("<route-page data-ws><p>route content</p></route-page>"),
15462 "matched route host must be deferred before upgrade: {}",
15463 writer.output
15464 );
15465 }
15466
15467 #[test]
15468 fn streaming_parser_route_host_outside_boundary_is_rejected() {
15469 let protocol = parser_route_protocol(false);
15470 let mut writer = FlushTestWriter::default();
15471 let result = WebUIHandler::new().render_streaming(
15472 &protocol,
15473 &test_json!({}),
15474 &RenderOptions::new("index.html", "/"),
15475 &mut writer,
15476 );
15477
15478 match result {
15479 Err(HandlerError::StreamingBoundary(error)) => {
15480 assert_eq!(error.signal, "route-page");
15481 assert!(
15482 error
15483 .reason
15484 .contains("no boundary or generated component span"),
15485 "reason: {}",
15486 error.reason
15487 );
15488 }
15489 other => panic!("expected route boundary rejection, got {other:?}"),
15490 }
15491 assert!(
15492 !writer.output.contains("<route-page>"),
15493 "an unmarked custom-element opening must never be completed"
15494 );
15495 }
15496
15497 #[test]
15498 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15499 let entry = vec![
15500 WebUIFragment::raw("<html><head>"),
15501 structural_fragment("head_start"),
15502 structural_fragment("head_end"),
15503 WebUIFragment::raw("</head><body>"),
15504 structural_fragment("body_start"),
15505 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15506 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15507 path: "/account".into(),
15508 fragment_id: "account-shell".into(),
15509 exact: false,
15510 ..Default::default()
15511 }),
15512 WebUIFragment::boundary_end(0),
15513 structural_fragment("body_end"),
15514 WebUIFragment::raw("</body></html>"),
15515 ];
15516 let fragments = HashMap::from([
15517 (
15518 "index.html".to_string(),
15519 FragmentList {
15520 fragments: entry,
15521 contains_boundary: true,
15522 },
15523 ),
15524 (
15525 "account-shell".to_string(),
15526 FragmentList {
15527 fragments: vec![WebUIFragment::route_from(
15528 webui_protocol::WebUiFragmentRoute {
15529 path: "./details".into(),
15530 fragment_id: "details-page".into(),
15531 exact: true,
15532 pending_component: "details-loading".into(),
15533 error_component: "details-error".into(),
15534 ..Default::default()
15535 },
15536 )],
15537 contains_boundary: false,
15538 },
15539 ),
15540 (
15541 "details-page".to_string(),
15542 FragmentList {
15543 fragments: vec![WebUIFragment::raw("<p>details</p>")],
15544 contains_boundary: false,
15545 },
15546 ),
15547 (
15548 "details-loading".to_string(),
15549 FragmentList {
15550 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15551 contains_boundary: false,
15552 },
15553 ),
15554 (
15555 "details-error".to_string(),
15556 FragmentList {
15557 fragments: vec![WebUIFragment::raw("<p>error</p>")],
15558 contains_boundary: false,
15559 },
15560 ),
15561 ]);
15562 let mut document = WebUIProtocol::new(fragments);
15563 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15564 for name in [
15565 "account-shell",
15566 "details-page",
15567 "details-loading",
15568 "details-error",
15569 ] {
15570 document.components.insert(
15571 name.to_string(),
15572 webui_protocol::ComponentData {
15573 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15574 ..Default::default()
15575 },
15576 );
15577 }
15578 let protocol = Protocol::new(document);
15579 let handler = WebUIHandler::with_plugin(|| {
15580 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15581 });
15582 let mut writer = FlushTestWriter::default();
15583
15584 handler
15585 .render_streaming(
15586 &protocol,
15587 &test_json!({}),
15588 &RenderOptions::new("index.html", "/account/details"),
15589 &mut writer,
15590 )
15591 .unwrap();
15592
15593 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15594 assert!(
15595 first.contains(r#""details-loading":"#),
15596 "relative-route pending metadata missing: {first}"
15597 );
15598 assert!(
15599 first.contains(r#""details-error":"#),
15600 "relative-route error metadata missing: {first}"
15601 );
15602 }
15603
15604 #[test]
15605 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15606 let entry = vec![
15607 WebUIFragment::raw("<html><head>"),
15608 structural_fragment("head_start"),
15609 structural_fragment("head_end"),
15610 WebUIFragment::raw("</head><body>"),
15611 structural_fragment("body_start"),
15612 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15613 WebUIFragment::raw("<static-shell"),
15614 structural_fragment("streaming_root:static-shell"),
15615 WebUIFragment::raw(">"),
15616 WebUIFragment::component("static-shell"),
15617 WebUIFragment::raw("</static-shell>"),
15618 WebUIFragment::raw("<route-shell"),
15619 structural_fragment("streaming_root:route-shell"),
15620 WebUIFragment::raw(">"),
15621 WebUIFragment::component("route-shell"),
15622 WebUIFragment::raw("</route-shell>"),
15623 WebUIFragment::boundary_end(0),
15624 structural_fragment("body_end"),
15625 WebUIFragment::raw("</body></html>"),
15626 ];
15627 let fragments = HashMap::from([
15628 (
15629 "index.html".to_string(),
15630 FragmentList {
15631 fragments: entry,
15632 contains_boundary: true,
15633 },
15634 ),
15635 (
15636 "static-shell".to_string(),
15637 FragmentList {
15638 fragments: vec![WebUIFragment::if_cond(
15639 ConditionExpr::identifier("show_hidden"),
15640 "static-hidden-if",
15641 )],
15642 contains_boundary: false,
15643 },
15644 ),
15645 (
15646 "static-hidden-if".to_string(),
15647 FragmentList {
15648 fragments: vec![WebUIFragment::component("static-hidden")],
15649 contains_boundary: false,
15650 },
15651 ),
15652 (
15653 "static-hidden".to_string(),
15654 FragmentList {
15655 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15656 contains_boundary: false,
15657 },
15658 ),
15659 (
15660 "route-shell".to_string(),
15661 FragmentList {
15662 fragments: vec![WebUIFragment::route_from(
15663 webui_protocol::WebUiFragmentRoute {
15664 path: "/account".into(),
15665 fragment_id: "route-page".into(),
15666 exact: true,
15667 ..Default::default()
15668 },
15669 )],
15670 contains_boundary: false,
15671 },
15672 ),
15673 (
15674 "route-page".to_string(),
15675 FragmentList {
15676 fragments: vec![WebUIFragment::raw("<p>route</p>")],
15677 contains_boundary: false,
15678 },
15679 ),
15680 ]);
15681 let mut document = WebUIProtocol::new(fragments);
15682 document.set_css_strategy(webui_protocol::CssStrategy::Link);
15683 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15684 for (name, keys) in [
15685 ("static-shell", vec!["show_hidden", "static_count"]),
15686 ("static-hidden", vec!["hidden_count"]),
15687 ("route-shell", vec!["route_shell_count"]),
15688 ("route-page", vec!["route_count"]),
15689 ] {
15690 document.components.insert(
15691 name.to_string(),
15692 webui_protocol::ComponentData {
15693 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15694 hydration_mode: StateProjectionMode::Keys as i32,
15695 hydration_keys: keys.into_iter().map(str::to_string).collect(),
15696 css_href: format!("/{name}.css"),
15697 ..Default::default()
15698 },
15699 );
15700 }
15701 document.populate_style_closures(&["index.html"]);
15702 let protocol = Protocol::new(document);
15703 assert_eq!(
15704 protocol
15705 .component_reachability()
15706 .is_route_dependent(protocol.component_index()["route-shell"]),
15707 Some(true),
15708 "fixture must exercise request-aware checkpoint reachability"
15709 );
15710 let handler = WebUIHandler::with_plugin(|| {
15711 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15712 });
15713 let mut writer = FlushTestWriter::default();
15714
15715 handler
15716 .render_streaming(
15717 &protocol,
15718 &test_json!({
15719 "show_hidden": false,
15720 "static_count": 1,
15721 "hidden_count": 2,
15722 "route_shell_count": 3,
15723 "route_count": 4,
15724 "unrelated": "private",
15725 }),
15726 &RenderOptions::new("index.html", "/account"),
15727 &mut writer,
15728 )
15729 .unwrap();
15730
15731 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15732 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15733 assert!(
15734 checkpoint.contains(&format!(r#""{template}":"#)),
15735 "{template} metadata missing: {checkpoint}"
15736 );
15737 }
15738 for state in [
15739 r#""show_hidden":false"#,
15740 r#""static_count":1"#,
15741 r#""hidden_count":2"#,
15742 r#""route_shell_count":3"#,
15743 r#""route_count":4"#,
15744 ] {
15745 assert!(
15746 checkpoint.contains(state),
15747 "{state} state missing: {checkpoint}"
15748 );
15749 }
15750 assert!(
15751 !checkpoint.contains("unrelated"),
15752 "checkpoint leaked unrelated state: {checkpoint}"
15753 );
15754 assert!(
15755 checkpoint.contains(
15756 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15757 ),
15758 "checkpoint metadata lost source order: {checkpoint}"
15759 );
15760 assert!(
15761 checkpoint.contains(r#""componentStyles":"#)
15762 && checkpoint.contains(r#""strategy":"link""#)
15763 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15764 "checkpoint is missing tree-local style metadata: {checkpoint}"
15765 );
15766 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15767 assert_eq!(
15768 writer
15769 .output
15770 .matches(&format!(r#"data-webui-resource="{resource}""#))
15771 .count(),
15772 1,
15773 "Document delivery state must persist across streamed checkpoints"
15774 );
15775 }
15776 }
15777
15778 #[test]
15779 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15780 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15785 let handler = WebUIHandler::with_plugin(|| {
15786 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15787 });
15788 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15789 let mut writer = FlushTestWriter::default();
15790 handler
15791 .render_streaming(
15792 &protocol,
15793 &state,
15794 &RenderOptions::new("index.html", "/"),
15795 &mut writer,
15796 )
15797 .unwrap();
15798
15799 assert_eq!(
15800 writer.flushes.len(),
15801 5,
15802 "bootstrap, three boundary commits, and one terminal"
15803 );
15804 let segment = |index: usize| -> &str {
15805 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15806 };
15807 let b0 = segment(0);
15808 let b1 = segment(1);
15809 let b2 = segment(2);
15810
15811 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15813 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15814 assert!(
15815 b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15816 "b0: {b0}"
15817 );
15818 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15819 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15820 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15821
15822 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15824 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15825 assert!(
15826 b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15827 "b1: {b1}"
15828 );
15829 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15830 assert!(
15831 !b1.contains(r#""templates":{"comp-a"#),
15832 "b1 re-sent comp-a: {b1}"
15833 );
15834 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15835
15836 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15838 assert!(
15839 b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15840 "b2: {b2}"
15841 );
15842 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15843 assert!(
15844 !b2.contains(r#""templates""#),
15845 "b2 re-sent a template: {b2}"
15846 );
15847
15848 assert!(!writer.output.contains("serverOnly"));
15850 }
15851
15852 #[test]
15853 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15854 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15855 let handler = WebUIHandler::with_plugin(|| {
15856 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15857 });
15858 let mut writer = FlushTestWriter::default();
15859 handler
15860 .render_streaming(
15861 &protocol,
15862 &test_json!({ "a_count": 1, "b_count": 2 }),
15863 &RenderOptions::new("index.html", "/"),
15864 &mut writer,
15865 )
15866 .unwrap();
15867
15868 let segment = |index: usize| -> &str {
15869 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15870 };
15871 let checkpoint_styles = |checkpoint: &str| -> Value {
15872 let script = checkpoint
15873 .rfind(r#"<script type="application/json" data-webui-boundary"#)
15874 .unwrap();
15875 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15876 let payload_end =
15877 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15878 let record: Value =
15879 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15880 record[3]["componentStyles"].clone()
15881 };
15882
15883 let first = checkpoint_styles(segment(0));
15884 assert!(first["resources"]["comp-a"].is_object());
15885 assert!(first["resources"]["comp-b"].is_object());
15886 assert!(first["closures"]["index.html"].is_array());
15887 assert!(first["closures"]["comp-a"].is_array());
15888
15889 let second = checkpoint_styles(segment(1));
15890 assert_eq!(second["resources"], test_json!({}));
15891 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15892
15893 let repeated = checkpoint_styles(segment(2));
15894 assert_eq!(repeated["resources"], test_json!({}));
15895 assert_eq!(repeated["closures"], test_json!({}));
15896 for resource in ["comp-a", "comp-b"] {
15897 assert_eq!(
15898 [&first, &second, &repeated]
15899 .iter()
15900 .filter(|styles| {
15901 styles["resources"]
15902 .as_object()
15903 .is_some_and(|resources| resources.contains_key(resource))
15904 })
15905 .count(),
15906 1,
15907 "{resource} style metadata was serialized more than once"
15908 );
15909 }
15910 }
15911
15912 #[test]
15913 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15914 let entry = vec![
15915 WebUIFragment::raw("<html><head>"),
15916 structural_fragment("head_start"),
15917 structural_fragment("head_end"),
15918 WebUIFragment::raw("</head><body>"),
15919 structural_fragment("body_start"),
15920 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15921 WebUIFragment::raw("<comp-a"),
15922 structural_fragment("streaming_root:comp-a"),
15923 WebUIFragment::raw(">"),
15924 WebUIFragment::component("comp-a"),
15925 WebUIFragment::raw("</comp-a>"),
15926 WebUIFragment::boundary_end(0),
15927 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15928 WebUIFragment::raw("<comp-hidden"),
15929 structural_fragment("streaming_root:comp-hidden"),
15930 WebUIFragment::raw(">"),
15931 WebUIFragment::component("comp-hidden"),
15932 WebUIFragment::raw("</comp-hidden>"),
15933 WebUIFragment::boundary_end(1),
15934 structural_fragment("body_end"),
15935 WebUIFragment::raw("</body></html>"),
15936 ];
15937 let fragments = HashMap::from([
15938 (
15939 "index.html".to_string(),
15940 FragmentList {
15941 fragments: entry,
15942 contains_boundary: true,
15943 },
15944 ),
15945 (
15946 "comp-a".to_string(),
15947 FragmentList {
15948 fragments: vec![WebUIFragment::if_cond(
15949 ConditionExpr::identifier("show_hidden"),
15950 "hidden-if",
15951 )],
15952 contains_boundary: false,
15953 },
15954 ),
15955 (
15956 "hidden-if".to_string(),
15957 FragmentList {
15958 fragments: vec![WebUIFragment::component("comp-hidden")],
15959 contains_boundary: false,
15960 },
15961 ),
15962 (
15963 "comp-hidden".to_string(),
15964 FragmentList {
15965 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15966 contains_boundary: false,
15967 },
15968 ),
15969 ]);
15970 let mut document = WebUIProtocol::new(fragments);
15971 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15972 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15973 document.components.insert(
15974 "comp-a".to_string(),
15975 webui_protocol::ComponentData {
15976 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15977 hydration_mode: StateProjectionMode::Keys as i32,
15978 hydration_keys: vec!["show_hidden".to_string()],
15979 ..Default::default()
15980 },
15981 );
15982 document.components.insert(
15983 "comp-hidden".to_string(),
15984 webui_protocol::ComponentData {
15985 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15986 css: ".hidden{display:block}".to_string(),
15987 hydration_mode: StateProjectionMode::Keys as i32,
15988 hydration_keys: vec!["hidden_count".to_string()],
15989 ..Default::default()
15990 },
15991 );
15992 document.populate_style_closures(&["index.html"]);
15993 let protocol = Protocol::new(document);
15994 let handler = WebUIHandler::with_plugin(|| {
15995 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15996 });
15997 let mut writer = FlushTestWriter::default();
15998
15999 handler
16000 .render_streaming(
16001 &protocol,
16002 &test_json!({
16003 "show_hidden": false,
16004 "hidden_count": 7,
16005 "unrelated": "private",
16006 }),
16007 &RenderOptions::new("index.html", "/"),
16008 &mut writer,
16009 )
16010 .unwrap();
16011
16012 let boundary = |index: usize| -> &str {
16013 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
16014 };
16015 let first = boundary(0);
16016 let second = boundary(1);
16017
16018 assert!(first.contains(r#""comp-a":"#), "first: {first}");
16019 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
16020 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
16021 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
16022 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
16023 assert!(
16024 first.contains(r#"<script type="importmap""#),
16025 "first: {first}"
16026 );
16027 assert!(
16028 !first.contains("unrelated"),
16029 "first leaked unrelated state: {first}"
16030 );
16031
16032 assert!(
16033 !second.contains(r#""templates""#),
16034 "second re-sent metadata: {second}"
16035 );
16036 assert!(
16037 !second.contains(r#"<script type="importmap""#),
16038 "second re-sent CSS: {second}"
16039 );
16040 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
16041 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
16042 assert_eq!(
16043 writer
16044 .output
16045 .matches(r#"<script type="importmap">"#)
16046 .count(),
16047 1,
16048 "import-map output: {}",
16049 writer.output
16050 );
16051 }
16052
16053 #[test]
16054 fn streaming_flush_count_is_boundaries_plus_one() {
16055 for &count in &[1usize, 3, 10, 100] {
16056 let hosts: Vec<&str> = (0..count)
16057 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
16058 .collect();
16059 let protocol = disjoint_streaming_protocol(&hosts);
16060 let handler = WebUIHandler::with_plugin(|| {
16061 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
16062 });
16063 let mut writer = FlushTestWriter::default();
16064 handler
16065 .render_streaming(
16066 &protocol,
16067 &test_json!({ "a_count": 1, "b_count": 2 }),
16068 &RenderOptions::new("index.html", "/"),
16069 &mut writer,
16070 )
16071 .unwrap();
16072 assert_eq!(
16073 writer.flushes.len(),
16074 count + 2,
16075 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
16076 );
16077 }
16078 }
16079
16080 #[test]
16081 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
16082 let mut fragments = HashMap::new();
16083 fragments.insert("comp-a".to_string(), FragmentList::default());
16084 fragments.insert("comp-b".to_string(), FragmentList::default());
16085 let mut protocol = WebUIProtocol::new(fragments);
16086 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
16087 protocol.components.insert(
16088 "comp-a".to_string(),
16089 webui_protocol::ComponentData {
16090 hydration_mode: StateProjectionMode::Keys as i32,
16091 hydration_keys: vec!["a".to_string(), "shared".to_string()],
16092 ..Default::default()
16093 },
16094 );
16095 protocol.components.insert(
16096 "comp-b".to_string(),
16097 webui_protocol::ComponentData {
16098 hydration_mode: StateProjectionMode::Keys as i32,
16099 hydration_keys: vec!["b".to_string(), "shared".to_string()],
16100 ..Default::default()
16101 },
16102 );
16103
16104 let protocol = Protocol::new(protocol);
16105 let component_index = protocol.component_index();
16106 let reachability = protocol.component_reachability();
16107 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
16108 assert!(!collect_hydration_key_ids_into(
16109 protocol.protocol(),
16110 reachability,
16111 [component_index["comp-a"]],
16112 &mut scratch,
16113 ));
16114 let keys = scratch
16115 .iter()
16116 .filter_map(|id| reachability.hydration_key(*id))
16117 .collect::<Vec<_>>();
16118 assert_eq!(keys, ["a", "shared"]);
16119 let pointer = scratch.as_ptr();
16120 let capacity = scratch.capacity();
16121
16122 assert!(!collect_hydration_key_ids_into(
16123 protocol.protocol(),
16124 reachability,
16125 [component_index["comp-b"]],
16126 &mut scratch,
16127 ));
16128 let keys = scratch
16129 .iter()
16130 .filter_map(|id| reachability.hydration_key(*id))
16131 .collect::<Vec<_>>();
16132 assert_eq!(keys, ["b", "shared"]);
16133 assert_eq!(scratch.as_ptr(), pointer);
16134 assert_eq!(scratch.capacity(), capacity);
16135 }
16136}