1pub mod css_module;
10pub(crate) mod html_encode;
11pub mod plugin;
12mod response_writer;
13pub mod route_handler;
14pub mod route_matcher;
15pub(crate) mod route_renderer;
16pub(crate) mod streaming;
17
18#[doc(hidden)]
19pub use response_writer::{
20 append_attribute_to_bytes, append_attribute_to_string, append_boolean_attribute_to_bytes,
21 append_boolean_attribute_to_string,
22};
23pub use route_handler::Protocol;
24
25pub use html_encode::encode_safe;
35
36use plugin::BootstrapExtensionContext;
37use plugin::HandlerPlugin;
38use plugin::WebUiTemplatePayload;
39use route_handler::ComponentReachabilityIndex;
40use route_matcher::CompiledRouteIndex;
41use serde::ser::SerializeMap;
42use serde::Serialize;
43use serde_json::Value;
44use std::borrow::Cow;
45use std::cell::{Cell, OnceCell};
46use std::collections::{HashMap, HashSet};
47use std::sync::Arc;
48use streaming::{
49 consume_streaming_component_root, ensure_no_pending_streaming_root,
50 prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
51 validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
52 StreamingRenderState,
53};
54pub use streaming::{
55 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
56 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
57 MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
58};
59use thiserror::Error;
60use webui_expressions::{evaluate_with_resolver, ExpressionError};
61use webui_protocol::{
62 web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
63 InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
64};
65use webui_state::find_value_by_dotted_path_ref;
66
67#[derive(Debug, Error)]
69pub enum HandlerError {
70 #[error("Rendering error: {0}")]
71 Rendering(String),
72
73 #[error("invalid state JSON: {0}")]
79 InvalidState(String),
80
81 #[error("Rendering invariant error: {0}")]
82 Invariant(String),
83
84 #[error("Missing fragment: {0}")]
85 MissingFragment(String),
86
87 #[error("Missing data field: {0}")]
88 MissingData(String),
89
90 #[error("Type error: {0}")]
91 TypeError(String),
92
93 #[error("Protocol error: {0}")]
94 Protocol(#[from] webui_protocol::ProtocolError),
95
96 #[error("Evaluation error: {0}")]
97 Evaluation(String),
98
99 #[error("I/O error: {0}")]
100 Io(#[from] std::io::Error),
101
102 #[error("Writer error: {0}")]
103 Writer(String),
104
105 #[error("Plugin data error: {0}")]
106 PluginData(String),
107
108 #[error("client disconnected")]
115 ClientDisconnected,
116
117 #[error("streaming flush timed out")]
124 StreamTimeout,
125
126 #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
133 StreamingBoundary(Box<StreamingBoundaryError>),
134
135 #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
137 MissingStreamingBodyEnd,
138
139 #[error(
141 "streaming protocol is missing the required `head_start` signal before `{before}`; \
142 rebuild the protocol with streaming-boundary parser support"
143 )]
144 MissingStreamingHeadStart {
145 before: &'static str,
147 },
148
149 #[error("streaming protocol emitted duplicate `head_start` signals")]
151 DuplicateStreamingHeadStart,
152}
153
154#[derive(Debug)]
161pub struct StreamingBoundaryError {
162 pub signal: String,
164 pub reason: String,
166}
167
168pub type Result<T> = std::result::Result<T, HandlerError>;
169
170#[cold]
171#[inline(never)]
172fn invalid_fragment_range_error(
173 range: &std::ops::Range<usize>,
174 fragment_count: usize,
175) -> HandlerError {
176 HandlerError::Invariant(format!(
177 "fragment range {}..{} exceeds fragment count {fragment_count}",
178 range.start, range.end
179 ))
180}
181
182#[cold]
183#[inline(never)]
184fn route_style_plan_missing_error() -> HandlerError {
185 HandlerError::Invariant(
186 "matched route style targets were computed without a route chain".to_string(),
187 )
188}
189
190#[cold]
191#[inline(never)]
192fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
193 HandlerError::Invariant(format!(
194 "matched route style target count {targets} does not match route count {routes}"
195 ))
196}
197
198pub trait ResponseWriter {
200 fn write(&mut self, content: &str) -> Result<()>;
202
203 #[doc(hidden)]
205 fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
206 self.write(" ")?;
207 self.write(name)?;
208 self.write("=\"")?;
209 self.write(value)?;
210 self.write("\"")
211 }
212
213 #[doc(hidden)]
215 fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
216 self.write(" ")?;
217 self.write(name)
218 }
219
220 fn end(&mut self) -> Result<()>;
222
223 #[doc(hidden)]
231 fn stream_flush(&mut self) -> Result<()> {
232 Ok(())
233 }
234
235 #[doc(hidden)]
236 fn stream_begin_component(&mut self) -> Result<()> {
237 Err(HandlerError::Invariant(
238 "component opening buffering requires a streaming sink".to_string(),
239 ))
240 }
241
242 #[doc(hidden)]
243 fn stream_mark_component_root(&mut self) -> Result<()> {
244 Err(HandlerError::Invariant(
245 "component root buffering requires a streaming sink".to_string(),
246 ))
247 }
248
249 #[doc(hidden)]
250 fn stream_commit_component(
251 &mut self,
252 span_id: Option<u32>,
253 enclosing_span_id: Option<u32>,
254 deferred: bool,
255 ) -> Result<()> {
256 let _ = (span_id, enclosing_span_id, deferred);
257 Err(HandlerError::Invariant(
258 "component opening commit requires a streaming sink".to_string(),
259 ))
260 }
261}
262
263struct StateWithoutSelectedKeys<'a> {
269 value: &'a Value,
270 keys: KeyView<'a>,
271}
272
273impl Serialize for StateWithoutSelectedKeys<'_> {
274 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
275 where
276 S: serde::Serializer,
277 {
278 let Value::Object(map) = self.value else {
279 return serializer.serialize_map(Some(0))?.end();
280 };
281 let mut out = serializer.serialize_map(None)?;
282 for (key, value) in map {
283 if key != STATE_INJECT_KEY && !self.keys.contains(key) {
284 out.serialize_entry(key, value)?;
285 }
286 }
287 out.end()
288 }
289}
290
291pub trait FlushWriter: ResponseWriter {
296 fn flush(&mut self) -> Result<()>;
298}
299
300pub struct RenderOptions<'a> {
305 pub entry_id: &'a str,
307 pub request_path: &'a str,
309 pub nonce: Option<&'a str>,
313 pub head_inject: Option<&'a str>,
321 pub body_inject: Option<&'a str>,
326}
327
328pub const STATE_INJECT_KEY: &str = "$webui";
344
345#[derive(Clone, Copy, Default)]
352pub(crate) struct StateInject<'state> {
353 pub(crate) head_end: Option<&'state str>,
354 pub(crate) body_start: Option<&'state str>,
355 pub(crate) body_end: Option<&'state str>,
356}
357
358impl<'state> StateInject<'state> {
359 pub(crate) fn resolve(state: &'state Value) -> Self {
366 let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
367 return Self::default();
368 };
369 let field = |name: &str| {
370 map.get(name)
371 .and_then(Value::as_str)
372 .filter(|html| !html.is_empty())
373 };
374 Self {
375 head_end: field("headEnd"),
376 body_start: field("bodyStart"),
377 body_end: field("bodyEnd"),
378 }
379 }
380}
381
382impl<'a> RenderOptions<'a> {
383 #[must_use]
385 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
386 Self {
387 entry_id,
388 request_path,
389 nonce: None,
390 head_inject: None,
391 body_inject: None,
392 }
393 }
394
395 #[must_use]
399 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
400 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
401 self
402 }
403
404 #[must_use]
419 pub fn with_head_inject(mut self, html: &'a str) -> Self {
420 self.head_inject = if html.is_empty() { None } else { Some(html) };
421 self
422 }
423
424 #[must_use]
434 pub fn with_body_inject(mut self, html: &'a str) -> Self {
435 self.body_inject = if html.is_empty() { None } else { Some(html) };
436 self
437 }
438}
439
440pub struct WebUIHandler {
445 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
446}
447
448#[derive(Clone, Copy, Eq, PartialEq)]
449enum StyleClosureInstall {
450 Static,
451 Routed,
452}
453
454pub(crate) struct ShadowStyleRoot {
455 component_index: u32,
456 static_closure_emitted: bool,
457 routed_resources: Vec<u32>,
458}
459
460struct LoopBinding<'protocol, 'state> {
461 name: &'protocol str,
462 value: &'state Value,
463}
464
465#[derive(Clone, Copy)]
466struct VisibleLoopScope {
467 start: usize,
468 end: usize,
469}
470
471impl VisibleLoopScope {
472 const EMPTY: Self = Self { start: 0, end: 0 };
473}
474
475#[derive(Clone, Copy)]
476struct LocalValueSources<'ctx, 'protocol, 'state> {
477 owned: &'ctx HashMap<String, Value>,
478 borrowed: &'ctx BorrowedScope<'protocol, 'state>,
479}
480
481#[derive(Default)]
482struct BorrowedScope<'protocol, 'state> {
483 inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
484 inline_len: usize,
485 overflow: Vec<(&'protocol str, &'state Value)>,
486}
487
488impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
489 fn get(&self, name: &str) -> Option<&'state Value> {
490 if self.inline_len == 0 {
491 return None;
492 }
493 if let Some((entry_name, value)) = self.inline[0].as_ref() {
494 if *entry_name == name {
495 return Some(*value);
496 }
497 }
498 for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
499 if *entry_name == name {
500 return Some(*value);
501 }
502 }
503 self.overflow
504 .iter()
505 .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
506 }
507
508 fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
509 for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
510 if *entry_name == name {
511 return Some(std::mem::replace(current, value));
512 }
513 }
514 for (entry_name, current) in &mut self.overflow {
515 if *entry_name == name {
516 return Some(std::mem::replace(current, value));
517 }
518 }
519 if self.inline_len < INLINE_SCOPE_SLOTS {
520 self.inline[self.inline_len] = Some((name, value));
521 self.inline_len += 1;
522 } else {
523 self.overflow.push((name, value));
524 }
525 None
526 }
527
528 fn remove(&mut self, name: &str) -> Option<&'state Value> {
529 if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
530 entry
531 .as_ref()
532 .is_some_and(|(entry_name, _)| *entry_name == name)
533 }) {
534 let removed = self.inline[index].take().map(|(_, value)| value);
535 if let Some(entry) = self.overflow.pop() {
536 self.inline[index] = Some(entry);
537 } else {
538 self.inline_len -= 1;
539 self.inline[index] = self.inline[self.inline_len].take();
540 }
541 return removed;
542 }
543 let index = self
544 .overflow
545 .iter()
546 .position(|(entry_name, _)| *entry_name == name)?;
547 Some(self.overflow.swap_remove(index).1)
548 }
549
550 fn clear(&mut self) {
551 for entry in &mut self.inline[..self.inline_len] {
552 *entry = None;
553 }
554 self.inline_len = 0;
555 self.overflow.clear();
556 }
557
558 fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
559 for (name, value) in self.inline[..self.inline_len].iter().flatten() {
560 target.insert((*name).to_owned(), (*value).clone());
561 }
562 for (name, value) in &self.overflow {
563 target.insert((*name).to_owned(), (*value).clone());
564 }
565 }
566}
567
568#[derive(Clone, Copy)]
574pub(crate) struct RenderFragmentList<'protocol> {
575 fragments: &'protocol [WebUIFragment],
576 metadata: &'protocol [RenderFragmentMetadata],
577 attr_names: &'protocol str,
578 contains_boundary: bool,
579 has_routes: bool,
582}
583
584impl<'protocol> RenderFragmentList<'protocol> {
585 fn target(self, index: usize) -> Option<usize> {
587 let target = self.metadata.get(index)?.target;
588 (target != NO_RENDER_SLOT).then_some(target as usize)
589 }
590
591 fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
593 let prepared = self.metadata.get(index)?;
594 if prepared.attr_start == NO_ATTR_NAME {
595 return None;
596 }
597 let start = prepared.attr_start as usize;
598 self.attr_names
599 .get(start..start + prepared.attr_len as usize)
600 }
601}
602
603const NO_RENDER_SLOT: u32 = u32::MAX;
605const NO_ATTR_NAME: u32 = u32::MAX;
607
608#[derive(Clone, Copy)]
613struct RenderFragmentMetadata {
614 target: u32,
618 attr_start: u32,
620 attr_len: u32,
621}
622
623pub(crate) struct RenderFragmentIndex {
630 ids: Box<[Arc<str>]>,
631 metadata: Box<[RenderFragmentMetadata]>,
632 ranges: Box<[u32]>,
634 attr_names: Box<str>,
636 route_presence: Box<[u64]>,
638}
639
640pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
645 cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
646 spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
650 index: &'protocol RenderFragmentIndex,
651 protocol: &'protocol WebUIProtocol,
652}
653
654impl RenderFragmentIndex {
655 pub(crate) fn new(
662 protocol: &WebUIProtocol,
663 ids: &[Arc<str>],
664 slots: &HashMap<Arc<str>, u32>,
665 ) -> Self {
666 let total_fragments = protocol
667 .fragments
668 .values()
669 .map(|list| list.fragments.len())
670 .sum();
671 let mut metadata = Vec::with_capacity(total_fragments);
672 let mut ranges = Vec::with_capacity(ids.len() + 1);
673 let mut attr_names = String::with_capacity(total_fragments * 8);
676 let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
677
678 for (slot, id) in ids.iter().enumerate() {
679 #[allow(clippy::cast_possible_truncation)]
681 ranges.push(metadata.len() as u32);
682 let Some(list) = protocol.fragments.get(id.as_ref()) else {
683 continue;
684 };
685 let mut has_routes = false;
686 for fragment in &list.fragments {
687 let inner = fragment.fragment.as_ref();
688 if matches!(inner, Some(Fragment::Route(_))) {
689 has_routes = true;
690 }
691 let target = fragment_target_id(fragment)
692 .and_then(|target| slots.get(target).copied())
693 .unwrap_or(NO_RENDER_SLOT);
694 let (attr_start, attr_len) = match inner {
695 Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
699 #[allow(clippy::cast_possible_truncation)]
700 let start = attr_names.len() as u32;
701 let before = attr_names.len();
702 push_component_attr_name(&mut attr_names, component_attr_source(attribute));
703 #[allow(clippy::cast_possible_truncation)]
704 let len = (attr_names.len() - before) as u32;
705 (start, len)
706 }
707 _ => (NO_ATTR_NAME, 0),
708 };
709 metadata.push(RenderFragmentMetadata {
710 target,
711 attr_start,
712 attr_len,
713 });
714 }
715 if has_routes {
716 route_presence[slot / 64] |= 1u64 << (slot % 64);
717 }
718 }
719 #[allow(clippy::cast_possible_truncation)]
720 ranges.push(metadata.len() as u32);
721
722 Self {
723 ids: ids.to_vec().into_boxed_slice(),
724 metadata: metadata.into_boxed_slice(),
725 ranges: ranges.into_boxed_slice(),
726 attr_names: attr_names.into_boxed_str(),
727 route_presence: route_presence.into_boxed_slice(),
728 }
729 }
730
731 pub(crate) fn index(&self, id: &str) -> Option<usize> {
733 self.ids
734 .binary_search_by(|candidate| candidate.as_ref().cmp(id))
735 .ok()
736 }
737
738 pub(crate) fn resolve<'protocol>(
739 &'protocol self,
740 protocol: &'protocol WebUIProtocol,
741 ) -> ResolvedRenderFragmentIndex<'protocol> {
742 ResolvedRenderFragmentIndex {
743 cache: std::array::from_fn(|_| Cell::new(None)),
744 spilled: OnceCell::new(),
745 index: self,
746 protocol,
747 }
748 }
749}
750
751impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
752 pub(crate) fn index(&self, id: &str) -> Option<usize> {
753 self.index.index(id)
754 }
755
756 fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
758 if index < INLINE_RENDER_FRAGMENT_LISTS {
759 return self.cache.get(index);
760 }
761 let spilled = self.spilled.get_or_init(|| {
762 let overflow = self
763 .index
764 .ids
765 .len()
766 .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
767 (0..overflow).map(|_| Cell::new(None)).collect()
768 });
769 spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
770 }
771
772 pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
773 let id = self.index.ids.get(index)?;
774 let fragment_list = match self.slot(index) {
775 Some(slot) => match slot.get() {
776 Some(cached) => cached,
777 None => {
778 let resolved = self.protocol.fragments.get(id.as_ref())?;
779 slot.set(Some(resolved));
780 resolved
781 }
782 },
783 None => self.protocol.fragments.get(id.as_ref())?,
784 };
785 let start = *self.index.ranges.get(index)? as usize;
786 let end = *self.index.ranges.get(index + 1)? as usize;
787 let metadata = self.index.metadata.get(start..end)?;
788 let has_routes = self
789 .index
790 .route_presence
791 .get(index / 64)
792 .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
793 Some(RenderFragmentList {
794 fragments: &fragment_list.fragments,
795 metadata,
796 attr_names: &self.index.attr_names,
797 contains_boundary: fragment_list.contains_boundary,
798 has_routes,
799 })
800 }
801
802 #[cfg(test)]
803 pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
804 self.index(id).and_then(|index| self.list(index))
805 }
806}
807
808fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
815 let takes_complex_branch = attribute.condition_tree.is_none()
816 && attribute.template.is_empty()
817 && !attribute.value.is_empty()
818 && !attribute.raw_value
819 && attribute.complex;
820 if takes_complex_branch {
821 attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
822 } else {
823 &attribute.name
824 }
825}
826
827fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
829 match fragment.fragment.as_ref()? {
830 Fragment::Component(component) => Some(&component.fragment_id),
831 Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
832 Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
833 Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
834 Some(&attribute.template)
835 }
836 _ => None,
837 }
838}
839
840pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
842 pub(crate) protocol: &'protocol WebUIProtocol,
843 pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
847 pub(crate) component_asset_style_manifest: &'protocol str,
850 pub(crate) component_asset_style_links: &'protocol str,
852 pub(crate) state: &'state Value,
853 pub(crate) writer: &'output mut dyn ResponseWriter,
854 pub(crate) local_vars: HashMap<String, Value>,
855 local_borrowed_vars: BorrowedScope<'protocol, 'state>,
857 loop_vars: Vec<LoopBinding<'protocol, 'state>>,
859 visible_loop_scope: VisibleLoopScope,
862 pub(crate) component_attrs: HashMap<String, Value>,
864 component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
866 collecting_component_attrs: bool,
870 pub(crate) request_path: &'protocol str,
873 pub(crate) route_base: Cow<'protocol, str>,
878 pub(crate) rendered_components: HashSet<String>,
882 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
884 pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
887 pub(crate) entry_id: &'protocol str,
890 pub(crate) nonce: Option<&'protocol str>,
893 pub(crate) component_index: &'protocol HashMap<String, u32>,
896 pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
898 pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
903 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
909 pub(crate) css_strategy: webui_protocol::CssStrategy,
911 pub(crate) head_inject: Option<&'protocol str>,
917 pub(crate) body_inject: Option<&'protocol str>,
921 pub(crate) state_inject: StateInject<'state>,
926 pub(crate) head_end_emitted: bool,
933 pub(crate) body_start_emitted: bool,
938 pub(crate) component_asset_styles_emitted: bool,
941 pub(crate) body_end_emitted: bool,
946 pub(crate) route_index: &'protocol CompiledRouteIndex,
948 pub(crate) route_chain_index: usize,
952 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
955 pub(crate) route_document_style_targets: Vec<bool>,
959 pub(crate) reachable_components: Option<Vec<String>>,
965 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
967 pub(crate) json_scratch: Vec<u8>,
974 pub(crate) scope_pool: Vec<HashMap<String, Value>>,
980 pub(crate) document_style_resources: HashSet<String>,
983 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
986 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
988}
989
990pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
993
994pub(crate) fn structural_signal_value(
995 signal: &webui_protocol::WebUIFragmentSignal,
996) -> Option<&str> {
997 if !signal.raw {
998 return None;
999 }
1000 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1001}
1002
1003pub(crate) fn write_interaction_marker(
1004 tag: &str,
1005 context: &mut WebUIProcessContext<'_, '_, '_>,
1006) -> Result<()> {
1007 if matches!(
1008 context
1009 .protocol
1010 .components
1011 .get(tag)
1012 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1013 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1014 ) {
1015 context.writer.write(" data-webui-interaction")?;
1016 }
1017 Ok(())
1018}
1019
1020fn doctype_prefix_end(raw: &str) -> Option<usize> {
1023 const PREFIX: &[u8] = b"<!doctype";
1024
1025 let bytes = raw.as_bytes();
1026 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1027 while bytes
1028 .get(cursor)
1029 .is_some_and(|byte| byte.is_ascii_whitespace())
1030 {
1031 cursor += 1;
1032 }
1033
1034 let prefix_end = cursor.checked_add(PREFIX.len())?;
1035 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1036 return None;
1037 }
1038 let separator = *bytes.get(prefix_end)?;
1039 if separator != b'>' && !separator.is_ascii_whitespace() {
1040 return None;
1041 }
1042
1043 let mut quote = None;
1044 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1045 match quote {
1046 Some(active) if byte == active => quote = None,
1047 Some(_) => {}
1048 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1049 None if byte == b'>' => return Some(prefix_end + offset + 1),
1050 None => {}
1051 }
1052 }
1053 None
1054}
1055
1056const SCOPE_POOL_CAP: usize = 8;
1059const INLINE_SCOPE_SLOTS: usize = 4;
1061const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1072const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1073
1074struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1075
1076impl Serialize for ComponentAssetStyleManifest<'_> {
1077 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1078 where
1079 S: serde::Serializer,
1080 {
1081 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1082 for entry in self.0 {
1083 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1084 }
1085 map.end()
1086 }
1087}
1088
1089fn serialize_component_asset_style_manifest(
1090 preloads: &[ComponentAssetStylePreload],
1091) -> std::result::Result<String, serde_json::Error> {
1092 if preloads.is_empty() {
1093 return Ok(String::new());
1094 }
1095 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1096 if json.contains("</") {
1097 Ok(json.replace("</", "<\\/"))
1098 } else {
1099 Ok(json)
1100 }
1101}
1102
1103fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1104 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1105 let mut seen = HashSet::with_capacity(href_count);
1106 let mut links = String::with_capacity(href_count.saturating_mul(48));
1107 for entry in preloads {
1108 for href in &entry.style_hrefs {
1109 if !seen.insert(href.as_str()) {
1110 continue;
1111 }
1112 links.push_str("<link rel=\"stylesheet\" href=\"");
1113 push_escaped_html_attribute(&mut links, href);
1114 links.push_str("\">");
1115 }
1116 }
1117 links
1118}
1119
1120fn push_escaped_html_attribute(output: &mut String, value: &str) {
1121 let mut start = 0;
1122 for (index, ch) in value.char_indices() {
1123 let escaped = match ch {
1124 '&' => Some("&"),
1125 '"' => Some("""),
1126 '<' => Some("<"),
1127 '>' => Some(">"),
1128 _ => None,
1129 };
1130 let Some(entity) = escaped else {
1131 continue;
1132 };
1133 if start < index {
1134 output.push_str(&value[start..index]);
1135 }
1136 output.push_str(entity);
1137 start = index + ch.len_utf8();
1138 }
1139 if start < value.len() {
1140 output.push_str(&value[start..]);
1141 }
1142}
1143
1144fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1147 pool.pop().unwrap_or_default()
1148}
1149
1150fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1154 if pool.len() < SCOPE_POOL_CAP {
1155 map.clear();
1156 pool.push(map);
1157 }
1158}
1159
1160fn take_borrowed_scope<'protocol, 'state>(
1161 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1162) -> BorrowedScope<'protocol, 'state> {
1163 pool.pop().unwrap_or_default()
1164}
1165
1166fn recycle_borrowed_scope<'protocol, 'state>(
1167 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1168 mut scope: BorrowedScope<'protocol, 'state>,
1169) {
1170 if pool.len() < SCOPE_POOL_CAP {
1171 scope.clear();
1172 pool.push(scope);
1173 }
1174}
1175
1176pub(crate) enum WebUiBootstrapState<'a> {
1177 Complete {
1178 value: &'a Value,
1179 selection: StateSelection<'a>,
1180 },
1181 Reference {
1182 record_sequence: usize,
1183 value: &'a Value,
1184 delta: Option<StateSelection<'a>>,
1185 },
1186}
1187
1188pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1189 pub(crate) declaration_id: Option<u32>,
1190 pub(crate) enclosing_span_instance_id: Option<u32>,
1191 pub(crate) state: WebUiBootstrapState<'a>,
1192 pub(crate) chain: &'a [Value],
1193 pub(crate) inventory: &'a str,
1194 pub(crate) nonce: Option<&'a str>,
1195 pub(crate) css_hrefs: &'a [&'a str],
1196 pub(crate) style_specs: &'a [&'a str],
1197 pub(crate) component_styles: &'a ComponentStyles,
1198 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1199}
1200
1201#[cfg(test)]
1211fn component_attr_name(name: &str) -> String {
1212 let mut buffer = String::new();
1213 push_component_attr_name(&mut buffer, name);
1214 buffer
1215}
1216
1217fn push_component_attr_name(buffer: &mut String, name: &str) {
1226 let stripped = name.strip_prefix(':').unwrap_or(name);
1227 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1232 buffer.push_str(property);
1233 return;
1234 }
1235 buffer.reserve(stripped.len());
1236 let mut capitalize_next = false;
1237 for ch in stripped.chars() {
1238 if ch == '-' {
1239 capitalize_next = true;
1240 } else if capitalize_next {
1241 buffer.extend(ch.to_uppercase());
1242 capitalize_next = false;
1243 } else {
1244 buffer.push(ch);
1245 }
1246 }
1247}
1248
1249#[cold]
1250#[inline(never)]
1251fn missing_component_attr_name_error() -> HandlerError {
1252 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1253}
1254
1255pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1257 if n == 0 {
1258 return writer.write("0");
1259 }
1260 let mut buf = [0u8; 20];
1262 let mut pos = buf.len();
1263 while n > 0 {
1264 pos -= 1;
1265 #[allow(clippy::cast_possible_truncation)]
1267 let digit = (n % 10) as u8;
1268 buf[pos] = b'0' + digit;
1269 n /= 10;
1270 }
1271 match std::str::from_utf8(&buf[pos..]) {
1273 Ok(s) => writer.write(s),
1274 Err(_) => writer.write("0"),
1275 }
1276}
1277
1278pub(crate) fn write_script_safe_json<T>(
1279 writer: &mut dyn ResponseWriter,
1280 scratch: &mut Vec<u8>,
1281 value: &T,
1282) -> Result<()>
1283where
1284 T: Serialize + ?Sized,
1285{
1286 scratch.clear();
1294 serde_json::to_writer(&mut *scratch, value)
1295 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1296 let json = std::str::from_utf8(scratch)
1297 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1298 write_script_safe_json_str(writer, json)
1299}
1300
1301fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1302 let mut start = 0;
1303 while start < json.len() {
1304 let rest = &json[start..];
1305 let Some(offset) = rest.find("</") else {
1306 writer.write(rest)?;
1307 return Ok(());
1308 };
1309
1310 if offset > 0 {
1311 writer.write(&rest[..offset])?;
1312 }
1313 writer.write("<\\/")?;
1314 start += offset + 2;
1315 }
1316 Ok(())
1317}
1318
1319fn write_json_field_name(
1320 writer: &mut dyn ResponseWriter,
1321 wrote_field: &mut bool,
1322 name: &str,
1323) -> Result<()> {
1324 if *wrote_field {
1325 writer.write(",")?;
1326 }
1327 *wrote_field = true;
1328 writer.write("\"")?;
1329 writer.write(name)?;
1330 writer.write("\":")
1331}
1332
1333fn write_json_field<T>(
1334 writer: &mut dyn ResponseWriter,
1335 scratch: &mut Vec<u8>,
1336 wrote_field: &mut bool,
1337 name: &str,
1338 value: &T,
1339) -> Result<()>
1340where
1341 T: Serialize + ?Sized,
1342{
1343 write_json_field_name(writer, wrote_field, name)?;
1344 write_script_safe_json(writer, scratch, value)
1345}
1346
1347struct ProjectedState<'a> {
1365 value: &'a Value,
1366 keys: KeyView<'a>,
1367}
1368
1369impl Serialize for ProjectedState<'_> {
1370 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1371 where
1372 S: serde::Serializer,
1373 {
1374 let Value::Object(map) = self.value else {
1375 return serializer.serialize_map(Some(0))?.end();
1376 };
1377
1378 let mut out = serializer.serialize_map(None)?;
1379 if self.keys.len() < map.len() {
1380 let mut previous = None;
1381 for key in self.keys.iter() {
1382 if key == STATE_INJECT_KEY {
1383 continue;
1384 }
1385 if previous == Some(key) {
1386 continue;
1387 }
1388 previous = Some(key);
1389 if let Some(value) = map.get(key) {
1390 out.serialize_entry(key, value)?;
1391 }
1392 }
1393 } else {
1394 for (key, value) in map {
1395 if key == STATE_INJECT_KEY {
1396 continue;
1397 }
1398 if self.keys.contains(key.as_str()) {
1399 out.serialize_entry(key, value)?;
1400 }
1401 }
1402 }
1403 out.end()
1404 }
1405}
1406
1407struct StateWithoutReservedKey<'a> {
1428 value: &'a Value,
1429}
1430
1431impl Serialize for StateWithoutReservedKey<'_> {
1432 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1433 where
1434 S: serde::Serializer,
1435 {
1436 let Value::Object(map) = self.value else {
1437 return self.value.serialize(serializer);
1438 };
1439 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1440 for (key, value) in map {
1441 if key == STATE_INJECT_KEY {
1442 continue;
1443 }
1444 out.serialize_entry(key, value)?;
1445 }
1446 out.end()
1447 }
1448}
1449
1450fn write_full_state(
1455 writer: &mut dyn ResponseWriter,
1456 scratch: &mut Vec<u8>,
1457 state: &Value,
1458) -> Result<()> {
1459 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1460 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1461 }
1462 write_script_safe_json(writer, scratch, state)
1463}
1464
1465pub(crate) fn write_selected_state(
1466 writer: &mut dyn ResponseWriter,
1467 scratch: &mut Vec<u8>,
1468 state: &Value,
1469 selection: &StateSelection<'_>,
1470) -> Result<()> {
1471 let keys = match selection {
1472 StateSelection::Full => return write_full_state(writer, scratch, state),
1473 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1474 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1475 StateSelection::FullExceptKeyIds(selection) => {
1476 return write_script_safe_json(
1477 writer,
1478 scratch,
1479 &StateWithoutSelectedKeys {
1480 value: state,
1481 keys: KeyView::Ids(*selection),
1482 },
1483 );
1484 }
1485 };
1486 if keys.is_empty() {
1487 return writer.write("{}");
1488 }
1489
1490 debug_assert!(
1495 keys.iter()
1496 .zip(keys.iter().skip(1))
1497 .all(|(left, right)| left <= right),
1498 "hydration keys must be sorted for binary-search projection"
1499 );
1500 if let Value::Object(map) = state {
1501 let selects_entire_map =
1502 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1503 if selects_entire_map {
1504 return write_full_state(writer, scratch, state);
1505 }
1506 }
1507 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1508}
1509
1510pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1513
1514pub(crate) enum StateSelection<'a> {
1516 Full,
1518 Keys(Vec<&'a str>),
1520 KeyIds(HydrationKeySelection<'a>),
1527 FullExceptKeyIds(HydrationKeySelection<'a>),
1529}
1530
1531#[derive(Clone, Copy)]
1533pub(crate) struct HydrationKeySelection<'a> {
1534 pub(crate) ids: &'a [u32],
1535 pub(crate) index: &'a ComponentReachabilityIndex,
1536}
1537
1538impl<'a> HydrationKeySelection<'a> {
1539 fn key(self, position: usize) -> Option<&'a str> {
1540 let id = self.ids.get(position).copied()?;
1541 self.index.hydration_key(id)
1542 }
1543}
1544
1545#[derive(Clone, Copy)]
1547enum KeyView<'a> {
1548 Borrowed(&'a [&'a str]),
1549 Ids(HydrationKeySelection<'a>),
1550}
1551
1552impl<'a> KeyView<'a> {
1553 fn len(self) -> usize {
1554 match self {
1555 Self::Borrowed(keys) => keys.len(),
1556 Self::Ids(selection) => selection.ids.len(),
1557 }
1558 }
1559
1560 fn is_empty(self) -> bool {
1561 self.len() == 0
1562 }
1563
1564 fn get(self, position: usize) -> Option<&'a str> {
1565 match self {
1566 Self::Borrowed(keys) => keys.get(position).copied(),
1567 Self::Ids(selection) => selection.key(position),
1568 }
1569 }
1570
1571 fn iter(self) -> impl Iterator<Item = &'a str> {
1572 (0..self.len()).map_while(move |position| self.get(position))
1573 }
1574
1575 fn contains(self, key: &str) -> bool {
1578 match self {
1579 Self::Borrowed(keys) => keys
1580 .binary_search_by(|candidate| str::cmp(candidate, key))
1581 .is_ok(),
1582 Self::Ids(selection) => selection
1583 .ids
1584 .binary_search_by(|id| {
1585 selection
1586 .index
1587 .hydration_key(*id)
1588 .map_or(std::cmp::Ordering::Less, |candidate| {
1589 str::cmp(candidate, key)
1590 })
1591 })
1592 .is_ok(),
1593 }
1594 }
1595}
1596
1597#[derive(Clone, Copy)]
1598enum ComponentStateSurface {
1599 Hydration,
1600 Navigation,
1601}
1602
1603pub(crate) fn collect_hydration_state<'a, 'b>(
1609 protocol: &'a WebUIProtocol,
1610 components: impl IntoIterator<Item = &'b str>,
1611) -> StateSelection<'a> {
1612 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1613 return StateSelection::Full;
1614 }
1615 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1616}
1617
1618pub(crate) fn collect_hydration_key_ids_into(
1626 protocol: &WebUIProtocol,
1627 index: &ComponentReachabilityIndex,
1628 components: impl IntoIterator<Item = u32>,
1629 ids: &mut Vec<u32>,
1630) -> bool {
1631 ids.clear();
1632 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1633 return true;
1634 }
1635 for component in components {
1636 if index.extend_hydration_keys(component, ids) {
1637 ids.clear();
1638 return true;
1639 }
1640 }
1641 ids.sort_unstable();
1642 ids.dedup();
1643 false
1644}
1645
1646pub(crate) fn collect_navigation_state<'a, 'b>(
1648 protocol: &'a WebUIProtocol,
1649 components: impl IntoIterator<Item = &'b str>,
1650) -> StateSelection<'a> {
1651 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1652}
1653
1654fn collect_component_state<'a, 'b>(
1655 protocol: &'a WebUIProtocol,
1656 components: impl IntoIterator<Item = &'b str>,
1657 surface: ComponentStateSurface,
1658) -> StateSelection<'a> {
1659 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1660 if collect_component_state_into(protocol, components, surface, &mut keys) {
1661 StateSelection::Full
1662 } else {
1663 StateSelection::Keys(keys)
1664 }
1665}
1666
1667fn collect_component_state_into<'a, 'b>(
1670 protocol: &'a WebUIProtocol,
1671 components: impl IntoIterator<Item = &'b str>,
1672 surface: ComponentStateSurface,
1673 keys: &mut Vec<&'a str>,
1674) -> bool {
1675 keys.clear();
1676 for name in components {
1677 let Some(component) = protocol.components.get(name) else {
1678 return true;
1679 };
1680 let (mode, component_keys) = match surface {
1681 ComponentStateSurface::Hydration => {
1682 (component.hydration_mode, &component.hydration_keys)
1683 }
1684 ComponentStateSurface::Navigation => (
1685 match component.navigation_mode {
1686 Some(mode) => mode,
1687 None if !component.navigation_keys.is_empty() => {
1688 StateProjectionMode::Keys as i32
1689 }
1690 None => return true,
1691 },
1692 &component.navigation_keys,
1693 ),
1694 };
1695 if mode == StateProjectionMode::All as i32 {
1696 return true;
1697 }
1698 if mode == StateProjectionMode::Keys as i32
1699 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1700 {
1701 keys.extend(component_keys.iter().map(String::as_str));
1702 } else if mode != StateProjectionMode::None as i32 {
1703 return true;
1704 }
1705 }
1706 keys.sort_unstable();
1707 keys.dedup();
1708 false
1709}
1710
1711pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1712 writer: &mut dyn ResponseWriter,
1713 scratch: &mut Vec<u8>,
1714 bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1715) -> Result<()> {
1716 let mut wrote_field = false;
1717
1718 writer.write("{")?;
1719 if let Some(declaration_id) = bootstrap.declaration_id {
1720 write_json_field(
1721 writer,
1722 scratch,
1723 &mut wrote_field,
1724 "declarationId",
1725 &declaration_id,
1726 )?;
1727 }
1728 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1729 write_json_field(
1730 writer,
1731 scratch,
1732 &mut wrote_field,
1733 "enclosingSpanInstanceId",
1734 &span_id,
1735 )?;
1736 }
1737 if !bootstrap.chain.is_empty() {
1738 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1739 }
1740 write_json_field(
1743 writer,
1744 scratch,
1745 &mut wrote_field,
1746 "componentStyles",
1747 bootstrap.component_styles,
1748 )?;
1749 if !bootstrap.css_hrefs.is_empty() {
1750 write_json_field(
1751 writer,
1752 scratch,
1753 &mut wrote_field,
1754 "css",
1755 bootstrap.css_hrefs,
1756 )?;
1757 }
1758 write_json_field(
1759 writer,
1760 scratch,
1761 &mut wrote_field,
1762 "inventory",
1763 bootstrap.inventory,
1764 )?;
1765 if let Some(nonce) = bootstrap.nonce {
1766 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1767 }
1768 match bootstrap.state {
1769 WebUiBootstrapState::Complete { value, selection } => {
1770 write_json_field_name(writer, &mut wrote_field, "state")?;
1771 write_selected_state(writer, scratch, value, &selection)?;
1772 }
1773 WebUiBootstrapState::Reference {
1774 record_sequence,
1775 value,
1776 delta,
1777 } => {
1778 write_json_field(
1779 writer,
1780 scratch,
1781 &mut wrote_field,
1782 "stateRef",
1783 &record_sequence,
1784 )?;
1785 if let Some(delta) = delta {
1786 write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1787 write_selected_state(writer, scratch, value, &delta)?;
1788 }
1789 }
1790 }
1791 if !bootstrap.style_specs.is_empty() {
1792 write_json_field(
1793 writer,
1794 scratch,
1795 &mut wrote_field,
1796 "styles",
1797 bootstrap.style_specs,
1798 )?;
1799 }
1800 if bootstrap
1801 .templates
1802 .iter()
1803 .any(|template| !template.template_json.is_empty())
1804 {
1805 write_json_field_name(writer, &mut wrote_field, "templates")?;
1806 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1807 }
1808 writer.write("}")
1809}
1810
1811fn write_webui_data_block(
1812 writer: &mut dyn ResponseWriter,
1813 scratch: &mut Vec<u8>,
1814 bootstrap: WebUiBootstrap<'_>,
1815) -> Result<()> {
1816 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1817 if let Some(nonce) = bootstrap.nonce {
1818 writer.write(" nonce=\"")?;
1819 writer.write(nonce)?;
1820 writer.write("\"")?;
1821 }
1822
1823 writer.write(">")?;
1824 write_webui_bootstrap(writer, scratch, bootstrap)?;
1825 writer.write("</script>\n")
1826}
1827
1828fn write_component_asset_style_manifest(
1829 writer: &mut dyn ResponseWriter,
1830 manifest: &str,
1831 nonce: Option<&str>,
1832) -> Result<()> {
1833 if manifest.is_empty() {
1834 return Ok(());
1835 }
1836 writer.write("<script type=\"application/json\" id=\"")?;
1837 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1838 writer.write("\"")?;
1839 if let Some(nonce) = nonce {
1840 writer.write(" nonce=\"")?;
1841 writer.write(&crate::html_encode::encode_safe(nonce))?;
1842 writer.write("\"")?;
1843 }
1844 writer.write(">")?;
1845 writer.write(manifest)?;
1846 writer.write("</script>")
1847}
1848
1849fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1850 if context.component_asset_styles_emitted {
1851 return Ok(());
1852 }
1853 context.component_asset_styles_emitted = true;
1854 context.writer.write(context.component_asset_style_links)?;
1855 write_component_asset_style_manifest(
1856 context.writer,
1857 context.component_asset_style_manifest,
1858 context.nonce,
1859 )
1860}
1861
1862fn write_webui_template_json_map(
1863 writer: &mut dyn ResponseWriter,
1864 scratch: &mut Vec<u8>,
1865 templates: &[WebUiTemplatePayload<'_>],
1866) -> Result<()> {
1867 writer.write("{")?;
1868 let mut wrote = false;
1869 for template in templates {
1870 if template.template_json.is_empty() {
1871 continue;
1872 }
1873 if wrote {
1874 writer.write(",")?;
1875 }
1876 wrote = true;
1877 write_script_safe_json(writer, scratch, template.tag_name)?;
1878 writer.write(":")?;
1879 write_script_safe_json_str(writer, template.template_json)?;
1880 }
1881 writer.write("}")
1882}
1883
1884fn resolve_value_from_sources<'ctx, 'state>(
1885 path: &str,
1886 loop_vars: &'ctx [LoopBinding<'_, 'state>],
1887 visible_loop_scope: VisibleLoopScope,
1888 local_values: LocalValueSources<'ctx, '_, 'state>,
1889 state: &'state Value,
1890) -> Option<Cow<'ctx, Value>>
1891where
1892 'state: 'ctx,
1893{
1894 if let Some(binding) = loop_vars
1895 .get(visible_loop_scope.start..visible_loop_scope.end)
1896 .and_then(|bindings| bindings.last())
1897 {
1898 let name = binding.name;
1899 if path.len() == name.len() && path == name {
1900 return Some(Cow::Borrowed(binding.value));
1901 }
1902 if path.len() > name.len()
1903 && path.as_bytes().get(name.len()) == Some(&b'.')
1904 && path.starts_with(name)
1905 {
1906 if let Some(value) =
1907 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1908 {
1909 return Some(value);
1910 }
1911 return find_value_by_dotted_path_ref(path, state);
1912 }
1913 }
1914
1915 if let Some(first_part) = path.split('.').next() {
1916 if let Some(local_value) = local_values.borrowed.get(first_part) {
1917 if first_part.len() == path.len() {
1918 return Some(Cow::Borrowed(local_value));
1919 }
1920 let remaining = &path[first_part.len() + 1..];
1921 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1922 return Some(value);
1923 }
1924 return find_value_by_dotted_path_ref(path, state);
1925 }
1926 if let Some(local_value) = local_values.owned.get(first_part) {
1927 if first_part.len() == path.len() {
1928 return Some(Cow::Borrowed(local_value));
1929 }
1930 let remaining = &path[first_part.len() + 1..];
1931 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1932 return Some(value);
1933 }
1934 return find_value_by_dotted_path_ref(path, state);
1935 }
1936
1937 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1938 .iter()
1939 .rev()
1940 {
1941 if binding.name != first_part {
1942 continue;
1943 }
1944 if first_part.len() == path.len() {
1945 return Some(Cow::Borrowed(binding.value));
1946 }
1947 let remaining = &path[first_part.len() + 1..];
1948 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1949 return Some(value);
1950 }
1951 return find_value_by_dotted_path_ref(path, state);
1952 }
1953 }
1954
1955 find_value_by_dotted_path_ref(path, state)
1956}
1957
1958fn resolve_borrowed_collection<'state>(
1959 path: &str,
1960 loop_vars: &[LoopBinding<'_, 'state>],
1961 visible_loop_scope: VisibleLoopScope,
1962 local_values: LocalValueSources<'_, '_, 'state>,
1963 state: &'state Value,
1964) -> Option<&'state [Value]> {
1965 let first_part = path.split('.').next()?;
1966 if let Some(binding) = loop_vars
1967 .get(visible_loop_scope.start..visible_loop_scope.end)
1968 .and_then(|bindings| bindings.last())
1969 .filter(|binding| binding.name == first_part)
1970 {
1971 if first_part.len() == path.len() {
1972 return binding.value.as_array().map(Vec::as_slice);
1973 }
1974 let remaining = &path[first_part.len() + 1..];
1975 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1976 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1977 Some(Cow::Owned(_)) => None,
1978 None => borrowed_state_array(path, state),
1979 };
1980 }
1981 if let Some(local_value) = local_values.borrowed.get(first_part) {
1982 let local_match = if first_part.len() == path.len() {
1983 Some(Cow::Borrowed(local_value))
1984 } else {
1985 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1986 };
1987 return match local_match {
1988 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1989 Some(Cow::Owned(_)) => None,
1990 None => borrowed_state_array(path, state),
1991 };
1992 }
1993 if let Some(local_value) = local_values.owned.get(first_part) {
1994 let local_match = if first_part.len() == path.len() {
1995 Some(Cow::Borrowed(local_value))
1996 } else {
1997 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1998 };
1999 if local_match.is_some() {
2000 return None;
2001 }
2002 return borrowed_state_array(path, state);
2003 }
2004
2005 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2006 .iter()
2007 .rev()
2008 {
2009 if binding.name != first_part {
2010 continue;
2011 }
2012 if first_part.len() == path.len() {
2013 return binding.value.as_array().map(Vec::as_slice);
2014 }
2015 let remaining = &path[first_part.len() + 1..];
2016 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2017 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2018 Some(Cow::Owned(_)) => None,
2019 None => borrowed_state_array(path, state),
2020 };
2021 }
2022
2023 borrowed_state_array(path, state)
2024}
2025
2026fn resolve_state_backed_value<'state>(
2027 path: &str,
2028 loop_vars: &[LoopBinding<'_, 'state>],
2029 visible_loop_scope: VisibleLoopScope,
2030 local_values: LocalValueSources<'_, '_, 'state>,
2031 state: &'state Value,
2032) -> Option<&'state Value> {
2033 let first_part = path.split('.').next()?;
2034 if let Some(binding) = loop_vars
2035 .get(visible_loop_scope.start..visible_loop_scope.end)
2036 .and_then(|bindings| bindings.last())
2037 .filter(|binding| binding.name == first_part)
2038 {
2039 if first_part.len() == path.len() {
2040 return Some(binding.value);
2041 }
2042 let remaining = &path[first_part.len() + 1..];
2043 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2044 Some(Cow::Borrowed(value)) => Some(value),
2045 Some(Cow::Owned(_)) => None,
2046 None => borrowed_state_value(path, state),
2047 };
2048 }
2049 if let Some(local_value) = local_values.borrowed.get(first_part) {
2050 if first_part.len() == path.len() {
2051 return Some(local_value);
2052 }
2053 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2054 Some(Cow::Borrowed(value)) => Some(value),
2055 Some(Cow::Owned(_)) => None,
2056 None => borrowed_state_value(path, state),
2057 };
2058 }
2059 if let Some(local_value) = local_values.owned.get(first_part) {
2060 if first_part.len() == path.len() {
2061 return None;
2062 }
2063 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2064 Some(_) => None,
2065 None => borrowed_state_value(path, state),
2066 };
2067 }
2068
2069 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2070 .iter()
2071 .rev()
2072 {
2073 if binding.name != first_part {
2074 continue;
2075 }
2076 if first_part.len() == path.len() {
2077 return Some(binding.value);
2078 }
2079 let remaining = &path[first_part.len() + 1..];
2080 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2081 Some(Cow::Borrowed(value)) => Some(value),
2082 Some(Cow::Owned(_)) => None,
2083 None => borrowed_state_value(path, state),
2084 };
2085 }
2086
2087 borrowed_state_value(path, state)
2088}
2089
2090fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2091 match find_value_by_dotted_path_ref(path, state) {
2092 Some(Cow::Borrowed(value)) => Some(value),
2093 Some(Cow::Owned(_)) | None => None,
2094 }
2095}
2096
2097fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2098 match find_value_by_dotted_path_ref(path, state)? {
2099 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2100 Cow::Owned(_) => None,
2101 }
2102}
2103
2104impl WebUIHandler {
2105 pub fn new() -> Self {
2107 Self {
2108 plugin_factory: None,
2109 }
2110 }
2111
2112 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2117 Self {
2118 plugin_factory: Some(factory),
2119 }
2120 }
2121
2122 #[cfg(test)]
2123 fn handle(
2124 &self,
2125 document: &WebUIProtocol,
2126 state: &Value,
2127 options: &RenderOptions<'_>,
2128 writer: &mut dyn ResponseWriter,
2129 ) -> Result<()> {
2130 let protocol = Protocol::new(document.clone());
2131 self.render(&protocol, state, options, writer)
2132 }
2133
2134 fn process_fragment_target<'data>(
2140 &self,
2141 target: Option<usize>,
2142 fragment_id: &str,
2143 context: &mut WebUIProcessContext<'data, '_, '_>,
2144 ) -> Result<()> {
2145 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2146 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2147 };
2148 let Some(fragment_list) = context.render_fragments.list(index) else {
2149 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2150 };
2151 self.process_fragment(fragment_list, context)
2152 }
2153
2154 fn process_fragment<'data>(
2159 &self,
2160 fragment_list: RenderFragmentList<'data>,
2161 context: &mut WebUIProcessContext<'data, '_, '_>,
2162 ) -> Result<()> {
2163 self.process_fragment_from(fragment_list, 0, context)
2164 }
2165
2166 fn process_fragment_from<'data>(
2167 &self,
2168 fragment_list: RenderFragmentList<'data>,
2169 start: usize,
2170 context: &mut WebUIProcessContext<'data, '_, '_>,
2171 ) -> Result<()> {
2172 let fragments = fragment_list.fragments;
2173 let best_route = if fragment_list.has_routes {
2179 route_renderer::find_best_route_match(
2180 fragments,
2181 context.request_path,
2182 &context.route_base,
2183 context.route_index,
2184 )
2185 } else {
2186 None
2187 };
2188 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2189 }
2190
2191 fn process_fragment_range<'data>(
2192 &self,
2193 fragment_list: RenderFragmentList<'data>,
2194 range: std::ops::Range<usize>,
2195 best_route: &Option<(String, route_matcher::RouteMatch)>,
2196 context: &mut WebUIProcessContext<'data, '_, '_>,
2197 ) -> Result<()> {
2198 let fragments = fragment_list.fragments;
2199 let Some(selected) = fragments.get(range.clone()) else {
2200 return Err(invalid_fragment_range_error(&range, fragments.len()));
2201 };
2202 for (offset, item) in selected.iter().enumerate() {
2203 let index = range.start + offset;
2204 if context.streaming.is_some() {
2205 validate_pending_streaming_root(item, context)?;
2206 validate_streaming_root_opening(&fragments[..index], item)?;
2207 }
2208 match item.fragment.as_ref() {
2212 Some(Fragment::Raw(raw)) => {
2213 context.writer.write(&raw.value)?;
2214 }
2215 Some(Fragment::Component(component)) => {
2216 self.process_component(
2217 component,
2218 fragment_list.target(index),
2219 ComponentHostOrigin::ParserProduced,
2220 context,
2221 )?;
2222 }
2223 Some(Fragment::ForLoop(for_loop)) => {
2224 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2225 }
2226 Some(Fragment::Signal(signal)) => {
2227 self.process_signal(signal, context)?;
2228 }
2229 Some(Fragment::IfCond(if_cond)) => {
2230 self.process_if(if_cond, fragment_list.target(index), context)?;
2231 }
2232 Some(Fragment::Attribute(attr)) => {
2233 self.process_attribute(
2234 attr,
2235 fragment_list.target(index),
2236 fragment_list.component_attr_name(index),
2237 context,
2238 )?;
2239 }
2240 Some(Fragment::Plugin(plugin_frag)) => {
2241 if let Some(p) = &mut context.plugin {
2242 p.on_element_data(&plugin_frag.data, context.writer)?;
2243 }
2244 }
2245 Some(Fragment::Route(route_frag)) => {
2246 self.process_route(route_frag, best_route, context)?;
2247 }
2248 Some(Fragment::Outlet(_)) => {
2249 self.process_outlet(context)?;
2250 }
2251 Some(Fragment::Boundary(_)) => {}
2252 None => {}
2253 }
2254 }
2255 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2256 }
2257
2258 fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2264 let mut children = std::mem::take(&mut context.route_children);
2265 if children.is_empty() {
2266 return Ok(());
2267 }
2268
2269 let request_segments = route_matcher::split_request_path(context.request_path);
2271 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2272 for (idx, child) in children.iter().enumerate() {
2273 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2274 context.route_index,
2275 &child.path,
2276 &context.route_base,
2277 &request_segments,
2278 child.exact,
2279 ) {
2280 let is_better = best
2281 .as_ref()
2282 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2283 if is_better {
2284 best = Some((idx, m));
2285 }
2286 }
2287 }
2288
2289 let grandchildren = if let Some((idx, _)) = &best {
2293 std::mem::take(&mut children[*idx].children)
2294 } else {
2295 Vec::new()
2296 };
2297
2298 if let Some((idx, ref rm)) = best {
2299 let matched_child = &children[idx];
2300 let comp = &matched_child.fragment_id;
2301
2302 if !comp.is_empty() {
2303 let saved_route_base = context.route_base.clone();
2304 let saved_route_children = std::mem::take(&mut context.route_children);
2305
2306 if rm.consumed_segments > 0 {
2307 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2308 context.request_path,
2309 rm.consumed_segments,
2310 ));
2311 }
2312
2313 context.route_children = grandchildren;
2314
2315 context.writer.write("<webui-route")?;
2317 context.writer.write(" path=\"")?;
2318 context.writer.write(&matched_child.path)?;
2319 context.writer.write("\"")?;
2320 context.writer.write(" component=\"")?;
2321 context.writer.write(comp)?;
2322 context.writer.write("\"")?;
2323 if matched_child.exact {
2324 context.writer.write(" exact")?;
2325 }
2326 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2327 let ri = context.route_chain_index;
2329 context.route_chain_index += 1;
2330 context.writer.write(" data-ri=\"")?;
2331 write_usize(context.writer, ri)?;
2332 context.writer.write("\" active>")?;
2333
2334 if !Self::component_owns_css_tree(comp, context.protocol) {
2335 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2336 }
2337 if !matched_child.content_fragment_id.is_empty() {
2338 self.process_fragment_target(
2339 None,
2340 &matched_child.content_fragment_id,
2341 context,
2342 )?;
2343 }
2344
2345 context.writer.write("<")?;
2346 context.writer.write(comp)?;
2347 if let Some(p) = &context.plugin {
2348 p.write_route_component_state(context.state, context.writer)?;
2349 }
2350 write_interaction_marker(comp, context)?;
2351 prepare_generated_streaming_root(comp, context)?;
2352 context.writer.write(">")?;
2353
2354 self.process_component(
2355 &webui_protocol::WebUIFragmentComponent {
2356 fragment_id: comp.clone(),
2357 },
2358 None,
2359 ComponentHostOrigin::HandlerGenerated,
2360 context,
2361 )?;
2362
2363 context.writer.write("</")?;
2364 context.writer.write(comp)?;
2365 context.writer.write(">")?;
2366 context.writer.write("</webui-route>")?;
2367
2368 context.route_base = saved_route_base;
2369 context.route_children = saved_route_children;
2370 }
2371 }
2372
2373 for (idx, child) in children.iter().enumerate() {
2375 let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2376 if !is_matched && !child.fragment_id.is_empty() {
2377 context.writer.write("<webui-route")?;
2378 context.writer.write(" path=\"")?;
2379 context.writer.write(&child.path)?;
2380 context.writer.write("\"")?;
2381 context.writer.write(" component=\"")?;
2382 context.writer.write(&child.fragment_id)?;
2383 context.writer.write("\"")?;
2384 if child.exact {
2385 context.writer.write(" exact")?;
2386 }
2387 route_renderer::write_route_navigation_attrs(context.writer, child)?;
2388 context
2389 .writer
2390 .write(" style=\"display:none\"></webui-route>")?;
2391 }
2392 }
2393
2394 Ok(())
2395 }
2396
2397 fn emit_css_module_importmap(
2408 &self,
2409 specifier: &str,
2410 css: &str,
2411 context: &mut WebUIProcessContext,
2412 ) -> Result<()> {
2413 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2414 context.writer.write(&tag)?;
2415 Ok(())
2416 }
2417
2418 fn emit_component_style_closure(
2420 &self,
2421 root: &str,
2422 install: StyleClosureInstall,
2423 context: &mut WebUIProcessContext,
2424 ) -> Result<()> {
2425 if context.protocol.style_closures.is_empty() {
2426 return Ok(());
2427 }
2428 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2429 match context.shadow_style_roots.last() {
2430 Some(shadow_root) => {
2431 if !shadow_root.static_closure_emitted {
2432 return Err(HandlerError::Invariant(
2433 "routed styles reached a ShadowRoot before its static style hook"
2434 .to_string(),
2435 ));
2436 }
2437 let root_name = context
2438 .component_index
2439 .iter()
2440 .find_map(|(name, index)| {
2441 (*index == shadow_root.component_index).then_some(name.as_str())
2442 })
2443 .ok_or_else(|| {
2444 HandlerError::Invariant(
2445 "active Shadow style root lost its protocol index".to_string(),
2446 )
2447 })?;
2448 Some(
2449 context
2450 .protocol
2451 .style_closures
2452 .get(root_name)
2453 .ok_or_else(|| {
2454 HandlerError::Invariant(format!(
2455 "component style closure metadata is missing Shadow root `{root_name}`"
2456 ))
2457 })?,
2458 )
2459 }
2460 None => None,
2461 }
2462 } else {
2463 None
2464 };
2465 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2466 HandlerError::Invariant(format!(
2467 "component style closure metadata is missing root `{root}`"
2468 ))
2469 })?;
2470 let is_document_tree = context.shadow_style_roots.is_empty();
2471 let strategy = context.css_strategy;
2472
2473 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2478 let mut emitted_resources = HashSet::with_capacity(unit_count);
2479
2480 for position in 0..unit_count {
2481 let unit = context
2482 .protocol
2483 .style_closure_unit(closure, &context.style_chunk_index, position)
2484 .ok_or_else(|| {
2485 HandlerError::Invariant(format!(
2486 "component style closure `{root}` references out-of-range unit {position}"
2487 ))
2488 })?;
2489 let (name, chunk) = (unit.name, unit.chunk);
2490 if !emitted_resources.insert(name) {
2491 continue;
2492 }
2493 let resource = unit.resource.ok_or_else(|| match chunk {
2494 Some(index) => HandlerError::Invariant(format!(
2495 "component style closure `{root}` references missing style chunk {index}"
2496 )),
2497 None => HandlerError::Invariant(format!(
2498 "component style closure `{root}` references missing resource `{name}`"
2499 )),
2500 })?;
2501 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2502 continue;
2503 }
2504 if let Some(static_closure) = shadow_static_closure {
2505 let resource_index = match chunk {
2506 Some(index) => {
2507 if static_closure.style_chunks.contains(&index) {
2508 continue;
2509 }
2510 index
2511 }
2512 None => {
2513 if static_closure.component_tags.iter().any(|tag| tag == name) {
2514 continue;
2515 }
2516 context.component_index.get(name).copied().ok_or_else(|| {
2517 HandlerError::Invariant(format!(
2518 "component style resource `{name}` is missing its protocol index"
2519 ))
2520 })?
2521 }
2522 };
2523 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2524 HandlerError::Invariant(
2525 "active Shadow style root disappeared during routed style delivery"
2526 .to_string(),
2527 )
2528 })?;
2529 if shadow_root.routed_resources.contains(&resource_index) {
2530 continue;
2531 }
2532 shadow_root.routed_resources.push(resource_index);
2533 }
2534
2535 match strategy {
2536 webui_protocol::CssStrategy::Link => {
2537 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2538 context
2539 .writer
2540 .write(&crate::html_encode::encode_safe(resource))?;
2541 context.writer.write("\" data-webui-resource=\"")?;
2542 context
2543 .writer
2544 .write(&crate::html_encode::encode_safe(name))?;
2545 context.writer.write("\" data-webui-strategy=\"link\">")?;
2546 }
2547 strategy => {
2548 context.writer.write("<style")?;
2549 if let Some(nonce) = context.nonce {
2550 context.writer.write(" nonce=\"")?;
2551 context
2552 .writer
2553 .write(&crate::html_encode::encode_safe(nonce))?;
2554 context.writer.write("\"")?;
2555 }
2556 context.writer.write(" data-webui-resource=\"")?;
2557 context
2558 .writer
2559 .write(&crate::html_encode::encode_safe(name))?;
2560 context.writer.write("\" data-webui-strategy=\"")?;
2561 context
2562 .writer
2563 .write(if strategy == webui_protocol::CssStrategy::Module {
2564 "module"
2565 } else {
2566 "style"
2567 })?;
2568 context.writer.write("\">")?;
2569 if context.style_resources_requiring_escape.contains(name) {
2570 crate::html_encode::write_style_text(context.writer, resource)?;
2571 } else {
2572 context.writer.write(resource)?;
2573 }
2574 context.writer.write("</style>")?;
2575 }
2576 }
2577 }
2578 Ok(())
2579 }
2580
2581 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2582 if context.route_chain.is_none() {
2583 let plan = crate::route_handler::collect_route_chain_plan(
2584 context.protocol,
2585 context.entry_id,
2586 context.request_path,
2587 context.route_index,
2588 );
2589 context.route_document_style_targets = plan.document_style_targets;
2590 context.route_chain = Some(plan.entries);
2591 }
2592 }
2593
2594 fn emit_active_route_styles(
2595 &self,
2596 preloaded: &mut Vec<u32>,
2597 context: &mut WebUIProcessContext,
2598 ) -> Result<()> {
2599 Self::ensure_request_route_chain(context);
2600 let Some(chain) = context.route_chain.take() else {
2601 return Err(route_style_plan_missing_error());
2602 };
2603 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2604 if chain.len() != document_targets.len() {
2605 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2606 context.route_document_style_targets = document_targets;
2607 context.route_chain = Some(chain);
2608 return Err(error);
2609 }
2610
2611 let result = (|| {
2612 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2613 if *targets_document {
2614 self.emit_component_style_closure(
2615 &entry.component,
2616 StyleClosureInstall::Static,
2617 context,
2618 )?;
2619 continue;
2620 }
2621
2622 if context.css_strategy != webui_protocol::CssStrategy::Link {
2623 continue;
2624 }
2625 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2626 }
2627 Ok(())
2628 })();
2629
2630 context.route_document_style_targets = document_targets;
2631 context.route_chain = Some(chain);
2632 result
2633 }
2634
2635 fn emit_component_style_preloads(
2636 &self,
2637 root: &str,
2638 preloaded: &mut Vec<u32>,
2639 context: &mut WebUIProcessContext,
2640 ) -> Result<()> {
2641 let Some(closure) = context.protocol.style_closures.get(root) else {
2642 return Ok(());
2643 };
2644 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2646 for position in 0..unit_count {
2647 let Some(unit) =
2648 context
2649 .protocol
2650 .style_closure_unit(closure, &context.style_chunk_index, position)
2651 else {
2652 continue;
2653 };
2654 let Some(href) = unit.resource else {
2655 continue;
2656 };
2657 let name = unit.name;
2658 if context.document_style_resources.contains(name) {
2659 continue;
2660 }
2661 let Some(&resource_index) = context.style_resource_index.get(name) else {
2662 continue;
2663 };
2664 if preloaded.contains(&resource_index) {
2665 continue;
2666 }
2667 context
2668 .writer
2669 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2670 context
2671 .writer
2672 .write(&crate::html_encode::encode_safe(href))?;
2673 context.writer.write("\">")?;
2674 preloaded.push(resource_index);
2675 }
2676 Ok(())
2677 }
2678
2679 fn emit_reachable_shadow_preloads(
2680 &self,
2681 preloaded: &mut Vec<u32>,
2682 context: &mut WebUIProcessContext,
2683 ) -> Result<()> {
2684 if context.css_strategy != webui_protocol::CssStrategy::Link {
2685 return Ok(());
2686 }
2687 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2688 crate::route_handler::collect_reachable_component_order_for_request(
2689 context.protocol,
2690 context.entry_id,
2691 context.request_path,
2692 context.route_index,
2693 )
2694 });
2695 let result = (|| {
2696 for component in &reachable {
2697 if context.protocol.component_uses_shadow_dom(component) {
2698 self.emit_component_style_preloads(component, preloaded, context)?;
2699 }
2700 }
2701 Ok(())
2702 })();
2703 context.reachable_components = Some(reachable);
2704 result
2705 }
2706
2707 fn process_shadow_style_signal(
2708 &self,
2709 root: &str,
2710 context: &mut WebUIProcessContext,
2711 ) -> Result<()> {
2712 let Some(root_index) = context.component_index.get(root).copied() else {
2713 return Err(HandlerError::Invariant(format!(
2714 "Shadow style hook `{root}` references an unknown component"
2715 )));
2716 };
2717 if context
2718 .shadow_style_roots
2719 .last()
2720 .map(|shadow_root| shadow_root.component_index)
2721 != Some(root_index)
2722 {
2723 return Err(HandlerError::Invariant(format!(
2724 "Shadow style hook `{root}` does not match the active component root"
2725 )));
2726 }
2727 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2728 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2729 HandlerError::Invariant(
2730 "active Shadow style root disappeared after its static style hook".to_string(),
2731 )
2732 })?;
2733 shadow_root.static_closure_emitted = true;
2734 Ok(())
2735 }
2736
2737 fn emit_css_module(
2746 &self,
2747 component: &webui_protocol::WebUIFragmentComponent,
2748 context: &mut WebUIProcessContext,
2749 ) -> Result<()> {
2750 if context.css_strategy != webui_protocol::CssStrategy::Module {
2751 return Ok(());
2752 }
2753 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2754 streaming_template_already_sent(
2755 streaming,
2756 context.component_index,
2757 &component.fragment_id,
2758 )
2759 });
2760 if !metadata_already_streamed
2761 && !context.rendered_components.contains(&component.fragment_id)
2762 {
2763 if let Some(css) = context
2764 .protocol
2765 .components
2766 .get(&component.fragment_id)
2767 .map(|c| c.css.as_str())
2768 .filter(|s| !s.is_empty())
2769 {
2770 self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2771 }
2772 }
2773 Ok(())
2774 }
2775
2776 fn process_route(
2778 &self,
2779 route_frag: &webui_protocol::WebUiFragmentRoute,
2780 best_route: &Option<(String, route_matcher::RouteMatch)>,
2781 context: &mut WebUIProcessContext,
2782 ) -> Result<()> {
2783 let is_matched = best_route
2784 .as_ref()
2785 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2786
2787 context.writer.write("<webui-route")?;
2788 context.writer.write(" path=\"")?;
2789 context.writer.write(&route_frag.path)?;
2790 context.writer.write("\"")?;
2791 if !route_frag.fragment_id.is_empty() {
2792 context.writer.write(" component=\"")?;
2793 context.writer.write(&route_frag.fragment_id)?;
2794 context.writer.write("\"")?;
2795 }
2796 if route_frag.exact {
2797 context.writer.write(" exact")?;
2798 }
2799 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2800
2801 if is_matched {
2802 let ri = context.route_chain_index;
2804 context.route_chain_index += 1;
2805 context.writer.write(" data-ri=\"")?;
2806 write_usize(context.writer, ri)?;
2807 context.writer.write("\" active>")?;
2808
2809 let saved_route_base = context.route_base.clone();
2810 let saved_route_children = std::mem::take(&mut context.route_children);
2811 if let Some((_, ref rm)) = best_route {
2812 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2813 context.request_path,
2814 rm.consumed_segments,
2815 ));
2816 }
2817 context.route_children = route_frag.children.clone();
2818
2819 if !route_frag.content_fragment_id.is_empty() {
2820 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2821 }
2822
2823 if !route_frag.fragment_id.is_empty() {
2824 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2825 self.emit_component_style_closure(
2826 &route_frag.fragment_id,
2827 StyleClosureInstall::Routed,
2828 context,
2829 )?;
2830 }
2831 let comp = webui_protocol::WebUIFragmentComponent {
2832 fragment_id: route_frag.fragment_id.clone(),
2833 };
2834
2835 context.writer.write("<")?;
2836 context.writer.write(&route_frag.fragment_id)?;
2837 if let Some(p) = &context.plugin {
2838 p.write_route_component_state(context.state, context.writer)?;
2839 }
2840 write_interaction_marker(&route_frag.fragment_id, context)?;
2841 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2842 context.writer.write(">")?;
2843
2844 self.process_component(
2845 &comp,
2846 None,
2847 ComponentHostOrigin::HandlerGenerated,
2848 context,
2849 )?;
2850
2851 context.writer.write("</")?;
2852 context.writer.write(&route_frag.fragment_id)?;
2853 context.writer.write(">")?;
2854 }
2855 context.route_base = saved_route_base;
2856 context.route_children = saved_route_children;
2857 } else {
2858 context.writer.write(" style=\"display:none\">")?;
2859 }
2860
2861 context.writer.write("</webui-route>")?;
2862 Ok(())
2863 }
2864
2865 fn process_component(
2867 &self,
2868 component: &webui_protocol::WebUIFragmentComponent,
2869 target: Option<usize>,
2870 origin: ComponentHostOrigin,
2871 context: &mut WebUIProcessContext,
2872 ) -> Result<()> {
2873 if context.streaming.is_some() {
2874 consume_streaming_component_root(&component.fragment_id, origin, context)?;
2875 record_checkpoint_tag(context, &component.fragment_id);
2878 }
2879
2880 if !context.rendered_components.contains(&component.fragment_id) {
2886 self.emit_css_module(component, context)?;
2887 context
2888 .rendered_components
2889 .insert(component.fragment_id.clone());
2890 }
2891
2892 let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2893 if owns_css_tree {
2894 Self::push_shadow_style_root(&component.fragment_id, context)?;
2895 }
2896
2897 let saved_local_vars = std::mem::take(&mut context.local_vars);
2899 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2900 let saved_component_attrs = std::mem::replace(
2904 &mut context.component_attrs,
2905 take_scope_map(&mut context.scope_pool),
2906 );
2907 let saved_component_borrowed_attrs = std::mem::replace(
2908 &mut context.component_borrowed_attrs,
2909 take_borrowed_scope(&mut context.borrowed_scope_pool),
2910 );
2911 context.local_vars = saved_component_attrs;
2912 context.local_borrowed_vars = saved_component_borrowed_attrs;
2913 context.collecting_component_attrs = false;
2914 let saved_loop_scope = context.visible_loop_scope;
2915 context.visible_loop_scope = VisibleLoopScope {
2916 start: context.loop_vars.len(),
2917 end: context.loop_vars.len(),
2918 };
2919
2920 if let Some(p) = &mut context.plugin {
2921 p.push_scope();
2922 }
2923
2924 let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2925 context.visible_loop_scope = saved_loop_scope;
2926
2927 if owns_css_tree {
2928 Self::pop_shadow_style_root(&component.fragment_id, context)?;
2929 }
2930 render_result?;
2931
2932 if let Some(p) = &mut context.plugin {
2933 p.pop_scope();
2934 }
2935
2936 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2939 recycle_scope_map(&mut context.scope_pool, used_locals);
2940 let used_borrowed_locals =
2941 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2942 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2943 context.component_attrs.clear();
2946 context.component_borrowed_attrs.clear();
2947
2948 Ok(())
2949 }
2950
2951 #[inline]
2952 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2953 !protocol.style_closures.is_empty()
2954 && protocol
2955 .components
2956 .get(component)
2957 .is_some_and(|data| data.uses_shadow_dom)
2958 }
2959
2960 pub(crate) fn push_shadow_style_root(
2961 component: &str,
2962 context: &mut WebUIProcessContext,
2963 ) -> Result<()> {
2964 if !context.protocol.style_closures.contains_key(component) {
2965 return Err(HandlerError::Invariant(format!(
2966 "component style closure metadata is missing Shadow root `{component}`"
2967 )));
2968 }
2969 let root_index = context
2970 .component_index
2971 .get(component)
2972 .copied()
2973 .ok_or_else(|| {
2974 HandlerError::Invariant(format!(
2975 "Shadow component `{component}` is missing its protocol index"
2976 ))
2977 })?;
2978 context.shadow_style_roots.push(ShadowStyleRoot {
2979 component_index: root_index,
2980 static_closure_emitted: false,
2981 routed_resources: Vec::new(),
2982 });
2983 Ok(())
2984 }
2985
2986 pub(crate) fn pop_shadow_style_root(
2987 component: &str,
2988 context: &mut WebUIProcessContext,
2989 ) -> Result<()> {
2990 context.shadow_style_roots.pop().ok_or_else(|| {
2991 HandlerError::Invariant(format!(
2992 "Shadow component `{component}` lost its active style root"
2993 ))
2994 })?;
2995 Ok(())
2996 }
2997
2998 fn resolve_value_owned(
3000 &self,
3001 path: &str,
3002 context: &WebUIProcessContext<'_, '_, '_>,
3003 ) -> Option<Value> {
3004 resolve_value_from_sources(
3005 path,
3006 &context.loop_vars,
3007 context.visible_loop_scope,
3008 LocalValueSources {
3009 owned: &context.local_vars,
3010 borrowed: &context.local_borrowed_vars,
3011 },
3012 context.state,
3013 )
3014 .map(Cow::into_owned)
3015 }
3016
3017 fn evaluate_condition(
3024 &self,
3025 condition: &webui_protocol::ConditionExpr,
3026 context: &WebUIProcessContext,
3027 ) -> Result<bool> {
3028 let loop_vars = &context.loop_vars;
3029 let visible_loop_scope = context.visible_loop_scope;
3030 let local_values = LocalValueSources {
3031 owned: &context.local_vars,
3032 borrowed: &context.local_borrowed_vars,
3033 };
3034 let state = context.state;
3035 match evaluate_with_resolver(condition, |path| {
3036 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3037 }) {
3038 Ok(result) => Ok(result),
3039 Err(ExpressionError::MissingValue(_)) => Ok(false),
3040 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3041 }
3042 }
3043
3044 fn process_for_loop<'protocol, 'state>(
3050 &self,
3051 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3052 target: Option<usize>,
3053 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3054 ) -> Result<()> {
3055 if let Some(items) = resolve_borrowed_collection(
3056 &for_loop.collection,
3057 &context.loop_vars,
3058 context.visible_loop_scope,
3059 LocalValueSources {
3060 owned: &context.local_vars,
3061 borrowed: &context.local_borrowed_vars,
3062 },
3063 context.state,
3064 ) {
3065 return self.process_borrowed_for_loop(for_loop, target, items, context);
3066 }
3067 self.process_owned_for_loop(for_loop, target, context)
3068 }
3069
3070 fn process_borrowed_for_loop<'protocol, 'state>(
3071 &self,
3072 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3073 target: Option<usize>,
3074 items: &'state [Value],
3075 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3076 ) -> Result<()> {
3077 if let Some(plugin) = &mut context.plugin {
3078 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3079 }
3080
3081 let item_name = for_loop.item.as_str();
3082 let saved_value = context.local_vars.remove(item_name);
3083 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3084 let saved_scope = context.visible_loop_scope;
3085 for (index, item) in items.iter().enumerate() {
3086 if let Some(plugin) = &mut context.plugin {
3087 plugin.on_repeat_item_start(index, context.writer)?;
3088 plugin.push_scope();
3089 }
3090
3091 context.loop_vars.push(LoopBinding {
3092 name: item_name,
3093 value: item,
3094 });
3095 context.visible_loop_scope.end = context.loop_vars.len();
3096 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3097 context.loop_vars.pop();
3098 context.visible_loop_scope = saved_scope;
3099
3100 if let Some(plugin) = &mut context.plugin {
3101 plugin.pop_scope();
3102 plugin.on_repeat_item_end(index, context.writer)?;
3103 }
3104 }
3105 if let Some(value) = saved_value {
3106 context.local_vars.insert(item_name.to_string(), value);
3107 }
3108 if let Some(value) = saved_borrowed_value {
3109 context.local_borrowed_vars.insert(item_name, value);
3110 }
3111
3112 if let Some(plugin) = &mut context.plugin {
3113 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3114 }
3115 Ok(())
3116 }
3117
3118 fn process_owned_for_loop<'protocol, 'state>(
3119 &self,
3120 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3121 target: Option<usize>,
3122 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3123 ) -> Result<()> {
3124 let collection_name = &for_loop.collection;
3125
3126 let items = match self.resolve_value_owned(collection_name, context) {
3129 Some(Value::Array(arr)) => arr,
3130 Some(_) => {
3131 return Err(HandlerError::TypeError(format!(
3132 "Collection '{}' is not an array",
3133 collection_name
3134 )))
3135 }
3136 None => Vec::new(),
3137 };
3138
3139 if let Some(p) = &mut context.plugin {
3140 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3141 }
3142
3143 let item_name = for_loop.item.as_str();
3151 let saved_value = context.local_vars.remove(item_name);
3152 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3153 if !items.is_empty() {
3157 context
3158 .local_vars
3159 .insert(item_name.to_string(), Value::Null);
3160 }
3161 for (i, item) in items.into_iter().enumerate() {
3162 if let Some(p) = &mut context.plugin {
3163 p.on_repeat_item_start(i, context.writer)?;
3164 p.push_scope();
3165 }
3166
3167 if let Some(slot) = context.local_vars.get_mut(item_name) {
3169 *slot = item;
3170 }
3171 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3172
3173 if let Some(p) = &mut context.plugin {
3174 p.pop_scope();
3175 p.on_repeat_item_end(i, context.writer)?;
3176 }
3177 }
3178 match saved_value {
3180 Some(value) => {
3181 context.local_vars.insert(item_name.to_string(), value);
3182 }
3183 None => {
3184 context.local_vars.remove(item_name);
3185 }
3186 }
3187 if let Some(value) = saved_borrowed_value {
3188 context.local_borrowed_vars.insert(item_name, value);
3189 }
3190
3191 if let Some(p) = &mut context.plugin {
3192 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3193 }
3194
3195 Ok(())
3196 }
3197
3198 #[inline]
3204 fn process_signal<'data>(
3205 &self,
3206 signal: &'data webui_protocol::WebUIFragmentSignal,
3207 context: &mut WebUIProcessContext<'data, '_, '_>,
3208 ) -> Result<()> {
3209 if signal.raw {
3210 self.process_raw_signal(signal, context)
3211 } else {
3212 self.process_state_signal(signal, false, context)
3213 }
3214 }
3215
3216 #[inline(never)]
3217 fn process_raw_signal<'data>(
3218 &self,
3219 signal: &'data webui_protocol::WebUIFragmentSignal,
3220 context: &mut WebUIProcessContext<'data, '_, '_>,
3221 ) -> Result<()> {
3222 let Some(structural_value) = structural_signal_value(signal) else {
3223 return self.process_state_signal(signal, !signal.raw_text_context, context);
3224 };
3225
3226 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3227 return self.process_shadow_style_signal(root, context);
3228 }
3229
3230 if context.streaming.is_some()
3231 && self.process_streaming_signal(structural_value, context)?
3232 {
3233 return Ok(());
3234 }
3235
3236 if structural_value == "head_end" && !context.head_end_emitted {
3240 context.head_end_emitted = true;
3241 if let Some(nonce) = context.nonce {
3242 context
3243 .writer
3244 .write("<meta name=\"webui-nonce\" content=\"")?;
3245 context
3246 .writer
3247 .write(&crate::html_encode::encode_safe(nonce))?;
3248 context.writer.write("\">")?;
3249 }
3250
3251 if !context.protocol.component_render_css.is_empty() {
3254 context.writer.write("<style data-webui-render-policy")?;
3255 if let Some(nonce) = context.nonce {
3256 context.writer.write(" nonce=\"")?;
3257 context
3258 .writer
3259 .write(&crate::html_encode::encode_safe(nonce))?;
3260 context.writer.write("\"")?;
3261 }
3262 context.writer.write(">")?;
3263 context
3264 .writer
3265 .write(&context.protocol.component_render_css)?;
3266 context.writer.write("</style>")?;
3267 }
3268
3269 if !context.protocol.style_closures.is_empty() {
3270 let entry_id = context.entry_id;
3271 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3272 let mut preloaded = Vec::new();
3273 self.emit_active_route_styles(&mut preloaded, context)?;
3274 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3275 }
3276
3277 if !context.protocol.module_preloads.is_empty() {
3289 for href in &context.protocol.module_preloads {
3290 context
3291 .writer
3292 .write("<link rel=\"modulepreload\" href=\"")?;
3293 context.writer.write(href)?;
3294 context.writer.write("\">")?;
3295 }
3296 }
3297
3298 write_component_asset_styles(context)?;
3299
3300 if let Some(html) = context.head_inject {
3306 context.writer.write(html)?;
3307 }
3308
3309 if let Some(html) = context.state_inject.head_end {
3313 context.writer.write(html)?;
3314 }
3315 }
3316
3317 if structural_value == "body_start" && !context.body_start_emitted {
3320 context.body_start_emitted = true;
3321 write_component_asset_styles(context)?;
3322 if let Some(html) = context.state_inject.body_start {
3323 context.writer.write(html)?;
3324 }
3325 }
3326
3327 if structural_value == "body_end" && !context.body_end_emitted {
3332 context.body_end_emitted = true;
3333 if context.plugin.is_some() {
3334 let reachable = context
3342 .reachable_components
3343 .take()
3344 .unwrap_or_else(|| {
3345 crate::route_handler::collect_reachable_component_order_for_request(
3346 context.protocol,
3347 context.entry_id,
3348 context.request_path,
3349 context.route_index,
3350 )
3351 })
3352 .into_iter()
3353 .collect::<HashSet<_>>();
3354 let state_selection =
3355 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3356
3357 if context.css_strategy == webui_protocol::CssStrategy::Module {
3361 for name in &reachable {
3362 if !context.rendered_components.contains(name) {
3363 if let Some(css) = context
3364 .protocol
3365 .components
3366 .get(name)
3367 .map(|c| c.css.as_str())
3368 .filter(|s| !s.is_empty())
3369 {
3370 self.emit_css_module_importmap(name, css, context)?;
3371 }
3372 }
3373 }
3374 }
3375
3376 let template_payloads = context
3380 .plugin
3381 .as_ref()
3382 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3383
3384 if template_payloads.is_none() {
3385 if let Some(ref p) = context.plugin {
3387 p.emit_templates(
3388 context.protocol,
3389 &reachable,
3390 context.nonce,
3391 context.writer,
3392 )?;
3393 }
3394 }
3395
3396 let inventory_hex = crate::route_handler::encode_component_inventory(
3398 &context.rendered_components,
3399 context.component_index,
3400 );
3401
3402 Self::ensure_request_route_chain(context);
3404 let chain = context.route_chain.as_deref().ok_or_else(|| {
3405 HandlerError::Invariant(
3406 "request route chain disappeared after collection".to_string(),
3407 )
3408 })?;
3409 let chain_json: Vec<Value> = chain
3410 .iter()
3411 .map(crate::route_handler::RouteChainEntry::to_json)
3412 .collect();
3413
3414 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3416 let mut css_hrefs: Vec<&str> = Vec::new();
3417 if is_link {
3418 for name in &reachable {
3419 if let Some(href) = context
3420 .protocol
3421 .components
3422 .get(name)
3423 .map(|c| c.css_href.as_str())
3424 .filter(|h| !h.is_empty())
3425 {
3426 css_hrefs.push(href);
3427 }
3428 }
3429 }
3430
3431 let mut style_specs: Vec<&str> = Vec::new();
3433 if context.css_strategy == webui_protocol::CssStrategy::Module {
3434 for name in &reachable {
3435 if context
3436 .protocol
3437 .components
3438 .get(name)
3439 .map(|c| !c.css.is_empty())
3440 .unwrap_or(false)
3441 {
3442 style_specs.push(name);
3443 }
3444 }
3445 }
3446
3447 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3448 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3449 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3450 style_roots.push(context.entry_id);
3451 for entry in chain {
3452 style_roots.push(entry.component.as_str());
3453 if !entry.pending_component.is_empty() {
3454 style_roots.push(entry.pending_component.as_str());
3455 }
3456 if !entry.error_component.is_empty() {
3457 style_roots.push(entry.error_component.as_str());
3458 }
3459 }
3460 style_roots.extend(reachable.iter().map(String::as_str));
3461 let component_styles =
3462 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3463 write_webui_data_block(
3464 context.writer,
3465 &mut context.json_scratch,
3466 WebUiBootstrap {
3467 declaration_id: None,
3468 enclosing_span_instance_id: None,
3469 state: WebUiBootstrapState::Complete {
3470 value: context.state,
3471 selection: state_selection,
3472 },
3473 chain: &chain_json,
3474 inventory: &inventory_hex,
3475 nonce: context.nonce,
3476 css_hrefs: &css_hrefs,
3477 style_specs: &style_specs,
3478 component_styles: &component_styles,
3479 templates: payloads,
3480 },
3481 )?;
3482
3483 if let Some(ref plugin) = context.plugin {
3487 plugin.emit_bootstrap_extension(
3488 BootstrapExtensionContext {
3489 protocol: context.protocol,
3490 components: &reachable,
3491 payloads,
3492 nonce: context.nonce,
3493 },
3494 context.writer,
3495 )?;
3496 }
3497 }
3498
3499 if let Some(html) = context.body_inject {
3506 context.writer.write(html)?;
3507 }
3508
3509 if let Some(html) = context.state_inject.body_end {
3512 context.writer.write(html)?;
3513 }
3514 }
3515
3516 Ok(())
3520 }
3521
3522 #[inline]
3534 fn process_state_signal(
3535 &self,
3536 signal: &webui_protocol::WebUIFragmentSignal,
3537 owns_html_range: bool,
3538 context: &mut WebUIProcessContext,
3539 ) -> Result<()> {
3540 if let Some(p) = &mut context.plugin {
3541 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3542 }
3543
3544 if let Some(value) = resolve_value_from_sources(
3545 &signal.value,
3546 &context.loop_vars,
3547 context.visible_loop_scope,
3548 LocalValueSources {
3549 owned: &context.local_vars,
3550 borrowed: &context.local_borrowed_vars,
3551 },
3552 context.state,
3553 ) {
3554 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3555 }
3556
3557 if let Some(p) = &mut context.plugin {
3558 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3559 }
3560 Ok(())
3561 }
3562
3563 fn write_signal_value(
3572 &self,
3573 value: &Value,
3574 raw: bool,
3575 writer: &mut dyn ResponseWriter,
3576 ) -> Result<()> {
3577 if raw {
3578 match value {
3579 Value::String(s) => writer.write(s),
3580 _ => writer.write(&value.to_string()),
3581 }
3582 } else {
3583 match value {
3584 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3585 _ => {
3586 let s = value.to_string();
3587 writer.write(&crate::html_encode::encode_safe(&s))
3588 }
3589 }
3590 }
3591 }
3592
3593 fn process_if(
3595 &self,
3596 if_cond: &webui_protocol::WebUIFragmentIf,
3597 target: Option<usize>,
3598 context: &mut WebUIProcessContext,
3599 ) -> Result<()> {
3600 let condition = if_cond
3601 .condition
3602 .as_ref()
3603 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3604 let condition_met = self.evaluate_condition(condition, context)?;
3605
3606 if let Some(p) = &mut context.plugin {
3607 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3608 }
3609
3610 if condition_met {
3611 if let Some(p) = &mut context.plugin {
3612 p.push_scope();
3613 }
3614
3615 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3616
3617 if let Some(p) = &mut context.plugin {
3618 p.pop_scope();
3619 }
3620 }
3621
3622 if let Some(p) = &mut context.plugin {
3623 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3624 }
3625
3626 Ok(())
3627 }
3628
3629 fn process_attribute<'protocol, 'state>(
3635 &self,
3636 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3637 template_target: Option<usize>,
3638 component_name: Option<&'protocol str>,
3639 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3640 ) -> Result<()> {
3641 if attr.attr_start {
3644 context.component_attrs.clear();
3645 context.component_borrowed_attrs.clear();
3646 context.collecting_component_attrs = true;
3647 }
3648
3649 if let Some(condition) = &attr.condition_tree {
3651 let condition_met = self.evaluate_condition(condition, context)?;
3652
3653 if context.collecting_component_attrs && !attr.attr_skip {
3654 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3655 context.component_borrowed_attrs.remove(name);
3656 context
3657 .component_attrs
3658 .insert(name.to_owned(), Value::Bool(condition_met));
3659 }
3660
3661 if condition_met {
3662 context.writer.write(" ")?;
3663 context.writer.write(&attr.name)?;
3664 }
3665 return Ok(());
3666 }
3667
3668 if !attr.template.is_empty() {
3670 let raw_value =
3671 self.render_template_attr_value(&attr.template, template_target, context)?;
3672 let escaped = crate::html_encode::encode_safe(&raw_value);
3673 write_attr(context.writer, &attr.name, &escaped)?;
3674
3675 if context.collecting_component_attrs && !attr.attr_skip {
3676 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3677 context.component_borrowed_attrs.remove(name);
3678 context
3679 .component_attrs
3680 .insert(name.to_owned(), Value::String(raw_value));
3681 }
3682 return Ok(());
3683 }
3684
3685 if !attr.value.is_empty() {
3687 if attr.raw_value {
3688 write_attr(context.writer, &attr.name, &attr.value)?;
3690 if context.collecting_component_attrs && !attr.attr_skip {
3691 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3692 context.component_borrowed_attrs.remove(name);
3693 context
3694 .component_attrs
3695 .insert(name.to_owned(), Value::String(attr.value.clone()));
3696 }
3697 } else if attr.complex {
3698 if context.collecting_component_attrs && !attr.attr_skip {
3700 let state_backed_value = resolve_state_backed_value(
3705 &attr.value,
3706 &context.loop_vars,
3707 context.visible_loop_scope,
3708 LocalValueSources {
3709 owned: &context.local_vars,
3710 borrowed: &context.local_borrowed_vars,
3711 },
3712 context.state,
3713 );
3714 if let Some(value) = state_backed_value {
3715 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3716 context.component_attrs.remove(name);
3717 context.component_borrowed_attrs.insert(name, value);
3718 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3719 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3720 context.component_borrowed_attrs.remove(name);
3721 context.component_attrs.insert(name.to_owned(), value);
3722 }
3723 }
3724 } else {
3725 let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3729 resolve_state_backed_value(
3730 &attr.value,
3731 &context.loop_vars,
3732 context.visible_loop_scope,
3733 LocalValueSources {
3734 owned: &context.local_vars,
3735 borrowed: &context.local_borrowed_vars,
3736 },
3737 context.state,
3738 )
3739 } else {
3740 None
3741 };
3742 let value = match state_backed_value {
3745 Some(value) => Some(Cow::Borrowed(value)),
3746 None => resolve_value_from_sources(
3747 &attr.value,
3748 &context.loop_vars,
3749 context.visible_loop_scope,
3750 LocalValueSources {
3751 owned: &context.local_vars,
3752 borrowed: &context.local_borrowed_vars,
3753 },
3754 context.state,
3755 ),
3756 };
3757 match value.as_deref() {
3760 Some(Value::String(s)) => {
3761 write_attr(
3762 context.writer,
3763 &attr.name,
3764 &crate::html_encode::encode_safe(s),
3765 )?;
3766 }
3767 Some(Value::Null) | None => {
3768 write_attr(context.writer, &attr.name, "")?;
3769 }
3770 Some(other) => {
3771 let s = other.to_string();
3772 write_attr(
3773 context.writer,
3774 &attr.name,
3775 &crate::html_encode::encode_safe(&s),
3776 )?;
3777 }
3778 }
3779
3780 if context.collecting_component_attrs && !attr.attr_skip {
3781 if let Some(borrowed) = state_backed_value {
3782 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3783 context.component_attrs.remove(name);
3784 context.component_borrowed_attrs.insert(name, borrowed);
3785 } else {
3786 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3787 context.component_borrowed_attrs.remove(name);
3788 context.component_attrs.insert(
3789 name.to_owned(),
3790 value
3791 .map(Cow::into_owned)
3792 .unwrap_or(Value::String(String::new())),
3793 );
3794 }
3795 }
3796 }
3797 }
3798
3799 Ok(())
3800 }
3801
3802 fn render_template_attr_value(
3804 &self,
3805 template_id: &str,
3806 target: Option<usize>,
3807 context: &WebUIProcessContext,
3808 ) -> Result<String> {
3809 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3810 return Err(HandlerError::MissingFragment(template_id.to_string()));
3811 };
3812 let fragments = context
3813 .render_fragments
3814 .list(index)
3815 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3816 let mut raw_value = String::new();
3817 for frag in fragments.fragments {
3818 match frag.fragment.as_ref() {
3819 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3820 Some(Fragment::Signal(signal)) => {
3821 if let Some(value) = resolve_value_from_sources(
3822 &signal.value,
3823 &context.loop_vars,
3824 context.visible_loop_scope,
3825 LocalValueSources {
3826 owned: &context.local_vars,
3827 borrowed: &context.local_borrowed_vars,
3828 },
3829 context.state,
3830 ) {
3831 match value.as_ref() {
3832 Value::String(s) => raw_value.push_str(s),
3833 _ => raw_value.push_str(&value.to_string()),
3834 }
3835 }
3836 }
3837 _ => {}
3838 }
3839 }
3840 Ok(raw_value)
3841 }
3842
3843 pub fn render<'a>(
3845 &self,
3846 protocol: &'a Protocol,
3847 state: &'a Value,
3848 options: &RenderOptions<'a>,
3849 writer: &'a mut dyn ResponseWriter,
3850 ) -> Result<()> {
3851 protocol.ensure_style_metadata()?;
3852 let document = protocol.protocol();
3853 let render_fragments = protocol.render_fragments().resolve(document);
3854 let Some(entry_index) = render_fragments.index(options.entry_id) else {
3855 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3856 };
3857 let entry = render_fragments
3858 .list(entry_index)
3859 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3860 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3861 let has_document_head_boundary = !entry_owns_css_tree
3862 && !document.style_closures.is_empty()
3863 && entry.fragments.iter().any(|fragment| {
3864 matches!(
3865 fragment.fragment.as_ref(),
3866 Some(Fragment::Signal(signal))
3867 if structural_signal_value(signal) == Some("head_end")
3868 )
3869 });
3870 let doctype_split = (!entry_owns_css_tree
3871 && !has_document_head_boundary
3872 && !document.style_closures.is_empty())
3873 .then(|| {
3874 let Some(Fragment::Raw(raw)) = entry
3875 .fragments
3876 .first()
3877 .and_then(|fragment| fragment.fragment.as_ref())
3878 else {
3879 return None;
3880 };
3881 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3882 })
3883 .flatten();
3884 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3885 let mut context = WebUIProcessContext {
3886 protocol: document,
3887 render_fragments,
3888 component_asset_style_manifest,
3889 component_asset_style_links: protocol.component_asset_style_links(),
3890 state,
3891 writer,
3892 local_vars: HashMap::new(),
3893 local_borrowed_vars: BorrowedScope::default(),
3894 loop_vars: Vec::new(),
3895 visible_loop_scope: VisibleLoopScope::EMPTY,
3896 component_attrs: HashMap::new(),
3897 component_borrowed_attrs: BorrowedScope::default(),
3898 collecting_component_attrs: false,
3899 request_path: options.request_path,
3900 route_base: Cow::Borrowed("/"),
3901 rendered_components: HashSet::new(),
3902 plugin: self.plugin_factory.map(|f| f()),
3903 route_children: Vec::new(),
3904 entry_id: options.entry_id,
3905 nonce: options.nonce.filter(|s| !s.is_empty()),
3908 head_inject: options.head_inject.filter(|s| !s.is_empty()),
3909 body_inject: options.body_inject.filter(|s| !s.is_empty()),
3910 state_inject: StateInject::resolve(state),
3911 head_end_emitted: false,
3912 body_start_emitted: false,
3913 component_asset_styles_emitted: false,
3914 component_index: protocol.component_index(),
3915 style_resource_index: protocol.style_resource_index(),
3916 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
3917 style_chunk_index: protocol.protocol().style_chunk_index(),
3918 css_strategy: protocol.css_strategy(),
3919 body_end_emitted: false,
3920 route_index: protocol.route_index(),
3921 route_chain_index: 0,
3922 route_chain: None,
3923 route_document_style_targets: Vec::new(),
3924 reachable_components: None,
3925 streaming: None,
3926 json_scratch: Vec::new(),
3927 scope_pool: Vec::new(),
3928 document_style_resources: HashSet::new(),
3929 shadow_style_roots: Vec::new(),
3930 borrowed_scope_pool: Vec::new(),
3931 };
3932
3933 if entry_owns_css_tree {
3934 Self::push_shadow_style_root(options.entry_id, &mut context)?;
3935 }
3936
3937 let render_result = if let Some((first_raw, split)) = doctype_split {
3938 context.writer.write(&first_raw[..split])?;
3939 self.emit_component_style_closure(
3940 options.entry_id,
3941 StyleClosureInstall::Static,
3942 &mut context,
3943 )?;
3944 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3945 context.writer.write(&first_raw[split..])?;
3946 self.process_fragment_from(entry, 1, &mut context)
3947 } else {
3948 if !entry_owns_css_tree
3949 && !has_document_head_boundary
3950 && !document.style_closures.is_empty()
3951 {
3952 self.emit_component_style_closure(
3953 options.entry_id,
3954 StyleClosureInstall::Static,
3955 &mut context,
3956 )?;
3957 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3958 }
3959 self.process_fragment(entry, &mut context)
3960 };
3961
3962 if entry_owns_css_tree {
3963 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3964 }
3965 render_result?;
3966 writer.end()?;
3967
3968 Ok(())
3969 }
3970}
3971
3972impl Default for WebUIHandler {
3973 fn default() -> Self {
3974 Self::new()
3975 }
3976}
3977
3978fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3980 writer.write_attribute(name, value)
3981}
3982
3983#[cfg(test)]
3984fn handle(
3985 protocol: &WebUIProtocol,
3986 state: &Value,
3987 options: &RenderOptions<'_>,
3988 writer: &mut dyn ResponseWriter,
3989) -> Result<()> {
3990 let handler = WebUIHandler::new();
3991 handler.handle(protocol, state, options, writer)
3992}
3993
3994#[cfg(test)]
3995mod tests {
3996 use super::*;
3997 use crate::streaming::STREAMING_MARKER;
3998 use std::cell::RefCell;
3999 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4000 use webui_protocol::{
4001 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4002 WebUIFragmentAttribute, WebUiFragmentRoute,
4003 };
4004 use webui_test_utils::test_json;
4005
4006 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4007 WebUIFragment::signal(
4008 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4009 true,
4010 )
4011 }
4012
4013 #[test]
4014 fn borrowed_collection_reuses_state_array() {
4015 let state = test_json!({
4016 "items": [
4017 {"name": "first"},
4018 {"name": "second"}
4019 ]
4020 });
4021 let local_vars = HashMap::new();
4022 let local_borrowed_vars = BorrowedScope::default();
4023 let loop_vars = Vec::new();
4024 let items = resolve_borrowed_collection(
4025 "items",
4026 &loop_vars,
4027 VisibleLoopScope::EMPTY,
4028 LocalValueSources {
4029 owned: &local_vars,
4030 borrowed: &local_borrowed_vars,
4031 },
4032 &state,
4033 )
4034 .unwrap_or_else(|| panic!("state array should be borrowed"));
4035 let state_items = state["items"]
4036 .as_array()
4037 .unwrap_or_else(|| panic!("fixture items should be an array"));
4038 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4039 }
4040
4041 #[test]
4042 fn owned_local_collection_keeps_precedence() {
4043 let state = test_json!({"items": [{"name": "global"}]});
4044 let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
4045 let local_borrowed_vars = BorrowedScope::default();
4046 assert!(resolve_borrowed_collection(
4047 "items",
4048 &[],
4049 VisibleLoopScope::EMPTY,
4050 LocalValueSources {
4051 owned: &local_vars,
4052 borrowed: &local_borrowed_vars,
4053 },
4054 &state,
4055 )
4056 .is_none());
4057 }
4058
4059 #[test]
4060 fn borrowed_component_collection_reuses_state_array() {
4061 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4062 let contacts = &state["teams"][0]["contacts"];
4063 let mut local_borrowed_vars = BorrowedScope::default();
4064 local_borrowed_vars.insert("contacts", contacts);
4065 let local_vars = HashMap::new();
4066 let items = resolve_borrowed_collection(
4067 "contacts",
4068 &[],
4069 VisibleLoopScope::EMPTY,
4070 LocalValueSources {
4071 owned: &local_vars,
4072 borrowed: &local_borrowed_vars,
4073 },
4074 &state,
4075 )
4076 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4077 let state_items = contacts
4078 .as_array()
4079 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4080 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4081 }
4082
4083 #[test]
4084 fn nested_borrowed_collection_reuses_child_array() {
4085 let state = test_json!({
4086 "items": [{
4087 "children": [
4088 {"name": "first"},
4089 {"name": "second"}
4090 ]
4091 }]
4092 });
4093 let item = &state["items"][0];
4094 let loop_vars = vec![LoopBinding {
4095 name: "item",
4096 value: item,
4097 }];
4098 let local_vars = HashMap::new();
4099 let local_borrowed_vars = BorrowedScope::default();
4100 let children = resolve_borrowed_collection(
4101 "item.children",
4102 &loop_vars,
4103 VisibleLoopScope { start: 0, end: 1 },
4104 LocalValueSources {
4105 owned: &local_vars,
4106 borrowed: &local_borrowed_vars,
4107 },
4108 &state,
4109 )
4110 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4111 let state_children = item["children"]
4112 .as_array()
4113 .unwrap_or_else(|| panic!("fixture children should be an array"));
4114 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4115 }
4116
4117 #[test]
4118 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4119 let state = test_json!({
4120 "item": {"name": "global", "fallback": "global fallback"},
4121 "outer": {"name": "outer"},
4122 "inner": {"name": "inner"}
4123 });
4124 let loop_vars = vec![
4125 LoopBinding {
4126 name: "item",
4127 value: &state["outer"],
4128 },
4129 LoopBinding {
4130 name: "item",
4131 value: &state["inner"],
4132 },
4133 ];
4134 let local_vars = HashMap::new();
4135 let local_borrowed_vars = BorrowedScope::default();
4136 let sources = LocalValueSources {
4137 owned: &local_vars,
4138 borrowed: &local_borrowed_vars,
4139 };
4140
4141 assert_eq!(
4142 resolve_value_from_sources(
4143 "item.name",
4144 &loop_vars,
4145 VisibleLoopScope { start: 0, end: 2 },
4146 sources,
4147 &state,
4148 )
4149 .as_deref()
4150 .and_then(Value::as_str),
4151 Some("inner")
4152 );
4153 assert_eq!(
4154 resolve_value_from_sources(
4155 "item.fallback",
4156 &loop_vars,
4157 VisibleLoopScope { start: 0, end: 2 },
4158 sources,
4159 &state,
4160 )
4161 .as_deref()
4162 .and_then(Value::as_str),
4163 Some("global fallback")
4164 );
4165 assert_eq!(
4166 resolve_value_from_sources(
4167 "item.name",
4168 &loop_vars,
4169 VisibleLoopScope { start: 2, end: 2 },
4170 sources,
4171 &state,
4172 )
4173 .as_deref()
4174 .and_then(Value::as_str),
4175 Some("global")
4176 );
4177 }
4178
4179 #[test]
4180 fn state_backed_resolver_matches_general_borrowed_resolution() {
4181 let state = test_json!({
4182 "global": {"name": "global"},
4183 "inner": {"fallback": "global fallback"},
4184 "borrowed": {"name": "borrowed"},
4185 "items": [1, 2, 3]
4186 });
4187 let loop_values = test_json!({
4188 "outer": {"name": "outer"},
4189 "inner": {"name": "inner"}
4190 });
4191 let loop_vars = vec![
4192 LoopBinding {
4193 name: "outer",
4194 value: &loop_values["outer"],
4195 },
4196 LoopBinding {
4197 name: "inner",
4198 value: &loop_values["inner"],
4199 },
4200 ];
4201 let mut local_borrowed_vars = BorrowedScope::default();
4202 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4203 let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4204 let sources = LocalValueSources {
4205 owned: &local_vars,
4206 borrowed: &local_borrowed_vars,
4207 };
4208 let scope = VisibleLoopScope { start: 0, end: 2 };
4209
4210 for path in [
4211 "global.name",
4212 "inner.name",
4213 "inner.fallback",
4214 "outer.name",
4215 "borrowed.name",
4216 ] {
4217 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4218 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4219 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4220 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4221 let Cow::Borrowed(resolved) = resolved else {
4222 panic!("{path} should remain borrowed");
4223 };
4224 assert!(
4225 std::ptr::eq(state_backed, resolved),
4226 "{path} resolved from different sources"
4227 );
4228 }
4229
4230 assert!(
4231 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4232 "owned local values must use the general resolver"
4233 );
4234 assert!(
4235 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4236 .is_none(),
4237 "synthetic values must use the general resolver"
4238 );
4239 }
4240
4241 #[test]
4242 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4243 let values = [
4244 Value::String("a".to_string()),
4245 Value::String("b".to_string()),
4246 Value::String("c".to_string()),
4247 Value::String("d".to_string()),
4248 Value::String("e".to_string()),
4249 Value::String("f".to_string()),
4250 Value::String("replacement".to_string()),
4251 ];
4252 let mut scope = BorrowedScope::default();
4253 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4254 assert!(scope.insert(name, &values[index]).is_none());
4255 }
4256 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4257 assert_eq!(scope.overflow.len(), 2);
4258 assert_eq!(scope.get("a"), Some(&values[0]));
4259 assert_eq!(scope.get("f"), Some(&values[5]));
4260 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4261 assert_eq!(scope.get("a"), Some(&values[6]));
4262 assert_eq!(scope.remove("b"), Some(&values[1]));
4263 assert!(scope.get("b").is_none());
4264 assert_eq!(scope.get("f"), Some(&values[5]));
4265 scope.clear();
4266 assert!(scope.get("a").is_none());
4267 assert!(scope.overflow.is_empty());
4268 }
4269
4270 struct TestWriter {
4272 content: RefCell<String>,
4273 ended: RefCell<bool>,
4274 }
4275
4276 impl TestWriter {
4277 fn new() -> Self {
4278 Self {
4279 content: RefCell::new(String::new()),
4280 ended: RefCell::new(false),
4281 }
4282 }
4283
4284 fn get_content(&self) -> String {
4285 self.content.borrow().clone()
4286 }
4287
4288 fn is_ended(&self) -> bool {
4289 *self.ended.borrow()
4290 }
4291 }
4292
4293 impl ResponseWriter for TestWriter {
4294 fn write(&mut self, content: &str) -> Result<()> {
4295 self.content.borrow_mut().push_str(content);
4296 Ok(())
4297 }
4298
4299 fn end(&mut self) -> Result<()> {
4300 *self.ended.borrow_mut() = true;
4301 Ok(())
4302 }
4303 }
4304
4305 #[test]
4306 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4307 struct AttributeWriter {
4308 output: String,
4309 }
4310
4311 impl ResponseWriter for AttributeWriter {
4312 fn write(&mut self, content: &str) -> Result<()> {
4313 self.output.push_str(content);
4314 Ok(())
4315 }
4316
4317 crate::string_response_writer_methods!(output);
4318
4319 fn end(&mut self) -> Result<()> {
4320 Ok(())
4321 }
4322 }
4323
4324 let mut writer = AttributeWriter {
4325 output: String::new(),
4326 };
4327 write_attr(&mut writer, "data-id", "42")
4328 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4329 writer
4330 .write_boolean_attribute("disabled")
4331 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4332 assert_eq!(writer.output, " data-id=\"42\" disabled");
4333 }
4334
4335 #[test]
4336 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4337 let mut fragments = HashMap::new();
4338 fragments.insert(
4339 "index.html".to_string(),
4340 FragmentList {
4341 fragments: vec![
4342 WebUIFragment {
4343 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4344 name: ":data-title".to_string(),
4345 value: "source".to_string(),
4346 complex: true,
4347 attr_start: true,
4348 ..Default::default()
4349 })),
4350 },
4351 WebUIFragment::component("prepared-child"),
4352 ],
4353 contains_boundary: false,
4354 },
4355 );
4356 fragments.insert(
4357 "prepared-child".to_string(),
4358 FragmentList {
4359 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4360 contains_boundary: false,
4361 },
4362 );
4363 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4364 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4365 let entry = resolved
4366 .list_by_id("index.html")
4367 .unwrap_or_else(|| panic!("entry render plan should exist"));
4368 let child_index = protocol
4369 .render_fragments()
4370 .index("prepared-child")
4371 .unwrap_or_else(|| panic!("component target should be indexed"));
4372 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4373 assert_eq!(entry.target(1), Some(child_index));
4374 assert!(!entry.has_routes);
4375 let source_entry = protocol
4376 .protocol()
4377 .fragments
4378 .get("index.html")
4379 .unwrap_or_else(|| panic!("source entry should exist"));
4380 assert!(std::ptr::eq(
4381 entry.fragments.as_ptr(),
4382 source_entry.fragments.as_ptr()
4383 ));
4384
4385 let state = test_json!({"source": "Prepared"});
4386 let options = RenderOptions::new("index.html", "/");
4387 for handler in [
4388 WebUIHandler::new(),
4389 WebUIHandler::with_plugin(|| {
4390 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4391 }),
4392 ] {
4393 let mut writer = TestWriter::new();
4394 assert!(handler
4395 .render(&protocol, &state, &options, &mut writer)
4396 .is_ok());
4397 assert!(writer.get_content().contains("Prepared"));
4398 }
4399 }
4400
4401 #[test]
4402 fn prepared_graph_records_route_presence_per_fragment_list() {
4403 let mut fragments = HashMap::new();
4404 fragments.insert(
4405 "index.html".to_string(),
4406 FragmentList {
4407 fragments: vec![WebUIFragment::route("/", "dash-page")],
4408 contains_boundary: false,
4409 },
4410 );
4411 fragments.insert(
4412 "dash-page".to_string(),
4413 FragmentList {
4414 fragments: vec![WebUIFragment::raw("dashboard")],
4415 contains_boundary: false,
4416 },
4417 );
4418 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4419 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4420 let entry = resolved
4421 .list_by_id("index.html")
4422 .unwrap_or_else(|| panic!("entry render plan should exist"));
4423 let page = resolved
4424 .list_by_id("dash-page")
4425 .unwrap_or_else(|| panic!("page render plan should exist"));
4426 assert!(entry.has_routes);
4428 assert!(!page.has_routes);
4429 }
4430
4431 #[test]
4432 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4433 let names = [
4437 "data-title",
4438 ":data-title",
4439 "aria-labelledby",
4440 "aria-valuemax",
4441 "readonly",
4442 "tabindex",
4443 "class",
4444 "for",
4445 "maxlength",
4446 "novalidate",
4447 "already-camel",
4448 "trailing-",
4449 "-leading",
4450 "multi-part-attribute-name",
4451 "single",
4452 "",
4453 ];
4454 for name in names {
4455 let stripped = name.strip_prefix(':').unwrap_or(name);
4456 assert_eq!(
4457 component_attr_name(name),
4458 webui_protocol::attrs::attribute_to_camel(stripped),
4459 "prepared name diverged for `{name}`"
4460 );
4461 }
4462 }
4463
4464 #[test]
4465 fn prepared_component_prop_names_match_per_branch_stripping() {
4466 let complex = WebUIFragmentAttribute {
4470 name: "::data-title".to_string(),
4471 value: "source".to_string(),
4472 complex: true,
4473 ..Default::default()
4474 };
4475 assert_eq!(component_attr_source(&complex), ":data-title");
4476 assert_eq!(
4477 component_attr_name(component_attr_source(&complex)),
4478 "dataTitle"
4479 );
4480
4481 let raw = WebUIFragmentAttribute {
4483 raw_value: true,
4484 ..complex.clone()
4485 };
4486 assert_eq!(component_attr_source(&raw), "::data-title");
4487
4488 let templated = WebUIFragmentAttribute {
4490 template: "tpl".to_string(),
4491 ..complex.clone()
4492 };
4493 assert_eq!(component_attr_source(&templated), "::data-title");
4494
4495 let empty_value = WebUIFragmentAttribute {
4497 value: String::new(),
4498 ..complex.clone()
4499 };
4500 assert_eq!(component_attr_source(&empty_value), "::data-title");
4501
4502 let ordinary = WebUIFragmentAttribute {
4504 name: ":data-title".to_string(),
4505 ..complex
4506 };
4507 assert_eq!(component_attr_source(&ordinary), "data-title");
4508 assert_eq!(
4509 component_attr_name(component_attr_source(&ordinary)),
4510 "dataTitle"
4511 );
4512 }
4513
4514 #[test]
4515 fn render_slots_match_continuation_slots() {
4516 let mut fragments = HashMap::new();
4521 for id in [
4522 "index.html",
4523 "zeta-last",
4524 "alpha-first",
4525 "middle-one",
4526 "middle-two",
4527 ] {
4528 fragments.insert(
4529 id.to_string(),
4530 FragmentList {
4531 fragments: vec![WebUIFragment::raw(id)],
4532 contains_boundary: false,
4533 },
4534 );
4535 }
4536 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4537 let index = protocol.render_fragments();
4538 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4539 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4540 {
4541 let id = protocol
4542 .fragment_id(slot)
4543 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4544 assert_eq!(
4545 index.index(id),
4546 Some(slot as usize),
4547 "render slot must equal continuation slot for `{id}`"
4548 );
4549 assert_eq!(protocol.fragment_slot(id), Some(slot));
4550 }
4551 }
4552
4553 #[test]
4554 fn prepared_graph_reports_missing_fragments_without_panicking() {
4555 let mut fragments = HashMap::new();
4556 fragments.insert(
4557 "index.html".to_string(),
4558 FragmentList {
4559 fragments: vec![WebUIFragment::component("absent-child")],
4560 contains_boundary: false,
4561 },
4562 );
4563 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4564 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4565 let entry = resolved
4566 .list_by_id("index.html")
4567 .unwrap_or_else(|| panic!("entry render plan should exist"));
4568 assert_eq!(entry.target(0), None);
4571 assert!(resolved.list_by_id("absent-child").is_none());
4572 assert!(resolved.list(usize::MAX).is_none());
4573
4574 let state = test_json!({});
4575 let options = RenderOptions::new("index.html", "/");
4576 let mut writer = TestWriter::new();
4577 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4578 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4579 other => panic!("expected a missing fragment error, got {other:?}"),
4580 }
4581
4582 let mut writer = TestWriter::new();
4583 match WebUIHandler::new().render(
4584 &protocol,
4585 &state,
4586 &RenderOptions::new("absent-entry", "/"),
4587 &mut writer,
4588 ) {
4589 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4590 other => panic!("expected a missing entry error, got {other:?}"),
4591 }
4592 }
4593
4594 #[test]
4595 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4596 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4597 let mut fragments = HashMap::with_capacity(list_count);
4598 for index in 0..list_count {
4599 fragments.insert(
4600 format!("fragment-{index}"),
4601 FragmentList {
4602 fragments: vec![WebUIFragment::raw("content")],
4603 contains_boundary: false,
4604 },
4605 );
4606 }
4607 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4608 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4609 assert!(resolved.cache[0].get().is_none());
4611 assert!(resolved.spilled.get().is_none());
4612 assert!(resolved.list(0).is_some());
4613 assert!(resolved.cache[0].get().is_some());
4614 assert!(resolved.spilled.get().is_none());
4616 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4619 let spilled = resolved
4620 .spilled
4621 .get()
4622 .expect("a slot past the inline cache should allocate the spill region");
4623 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4624 assert!(spilled[0].get().is_some());
4625 assert!(resolved.list(list_count - 1).is_some());
4626
4627 for (id, source) in &protocol.protocol().fragments {
4628 let prepared = resolved
4629 .list_by_id(id)
4630 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4631 assert!(std::ptr::eq(
4633 prepared.fragments.as_ptr(),
4634 source.fragments.as_ptr()
4635 ));
4636 }
4637 }
4638
4639 #[test]
4640 fn test_handle_raw() {
4641 let mut fragments = HashMap::new();
4643 fragments.insert(
4644 "index.html".to_string(),
4645 FragmentList {
4646 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4647 contains_boundary: false,
4648 },
4649 );
4650
4651 let protocol = WebUIProtocol::new(fragments);
4652 let state = test_json!({});
4653
4654 let mut writer = TestWriter::new();
4656
4657 assert!(
4659 handle(
4660 &protocol,
4661 &state,
4662 &RenderOptions::new("index.html", "/"),
4663 &mut writer
4664 )
4665 .is_ok(),
4666 "Failed to handle raw protocol"
4667 );
4668
4669 assert_eq!(writer.get_content(), "Hello, WebUI!");
4671 assert!(writer.is_ended());
4672 }
4673
4674 #[test]
4675 fn test_handle_signal() {
4676 let mut fragments = HashMap::new();
4678 fragments.insert(
4679 "index.html".to_string(),
4680 FragmentList {
4681 fragments: vec![
4682 WebUIFragment::raw("Hello, "),
4683 WebUIFragment::signal("name", false),
4684 WebUIFragment::raw("!"),
4685 ],
4686 contains_boundary: false,
4687 },
4688 );
4689
4690 let protocol = WebUIProtocol::new(fragments);
4691 let state = test_json!({"name": "WebUI"});
4692
4693 let mut writer = TestWriter::new();
4695
4696 assert!(
4698 handle(
4699 &protocol,
4700 &state,
4701 &RenderOptions::new("index.html", "/"),
4702 &mut writer
4703 )
4704 .is_ok(),
4705 "Failed to handle signal protocol"
4706 );
4707
4708 assert_eq!(writer.get_content(), "Hello, WebUI!");
4710 assert!(writer.is_ended());
4711 }
4712
4713 #[test]
4714 fn test_handle_for_loop() {
4715 let mut fragments = HashMap::new();
4717 fragments.insert(
4718 "index.html".to_string(),
4719 FragmentList {
4720 fragments: vec![
4721 WebUIFragment::raw("People: "),
4722 WebUIFragment::for_loop("person", "people", "person-item"),
4723 ],
4724 contains_boundary: false,
4725 },
4726 );
4727
4728 fragments.insert(
4729 "person-item".to_string(),
4730 FragmentList {
4731 fragments: vec![
4732 WebUIFragment::signal("person.name", false),
4733 WebUIFragment::raw(", "),
4734 ],
4735 contains_boundary: false,
4736 },
4737 );
4738
4739 let protocol = WebUIProtocol::new(fragments);
4740 let state = test_json!({
4741 "people": [
4742 {"name": "Alice"},
4743 {"name": "Bob"},
4744 {"name": "Charlie"}
4745 ]
4746 });
4747
4748 let mut writer = TestWriter::new();
4750
4751 assert!(
4753 handle(
4754 &protocol,
4755 &state,
4756 &RenderOptions::new("index.html", "/"),
4757 &mut writer
4758 )
4759 .is_ok(),
4760 "Failed to handle for loop protocol"
4761 );
4762
4763 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4765 assert!(writer.is_ended());
4766 }
4767
4768 #[test]
4769 fn test_handle_if_condition() {
4770 let mut fragments = HashMap::new();
4772 fragments.insert(
4773 "index.html".to_string(),
4774 FragmentList {
4775 fragments: vec![
4776 WebUIFragment::raw("Status: "),
4777 WebUIFragment::if_cond(
4778 webui_protocol::ConditionExpr::identifier("isActive"),
4779 "active-content",
4780 ),
4781 WebUIFragment::raw("End"),
4782 ],
4783 contains_boundary: false,
4784 },
4785 );
4786
4787 fragments.insert(
4788 "active-content".to_string(),
4789 FragmentList {
4790 fragments: vec![WebUIFragment::raw("Active")],
4791 contains_boundary: false,
4792 },
4793 );
4794
4795 let protocol = WebUIProtocol::new(fragments);
4796
4797 let state_true = test_json!({"isActive": true});
4799 let mut writer_true = TestWriter::new();
4800 assert!(
4801 handle(
4802 &protocol,
4803 &state_true,
4804 &RenderOptions::new("index.html", "/"),
4805 &mut writer_true
4806 )
4807 .is_ok(),
4808 "Failed to handle if condition (true case)"
4809 );
4810 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4811 assert!(writer_true.is_ended());
4812
4813 let state_false = test_json!({"isActive": false});
4815 let mut writer_false = TestWriter::new();
4816 assert!(
4817 handle(
4818 &protocol,
4819 &state_false,
4820 &RenderOptions::new("index.html", "/"),
4821 &mut writer_false
4822 )
4823 .is_ok(),
4824 "Failed to handle if condition (false case)"
4825 );
4826 assert_eq!(writer_false.get_content(), "Status: End");
4827 assert!(writer_false.is_ended());
4828 }
4829
4830 #[test]
4831 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4832 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>"#;
4833 let mut parser = HtmlParser::new();
4834 parser
4835 .parse("index.html", source)
4836 .expect("parse missing-path condition fixture");
4837 let protocol = WebUIProtocol::new(parser.into_fragment_records());
4838 let state = test_json!({
4839 "items": [
4840 {"label": "Normal"},
4841 {"label": "Search", "searchPresentation": true}
4842 ]
4843 });
4844 let mut writer = TestWriter::new();
4845
4846 handle(
4847 &protocol,
4848 &state,
4849 &RenderOptions::new("index.html", "/"),
4850 &mut writer,
4851 )
4852 .expect("render missing-path condition fixture");
4853
4854 assert_eq!(
4855 writer.get_content(),
4856 "top-negated<span>Normal</span><mark>Search</mark>"
4857 );
4858 }
4859
4860 #[test]
4861 fn native_dynamic_attribute_does_not_leak_into_next_component() {
4862 let mut fragments = HashMap::new();
4863 fragments.insert(
4864 "index.html".to_string(),
4865 FragmentList {
4866 fragments: vec![
4867 WebUIFragment::raw("<div"),
4868 WebUIFragment {
4869 fragment: Some(web_ui_fragment::Fragment::Attribute(
4870 WebUIFragmentAttribute {
4871 name: "title".into(),
4872 value: "nativeTitle".into(),
4873 ..Default::default()
4874 },
4875 )),
4876 },
4877 WebUIFragment::raw("></div><my-comp>"),
4878 WebUIFragment::component("my-comp"),
4879 WebUIFragment::raw("</my-comp>"),
4880 ],
4881 contains_boundary: false,
4882 },
4883 );
4884 fragments.insert(
4885 "my-comp".to_string(),
4886 FragmentList {
4887 fragments: vec![WebUIFragment::signal("title", false)],
4888 contains_boundary: false,
4889 },
4890 );
4891 let protocol = WebUIProtocol::new(fragments);
4892 let state = test_json!({
4893 "nativeTitle": "native",
4894 "title": "global"
4895 });
4896 let mut writer = TestWriter::new();
4897 handle(
4898 &protocol,
4899 &state,
4900 &RenderOptions::new("index.html", "/"),
4901 &mut writer,
4902 )
4903 .unwrap_or_else(|error| panic!("render failed: {error}"));
4904 assert_eq!(
4905 writer.get_content(),
4906 "<div title=\"native\"></div><my-comp>global</my-comp>"
4907 );
4908 }
4909
4910 #[test]
4911 fn test_handle_component() {
4912 let mut fragments = HashMap::new();
4914 fragments.insert(
4915 "index.html".to_string(),
4916 FragmentList {
4917 fragments: vec![
4918 WebUIFragment::raw("Component: "),
4919 WebUIFragment::component("my-component"),
4920 ],
4921 contains_boundary: false,
4922 },
4923 );
4924
4925 fragments.insert(
4926 "my-component".to_string(),
4927 FragmentList {
4928 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
4929 contains_boundary: false,
4930 },
4931 );
4932
4933 let protocol = WebUIProtocol::new(fragments);
4934 let state = test_json!({});
4935
4936 let mut writer = TestWriter::new();
4938
4939 assert!(
4941 handle(
4942 &protocol,
4943 &state,
4944 &RenderOptions::new("index.html", "/"),
4945 &mut writer
4946 )
4947 .is_ok(),
4948 "Failed to handle component protocol"
4949 );
4950
4951 assert_eq!(
4953 writer.get_content(),
4954 "Component: <div>Component Content</div>"
4955 );
4956 assert!(writer.is_ended());
4957 }
4958
4959 #[test]
4960 fn test_missing_fragment() {
4961 let mut fragments = HashMap::new();
4963 fragments.insert(
4964 "index.html".to_string(),
4965 FragmentList {
4966 fragments: vec![WebUIFragment::component("missing-component")],
4967 contains_boundary: false,
4968 },
4969 );
4970
4971 let protocol = WebUIProtocol::new(fragments);
4972 let state = test_json!({});
4973
4974 let mut writer = TestWriter::new();
4976
4977 let result = handle(
4979 &protocol,
4980 &state,
4981 &RenderOptions::new("index.html", "/"),
4982 &mut writer,
4983 );
4984
4985 assert!(result.is_err());
4987 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4988 assert_eq!(fragment_id, "missing-component");
4989 } else {
4990 panic!("Expected MissingFragment error");
4991 }
4992 }
4993
4994 #[test]
4995 fn test_missing_signal_renders_empty() {
4996 let mut fragments = HashMap::new();
4998 fragments.insert(
4999 "index.html".to_string(),
5000 FragmentList {
5001 fragments: vec![
5002 WebUIFragment::raw("Hello, "),
5003 WebUIFragment::signal("missing_field", false),
5004 WebUIFragment::raw("!"),
5005 ],
5006 contains_boundary: false,
5007 },
5008 );
5009
5010 let protocol = WebUIProtocol::new(fragments);
5011 let state = test_json!({});
5012
5013 let mut writer = TestWriter::new();
5014
5015 assert!(
5016 handle(
5017 &protocol,
5018 &state,
5019 &RenderOptions::new("index.html", "/"),
5020 &mut writer
5021 )
5022 .is_ok(),
5023 "Missing signal should not produce an error"
5024 );
5025
5026 assert_eq!(writer.get_content(), "Hello, !");
5027 assert!(writer.is_ended());
5028 }
5029
5030 #[test]
5033 fn test_boolean_attr_true() {
5034 let mut fragments = HashMap::new();
5035 fragments.insert(
5036 "index.html".to_string(),
5037 FragmentList {
5038 fragments: vec![
5039 WebUIFragment::raw("<button"),
5040 WebUIFragment::attribute_boolean(
5041 "disabled",
5042 ConditionExpr::identifier("isDisabled"),
5043 ),
5044 WebUIFragment::raw(">Click</button>"),
5045 ],
5046 contains_boundary: false,
5047 },
5048 );
5049 let protocol = WebUIProtocol::new(fragments);
5050 let state = test_json!({"isDisabled": true});
5051 let mut writer = TestWriter::new();
5052 handle(
5053 &protocol,
5054 &state,
5055 &RenderOptions::new("index.html", "/"),
5056 &mut writer,
5057 )
5058 .unwrap();
5059 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5060 }
5061
5062 #[test]
5063 fn test_boolean_attr_false() {
5064 let mut fragments = HashMap::new();
5065 fragments.insert(
5066 "index.html".to_string(),
5067 FragmentList {
5068 fragments: vec![
5069 WebUIFragment::raw("<button"),
5070 WebUIFragment::attribute_boolean(
5071 "disabled",
5072 ConditionExpr::identifier("isDisabled"),
5073 ),
5074 WebUIFragment::raw(">Click</button>"),
5075 ],
5076 contains_boundary: false,
5077 },
5078 );
5079 let protocol = WebUIProtocol::new(fragments);
5080 let state = test_json!({"isDisabled": false});
5081 let mut writer = TestWriter::new();
5082 handle(
5083 &protocol,
5084 &state,
5085 &RenderOptions::new("index.html", "/"),
5086 &mut writer,
5087 )
5088 .unwrap();
5089 assert_eq!(writer.get_content(), "<button>Click</button>");
5090 }
5091
5092 #[test]
5093 fn test_boolean_attr_missing() {
5094 let mut fragments = HashMap::new();
5095 fragments.insert(
5096 "index.html".to_string(),
5097 FragmentList {
5098 fragments: vec![
5099 WebUIFragment::raw("<input type=\"checkbox\""),
5100 WebUIFragment::attribute_boolean(
5101 "checked",
5102 ConditionExpr::identifier("checked"),
5103 ),
5104 WebUIFragment::raw(">"),
5105 ],
5106 contains_boundary: false,
5107 },
5108 );
5109 let protocol = WebUIProtocol::new(fragments);
5110 let state = test_json!({});
5111 let mut writer = TestWriter::new();
5112 handle(
5113 &protocol,
5114 &state,
5115 &RenderOptions::new("index.html", "/"),
5116 &mut writer,
5117 )
5118 .unwrap();
5119 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5120 }
5121
5122 #[test]
5123 fn test_boolean_attr_multiple() {
5124 let mut fragments = HashMap::new();
5125 fragments.insert(
5126 "index.html".to_string(),
5127 FragmentList {
5128 fragments: vec![
5129 WebUIFragment::raw("<input type=\"checkbox\""),
5130 WebUIFragment::attribute_boolean(
5131 "checked",
5132 ConditionExpr::identifier("checked"),
5133 ),
5134 WebUIFragment::attribute_boolean(
5135 "disabled",
5136 ConditionExpr::identifier("disabled"),
5137 ),
5138 WebUIFragment::raw(">"),
5139 ],
5140 contains_boundary: false,
5141 },
5142 );
5143 let protocol = WebUIProtocol::new(fragments);
5144 let state = test_json!({"checked": true, "disabled": false});
5145 let mut writer = TestWriter::new();
5146 handle(
5147 &protocol,
5148 &state,
5149 &RenderOptions::new("index.html", "/"),
5150 &mut writer,
5151 )
5152 .unwrap();
5153 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5154 }
5155
5156 #[test]
5159 fn test_attribute_with_value() {
5160 let mut fragments = HashMap::new();
5161 fragments.insert(
5162 "index.html".to_string(),
5163 FragmentList {
5164 fragments: vec![
5165 WebUIFragment::raw("<input"),
5166 WebUIFragment::attribute("value", "inputValue"),
5167 WebUIFragment::raw(">"),
5168 ],
5169 contains_boundary: false,
5170 },
5171 );
5172 let protocol = WebUIProtocol::new(fragments);
5173 let state = test_json!({"inputValue": "Hello"});
5174 let mut writer = TestWriter::new();
5175 handle(
5176 &protocol,
5177 &state,
5178 &RenderOptions::new("index.html", "/"),
5179 &mut writer,
5180 )
5181 .unwrap();
5182 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5183 }
5184
5185 #[test]
5186 fn test_attribute_with_falsy_numeric() {
5187 let mut fragments = HashMap::new();
5188 fragments.insert(
5189 "index.html".to_string(),
5190 FragmentList {
5191 fragments: vec![
5192 WebUIFragment::raw("<div name=\"test\""),
5193 WebUIFragment::attribute("handle", "number"),
5194 WebUIFragment::raw("></div>"),
5195 ],
5196 contains_boundary: false,
5197 },
5198 );
5199 let protocol = WebUIProtocol::new(fragments);
5200 let state = test_json!({"number": 0});
5201 let mut writer = TestWriter::new();
5202 handle(
5203 &protocol,
5204 &state,
5205 &RenderOptions::new("index.html", "/"),
5206 &mut writer,
5207 )
5208 .unwrap();
5209 assert_eq!(
5210 writer.get_content(),
5211 "<div name=\"test\" handle=\"0\"></div>"
5212 );
5213 }
5214
5215 #[test]
5218 fn test_attribute_array_value_is_escaped() {
5219 let mut fragments = HashMap::new();
5220 fragments.insert(
5221 "index.html".to_string(),
5222 FragmentList {
5223 fragments: vec![
5224 WebUIFragment::raw("<a"),
5225 WebUIFragment::attribute("href", "value"),
5226 WebUIFragment::raw(">demo</a>"),
5227 ],
5228 contains_boundary: false,
5229 },
5230 );
5231 let protocol = WebUIProtocol::new(fragments);
5232 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5233 let mut writer = TestWriter::new();
5234 handle(
5235 &protocol,
5236 &state,
5237 &RenderOptions::new("index.html", "/"),
5238 &mut writer,
5239 )
5240 .unwrap();
5241 let content = writer.get_content();
5242 assert!(
5245 content.contains("""),
5246 "Double quotes inside attribute value must be escaped: {content}"
5247 );
5248 assert_eq!(
5251 content.matches("=\"").count(),
5252 1,
5253 "Only one attribute assignment expected: {content}"
5254 );
5255 }
5256
5257 #[test]
5258 fn test_attribute_object_value_is_escaped() {
5259 let mut fragments = HashMap::new();
5260 fragments.insert(
5261 "index.html".to_string(),
5262 FragmentList {
5263 fragments: vec![
5264 WebUIFragment::raw("<div"),
5265 WebUIFragment::attribute("data-cfg", "cfg"),
5266 WebUIFragment::raw("></div>"),
5267 ],
5268 contains_boundary: false,
5269 },
5270 );
5271 let protocol = WebUIProtocol::new(fragments);
5272 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5273 let mut writer = TestWriter::new();
5274 handle(
5275 &protocol,
5276 &state,
5277 &RenderOptions::new("index.html", "/"),
5278 &mut writer,
5279 )
5280 .unwrap();
5281 let content = writer.get_content();
5282 assert!(
5283 content.contains("""),
5284 "Double quotes inside attribute value must be escaped: {content}"
5285 );
5286 assert_eq!(
5287 content.matches("=\"").count(),
5288 1,
5289 "Only one attribute assignment expected: {content}"
5290 );
5291 }
5292
5293 #[test]
5296 fn test_mixed_attribute_template() {
5297 let mut fragments = HashMap::new();
5298 fragments.insert(
5299 "index.html".to_string(),
5300 FragmentList {
5301 fragments: vec![
5302 WebUIFragment::raw("<input"),
5303 WebUIFragment::attribute_template("value", "attr-1"),
5304 WebUIFragment::raw(">"),
5305 ],
5306 contains_boundary: false,
5307 },
5308 );
5309 fragments.insert(
5310 "attr-1".to_string(),
5311 FragmentList {
5312 fragments: vec![
5313 WebUIFragment::raw("hello "),
5314 WebUIFragment::signal("item", false),
5315 ],
5316 contains_boundary: false,
5317 },
5318 );
5319 let protocol = WebUIProtocol::new(fragments);
5320 let state = test_json!({"item": "world"});
5321 let mut writer = TestWriter::new();
5322 handle(
5323 &protocol,
5324 &state,
5325 &RenderOptions::new("index.html", "/"),
5326 &mut writer,
5327 )
5328 .unwrap();
5329 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5330 }
5331
5332 #[test]
5335 fn test_raw_signal_not_escaped() {
5336 let mut fragments = HashMap::new();
5337 fragments.insert(
5338 "index.html".to_string(),
5339 FragmentList {
5340 fragments: vec![
5341 WebUIFragment::signal("html", false),
5342 WebUIFragment::signal("html", true),
5343 ],
5344 contains_boundary: false,
5345 },
5346 );
5347 let protocol = WebUIProtocol::new(fragments);
5348 let state = test_json!({"html": "<strong>hi</strong>"});
5349 let mut writer = TestWriter::new();
5350 handle(
5351 &protocol,
5352 &state,
5353 &RenderOptions::new("index.html", "/"),
5354 &mut writer,
5355 )
5356 .unwrap();
5357 assert_eq!(
5358 writer.get_content(),
5359 "<strong>hi</strong><strong>hi</strong>"
5360 );
5361 }
5362
5363 #[test]
5366 fn test_nested_for_loop() {
5367 let mut fragments = HashMap::new();
5368 fragments.insert(
5369 "index.html".to_string(),
5370 FragmentList {
5371 fragments: vec![
5372 WebUIFragment::raw("<div>"),
5373 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5374 WebUIFragment::raw("</div>"),
5375 ],
5376 contains_boundary: false,
5377 },
5378 );
5379 fragments.insert(
5380 "outer".to_string(),
5381 FragmentList {
5382 fragments: vec![
5383 WebUIFragment::raw("<div>"),
5384 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5385 WebUIFragment::raw("</div>"),
5386 ],
5387 contains_boundary: false,
5388 },
5389 );
5390 fragments.insert(
5391 "inner".to_string(),
5392 FragmentList {
5393 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5394 contains_boundary: false,
5395 },
5396 );
5397 let protocol = WebUIProtocol::new(fragments);
5398 let state = test_json!({
5399 "outerItems": [
5400 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5401 {"innerItems": [{"name": "C"}]}
5402 ]
5403 });
5404 let mut writer = TestWriter::new();
5405 handle(
5406 &protocol,
5407 &state,
5408 &RenderOptions::new("index.html", "/"),
5409 &mut writer,
5410 )
5411 .unwrap();
5412 assert_eq!(
5413 writer.get_content(),
5414 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5415 );
5416 }
5417
5418 #[test]
5419 fn test_nested_for_with_signals() {
5420 let mut fragments = HashMap::new();
5421 fragments.insert(
5422 "index.html".to_string(),
5423 FragmentList {
5424 fragments: vec![
5425 WebUIFragment::raw("<div>"),
5426 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5427 WebUIFragment::raw("</div>"),
5428 ],
5429 contains_boundary: false,
5430 },
5431 );
5432 fragments.insert(
5433 "outerTemplate".to_string(),
5434 FragmentList {
5435 fragments: vec![
5436 WebUIFragment::raw("<div>"),
5437 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5438 WebUIFragment::raw("</div>"),
5439 ],
5440 contains_boundary: false,
5441 },
5442 );
5443 fragments.insert(
5444 "innerTemplate".to_string(),
5445 FragmentList {
5446 fragments: vec![
5447 WebUIFragment::raw("<span>"),
5448 WebUIFragment::signal("innerItem.name", false),
5449 WebUIFragment::raw("</span>"),
5450 ],
5451 contains_boundary: false,
5452 },
5453 );
5454 let protocol = WebUIProtocol::new(fragments);
5455 let state = test_json!({
5456 "outerItems": [
5457 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5458 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5459 ]
5460 });
5461 let mut writer = TestWriter::new();
5462 handle(
5463 &protocol,
5464 &state,
5465 &RenderOptions::new("index.html", "/"),
5466 &mut writer,
5467 )
5468 .unwrap();
5469 assert_eq!(
5470 writer.get_content(),
5471 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5472 );
5473 }
5474
5475 #[test]
5476 fn test_nested_for_with_global_state() {
5477 let mut fragments = HashMap::new();
5478 fragments.insert(
5479 "index.html".to_string(),
5480 FragmentList {
5481 fragments: vec![
5482 WebUIFragment::raw("<div>"),
5483 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5484 WebUIFragment::raw("</div>"),
5485 ],
5486 contains_boundary: false,
5487 },
5488 );
5489 fragments.insert(
5490 "outerTemplate".to_string(),
5491 FragmentList {
5492 fragments: vec![
5493 WebUIFragment::raw("<div>"),
5494 WebUIFragment::signal("globalOuter", false),
5495 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5496 WebUIFragment::raw("</div>"),
5497 ],
5498 contains_boundary: false,
5499 },
5500 );
5501 fragments.insert(
5502 "innerTemplate".to_string(),
5503 FragmentList {
5504 fragments: vec![
5505 WebUIFragment::raw("<span>"),
5506 WebUIFragment::signal("innerItem.name", false),
5507 WebUIFragment::signal("globalInner", false),
5508 WebUIFragment::raw("</span>"),
5509 ],
5510 contains_boundary: false,
5511 },
5512 );
5513 let protocol = WebUIProtocol::new(fragments);
5514 let state = test_json!({
5515 "globalOuter": "GO",
5516 "globalInner": "GI",
5517 "outerItems": [
5518 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5519 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5520 ]
5521 });
5522 let mut writer = TestWriter::new();
5523 handle(
5524 &protocol,
5525 &state,
5526 &RenderOptions::new("index.html", "/"),
5527 &mut writer,
5528 )
5529 .unwrap();
5530 assert_eq!(
5531 writer.get_content(),
5532 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5533 );
5534 }
5535
5536 #[test]
5539 fn test_if_in_for_uses_local_state() {
5540 let mut fragments = HashMap::new();
5541 fragments.insert(
5542 "index.html".to_string(),
5543 FragmentList {
5544 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5545 contains_boundary: false,
5546 },
5547 );
5548 fragments.insert(
5549 "item-tpl".to_string(),
5550 FragmentList {
5551 fragments: vec![WebUIFragment::if_cond(
5552 ConditionExpr::identifier("item.visible"),
5553 "visible-tpl",
5554 )],
5555 contains_boundary: false,
5556 },
5557 );
5558 fragments.insert(
5559 "visible-tpl".to_string(),
5560 FragmentList {
5561 fragments: vec![WebUIFragment::signal("item.name", false)],
5562 contains_boundary: false,
5563 },
5564 );
5565 let protocol = WebUIProtocol::new(fragments);
5566 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5567 let mut writer = TestWriter::new();
5568 handle(
5569 &protocol,
5570 &state,
5571 &RenderOptions::new("index.html", "/"),
5572 &mut writer,
5573 )
5574 .unwrap();
5575 assert_eq!(writer.get_content(), "Show");
5576 }
5577
5578 #[test]
5579 fn test_for_if_local_overrides_global() {
5580 let mut fragments = HashMap::new();
5581 fragments.insert(
5582 "index.html".to_string(),
5583 FragmentList {
5584 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5585 contains_boundary: false,
5586 },
5587 );
5588 fragments.insert(
5589 "item-tpl".to_string(),
5590 FragmentList {
5591 fragments: vec![WebUIFragment::if_cond(
5592 ConditionExpr::identifier("item.flag"),
5593 "show-tpl",
5594 )],
5595 contains_boundary: false,
5596 },
5597 );
5598 fragments.insert(
5599 "show-tpl".to_string(),
5600 FragmentList {
5601 fragments: vec![WebUIFragment::raw("yes")],
5602 contains_boundary: false,
5603 },
5604 );
5605 let protocol = WebUIProtocol::new(fragments);
5606 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5608 let mut writer = TestWriter::new();
5609 handle(
5610 &protocol,
5611 &state,
5612 &RenderOptions::new("index.html", "/"),
5613 &mut writer,
5614 )
5615 .unwrap();
5616 assert_eq!(writer.get_content(), "yes");
5617 }
5618
5619 #[test]
5622 fn test_component_attr_state_simple() {
5623 let mut fragments = HashMap::new();
5624 fragments.insert(
5625 "index.html".to_string(),
5626 FragmentList {
5627 fragments: vec![
5628 WebUIFragment::raw("<my-comp"),
5629 WebUIFragment {
5630 fragment: Some(web_ui_fragment::Fragment::Attribute(
5631 WebUIFragmentAttribute {
5632 name: "title".into(),
5633 value: "Attribute Title".into(),
5634 attr_start: true,
5635 raw_value: true,
5636 ..Default::default()
5637 },
5638 )),
5639 },
5640 WebUIFragment::raw(">"),
5641 WebUIFragment::component("my-comp"),
5642 WebUIFragment::raw("</my-comp>"),
5643 ],
5644
5645 contains_boundary: false,
5646 },
5647 );
5648 fragments.insert(
5649 "my-comp".to_string(),
5650 FragmentList {
5651 fragments: vec![
5652 WebUIFragment::raw("<span>"),
5653 WebUIFragment::signal("title", false),
5654 WebUIFragment::raw("</span>"),
5655 ],
5656 contains_boundary: false,
5657 },
5658 );
5659 let protocol = WebUIProtocol::new(fragments);
5660 let state = test_json!({"title": "Global Title"});
5661 let mut writer = TestWriter::new();
5662 handle(
5663 &protocol,
5664 &state,
5665 &RenderOptions::new("index.html", "/"),
5666 &mut writer,
5667 )
5668 .unwrap();
5669 assert_eq!(
5670 writer.get_content(),
5671 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5672 );
5673 }
5674
5675 #[test]
5676 fn test_component_attr_state_template() {
5677 let mut fragments = HashMap::new();
5678 fragments.insert(
5679 "index.html".to_string(),
5680 FragmentList {
5681 fragments: vec![
5682 WebUIFragment::raw("<my-comp"),
5683 WebUIFragment {
5684 fragment: Some(web_ui_fragment::Fragment::Attribute(
5685 WebUIFragmentAttribute {
5686 name: "title".into(),
5687 template: "title-attr".into(),
5688 attr_start: true,
5689 ..Default::default()
5690 },
5691 )),
5692 },
5693 WebUIFragment::raw(">"),
5694 WebUIFragment::component("my-comp"),
5695 WebUIFragment::raw("</my-comp>"),
5696 ],
5697
5698 contains_boundary: false,
5699 },
5700 );
5701 fragments.insert(
5702 "title-attr".to_string(),
5703 FragmentList {
5704 fragments: vec![
5705 WebUIFragment::raw("hello "),
5706 WebUIFragment::signal("item", false),
5707 ],
5708 contains_boundary: false,
5709 },
5710 );
5711 fragments.insert(
5712 "my-comp".to_string(),
5713 FragmentList {
5714 fragments: vec![
5715 WebUIFragment::raw("<span>"),
5716 WebUIFragment::signal("title", false),
5717 WebUIFragment::raw("</span>"),
5718 ],
5719 contains_boundary: false,
5720 },
5721 );
5722 let protocol = WebUIProtocol::new(fragments);
5723 let state = test_json!({"item": "<world>"});
5724 let mut writer = TestWriter::new();
5725 handle(
5726 &protocol,
5727 &state,
5728 &RenderOptions::new("index.html", "/"),
5729 &mut writer,
5730 )
5731 .unwrap();
5732 assert_eq!(
5733 writer.get_content(),
5734 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
5735 );
5736 }
5737
5738 #[test]
5739 fn test_component_attr_camel_case() {
5740 let mut fragments = HashMap::new();
5741 fragments.insert(
5742 "index.html".to_string(),
5743 FragmentList {
5744 fragments: vec![
5745 WebUIFragment::raw("<my-comp"),
5746 WebUIFragment {
5747 fragment: Some(web_ui_fragment::Fragment::Attribute(
5748 WebUIFragmentAttribute {
5749 name: "data-title".into(),
5750 template: "dt-attr".into(),
5751 attr_start: true,
5752 ..Default::default()
5753 },
5754 )),
5755 },
5756 WebUIFragment::raw(">"),
5757 WebUIFragment::component("my-comp"),
5758 WebUIFragment::raw("</my-comp>"),
5759 ],
5760
5761 contains_boundary: false,
5762 },
5763 );
5764 fragments.insert(
5765 "dt-attr".to_string(),
5766 FragmentList {
5767 fragments: vec![
5768 WebUIFragment::raw("prefix "),
5769 WebUIFragment::signal("item", false),
5770 ],
5771 contains_boundary: false,
5772 },
5773 );
5774 fragments.insert(
5775 "my-comp".to_string(),
5776 FragmentList {
5777 fragments: vec![
5778 WebUIFragment::raw("<span>"),
5779 WebUIFragment::signal("dataTitle", false),
5780 WebUIFragment::raw("</span>"),
5781 ],
5782 contains_boundary: false,
5783 },
5784 );
5785 let protocol = WebUIProtocol::new(fragments);
5786 let state = test_json!({"item": "a&b"});
5787 let mut writer = TestWriter::new();
5788 handle(
5789 &protocol,
5790 &state,
5791 &RenderOptions::new("index.html", "/"),
5792 &mut writer,
5793 )
5794 .unwrap();
5795 assert_eq!(
5796 writer.get_content(),
5797 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
5798 );
5799 }
5800
5801 #[test]
5802 fn test_component_complex_attr() {
5803 let mut fragments = HashMap::new();
5804 fragments.insert(
5805 "index.html".to_string(),
5806 FragmentList {
5807 fragments: vec![
5808 WebUIFragment::raw("<my-comp"),
5809 WebUIFragment {
5810 fragment: Some(web_ui_fragment::Fragment::Attribute(
5811 WebUIFragmentAttribute {
5812 name: ":item".into(),
5813 value: "complexItem".into(),
5814 attr_start: true,
5815 complex: true,
5816 ..Default::default()
5817 },
5818 )),
5819 },
5820 WebUIFragment::raw(">"),
5821 WebUIFragment::component("my-comp"),
5822 WebUIFragment::raw("</my-comp>"),
5823 ],
5824
5825 contains_boundary: false,
5826 },
5827 );
5828 fragments.insert(
5829 "my-comp".to_string(),
5830 FragmentList {
5831 fragments: vec![
5832 WebUIFragment::raw("<span>"),
5833 WebUIFragment::signal("item.foo", false),
5834 WebUIFragment::raw("</span><p>"),
5835 WebUIFragment::signal("item.bar", false),
5836 WebUIFragment::raw("</p>"),
5837 ],
5838 contains_boundary: false,
5839 },
5840 );
5841 let protocol = WebUIProtocol::new(fragments);
5842 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5843 let mut writer = TestWriter::new();
5844 handle(
5845 &protocol,
5846 &state,
5847 &RenderOptions::new("index.html", "/"),
5848 &mut writer,
5849 )
5850 .unwrap();
5851 assert_eq!(
5852 writer.get_content(),
5853 "<my-comp><span>1</span><p>true</p></my-comp>"
5854 );
5855 }
5856
5857 #[test]
5858 fn test_component_no_parent_pollution() {
5859 let mut fragments = HashMap::new();
5860 fragments.insert(
5861 "index.html".to_string(),
5862 FragmentList {
5863 fragments: vec![
5864 WebUIFragment::raw("<parent"),
5865 WebUIFragment {
5866 fragment: Some(web_ui_fragment::Fragment::Attribute(
5867 WebUIFragmentAttribute {
5868 name: "var".into(),
5869 value: "var".into(),
5870 attr_start: true,
5871 ..Default::default()
5872 },
5873 )),
5874 },
5875 WebUIFragment::raw(">"),
5876 WebUIFragment::component("parent"),
5877 WebUIFragment::raw("</parent>"),
5878 ],
5879
5880 contains_boundary: false,
5881 },
5882 );
5883 fragments.insert(
5884 "parent".to_string(),
5885 FragmentList {
5886 fragments: vec![
5887 WebUIFragment::raw("Before: "),
5888 WebUIFragment::signal("var", false),
5889 WebUIFragment::raw("<child foo"),
5890 WebUIFragment {
5891 fragment: Some(web_ui_fragment::Fragment::Attribute(
5892 WebUIFragmentAttribute {
5893 name: "var".into(),
5894 value: "replaced".into(),
5895 raw_value: true,
5896 ..Default::default()
5897 },
5898 )),
5899 },
5900 WebUIFragment::raw(">"),
5901 WebUIFragment::component("child"),
5902 WebUIFragment::raw("Label</child>After: "),
5903 WebUIFragment::signal("var", false),
5904 ],
5905
5906 contains_boundary: false,
5907 },
5908 );
5909 fragments.insert(
5910 "child".to_string(),
5911 FragmentList {
5912 fragments: vec![],
5913 contains_boundary: false,
5914 },
5915 );
5916 let protocol = WebUIProtocol::new(fragments);
5917 let state = test_json!({"var": "original<&"});
5918 let mut writer = TestWriter::new();
5919 handle(
5920 &protocol,
5921 &state,
5922 &RenderOptions::new("index.html", "/"),
5923 &mut writer,
5924 )
5925 .unwrap();
5926 assert_eq!(
5927 writer.get_content(),
5928 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
5929 );
5930 }
5931
5932 #[test]
5933 fn test_component_boolean_attr_state() {
5934 let mut fragments = HashMap::new();
5935 fragments.insert(
5936 "index.html".to_string(),
5937 FragmentList {
5938 fragments: vec![
5939 WebUIFragment::raw("<my-comp"),
5940 WebUIFragment {
5941 fragment: Some(web_ui_fragment::Fragment::Attribute(
5942 WebUIFragmentAttribute {
5943 name: "disabled".into(),
5944 attr_start: true,
5945 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5946 ..Default::default()
5947 },
5948 )),
5949 },
5950 WebUIFragment::raw(">"),
5951 WebUIFragment::component("my-comp"),
5952 WebUIFragment::raw("</my-comp>"),
5953 ],
5954
5955 contains_boundary: false,
5956 },
5957 );
5958 fragments.insert(
5959 "my-comp".to_string(),
5960 FragmentList {
5961 fragments: vec![WebUIFragment::if_cond(
5962 ConditionExpr::identifier("disabled"),
5963 "show",
5964 )],
5965 contains_boundary: false,
5966 },
5967 );
5968 fragments.insert(
5969 "show".to_string(),
5970 FragmentList {
5971 fragments: vec![WebUIFragment::raw("disabled!")],
5972 contains_boundary: false,
5973 },
5974 );
5975 let protocol = WebUIProtocol::new(fragments);
5976 let state = test_json!({"isDisabled": true});
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 "<my-comp disabled>disabled!</my-comp>"
5988 );
5989 }
5990
5991 fn render_signal(value: &str) -> String {
5995 let mut fragments = HashMap::new();
5996 fragments.insert(
5997 "index.html".to_string(),
5998 FragmentList {
5999 fragments: vec![WebUIFragment::signal("v", false)],
6000 contains_boundary: false,
6001 },
6002 );
6003 let protocol = WebUIProtocol::new(fragments);
6004 let state = test_json!({"v": value});
6005 let mut writer = TestWriter::new();
6006 handle(
6007 &protocol,
6008 &state,
6009 &RenderOptions::new("index.html", "/"),
6010 &mut writer,
6011 )
6012 .unwrap();
6013 writer.get_content()
6014 }
6015
6016 #[test]
6017 fn test_escape_ampersand() {
6018 assert_eq!(render_signal("&"), "&");
6019 }
6020
6021 #[test]
6022 fn test_escape_less_than() {
6023 assert_eq!(render_signal("<"), "<");
6024 }
6025
6026 #[test]
6027 fn test_escape_greater_than() {
6028 assert_eq!(render_signal(">"), ">");
6029 }
6030
6031 #[test]
6032 fn test_escape_double_quote() {
6033 assert_eq!(render_signal("\""), """);
6034 }
6035
6036 #[test]
6037 fn test_escape_single_quote() {
6038 let result = render_signal("'");
6040 assert_eq!(
6041 result, "'",
6042 "Expected ' for single quote, got: {result}"
6043 );
6044 }
6045
6046 #[test]
6047 fn test_escape_multiple_special_chars() {
6048 let result = render_signal("<script>alert('xss');</script>");
6049 assert!(
6050 result.contains("<") && result.contains(">"),
6051 "Expected escaped HTML, got: {}",
6052 result
6053 );
6054 assert!(
6055 !result.contains("<script>"),
6056 "Should not contain raw <script> tag"
6057 );
6058 }
6059
6060 #[test]
6061 fn test_escape_no_special_chars() {
6062 assert_eq!(render_signal("Hello World"), "Hello World");
6063 }
6064
6065 #[test]
6066 fn test_escape_empty_string() {
6067 assert_eq!(render_signal(""), "");
6068 }
6069
6070 #[test]
6071 fn test_escape_special_at_beginning() {
6072 let result = render_signal("<Hello");
6073 assert!(
6074 result.starts_with("<"),
6075 "Expected < at start, got: {}",
6076 result
6077 );
6078 }
6079
6080 #[test]
6081 fn test_escape_special_at_end() {
6082 let result = render_signal("Hello>");
6083 assert!(
6084 result.ends_with(">"),
6085 "Expected > at end, got: {}",
6086 result
6087 );
6088 }
6089
6090 #[test]
6091 fn test_escape_special_in_middle() {
6092 let result = render_signal("Hel&lo");
6093 assert!(
6094 result.contains("&"),
6095 "Expected & in middle, got: {}",
6096 result
6097 );
6098 }
6099
6100 #[test]
6103 fn test_boolean_attr_truthy_values() {
6104 {
6106 let mut fragments = HashMap::new();
6107 fragments.insert(
6108 "index.html".to_string(),
6109 FragmentList {
6110 fragments: vec![
6111 WebUIFragment::raw("<input"),
6112 WebUIFragment::attribute_boolean(
6113 "checked",
6114 ConditionExpr::identifier("checked"),
6115 ),
6116 WebUIFragment::raw(">"),
6117 ],
6118 contains_boundary: false,
6119 },
6120 );
6121 let protocol = WebUIProtocol::new(fragments);
6122 let state = test_json!({"checked": 1});
6123 let mut writer = TestWriter::new();
6124 handle(
6125 &protocol,
6126 &state,
6127 &RenderOptions::new("index.html", "/"),
6128 &mut writer,
6129 )
6130 .unwrap();
6131 assert_eq!(writer.get_content(), "<input checked>");
6132 }
6133 {
6135 let mut fragments = HashMap::new();
6136 fragments.insert(
6137 "index.html".to_string(),
6138 FragmentList {
6139 fragments: vec![
6140 WebUIFragment::raw("<input"),
6141 WebUIFragment::attribute_boolean(
6142 "checked",
6143 ConditionExpr::identifier("checked"),
6144 ),
6145 WebUIFragment::raw(">"),
6146 ],
6147 contains_boundary: false,
6148 },
6149 );
6150 let protocol = WebUIProtocol::new(fragments);
6151 let state = test_json!({"checked": "yes"});
6152 let mut writer = TestWriter::new();
6153 handle(
6154 &protocol,
6155 &state,
6156 &RenderOptions::new("index.html", "/"),
6157 &mut writer,
6158 )
6159 .unwrap();
6160 assert_eq!(writer.get_content(), "<input checked>");
6161 }
6162 {
6164 let mut fragments = HashMap::new();
6165 fragments.insert(
6166 "index.html".to_string(),
6167 FragmentList {
6168 fragments: vec![
6169 WebUIFragment::raw("<input"),
6170 WebUIFragment::attribute_boolean(
6171 "checked",
6172 ConditionExpr::identifier("checked"),
6173 ),
6174 WebUIFragment::raw(">"),
6175 ],
6176 contains_boundary: false,
6177 },
6178 );
6179 let protocol = WebUIProtocol::new(fragments);
6180 let state = test_json!({"checked": {}});
6181 let mut writer = TestWriter::new();
6182 handle(
6183 &protocol,
6184 &state,
6185 &RenderOptions::new("index.html", "/"),
6186 &mut writer,
6187 )
6188 .unwrap();
6189 assert_eq!(writer.get_content(), "<input>");
6191 }
6192 {
6194 let mut fragments = HashMap::new();
6195 fragments.insert(
6196 "index.html".to_string(),
6197 FragmentList {
6198 fragments: vec![
6199 WebUIFragment::raw("<input"),
6200 WebUIFragment::attribute_boolean(
6201 "checked",
6202 ConditionExpr::identifier("checked"),
6203 ),
6204 WebUIFragment::raw(">"),
6205 ],
6206 contains_boundary: false,
6207 },
6208 );
6209 let protocol = WebUIProtocol::new(fragments);
6210 let state = test_json!({"checked": "false"});
6211 let mut writer = TestWriter::new();
6212 handle(
6213 &protocol,
6214 &state,
6215 &RenderOptions::new("index.html", "/"),
6216 &mut writer,
6217 )
6218 .unwrap();
6219 assert_eq!(writer.get_content(), "<input checked>");
6220 }
6221 }
6222
6223 #[test]
6224 fn test_boolean_attr_falsy_values() {
6225 {
6227 let mut fragments = HashMap::new();
6228 fragments.insert(
6229 "index.html".to_string(),
6230 FragmentList {
6231 fragments: vec![
6232 WebUIFragment::raw("<input"),
6233 WebUIFragment::attribute_boolean(
6234 "checked",
6235 ConditionExpr::identifier("checked"),
6236 ),
6237 WebUIFragment::raw(">"),
6238 ],
6239 contains_boundary: false,
6240 },
6241 );
6242 let protocol = WebUIProtocol::new(fragments);
6243 let state = test_json!({"checked": 0});
6244 let mut writer = TestWriter::new();
6245 handle(
6246 &protocol,
6247 &state,
6248 &RenderOptions::new("index.html", "/"),
6249 &mut writer,
6250 )
6251 .unwrap();
6252 assert_eq!(writer.get_content(), "<input>");
6253 }
6254 {
6256 let mut fragments = HashMap::new();
6257 fragments.insert(
6258 "index.html".to_string(),
6259 FragmentList {
6260 fragments: vec![
6261 WebUIFragment::raw("<input"),
6262 WebUIFragment::attribute_boolean(
6263 "checked",
6264 ConditionExpr::identifier("checked"),
6265 ),
6266 WebUIFragment::raw(">"),
6267 ],
6268 contains_boundary: false,
6269 },
6270 );
6271 let protocol = WebUIProtocol::new(fragments);
6272 let state = test_json!({"checked": ""});
6273 let mut writer = TestWriter::new();
6274 handle(
6275 &protocol,
6276 &state,
6277 &RenderOptions::new("index.html", "/"),
6278 &mut writer,
6279 )
6280 .unwrap();
6281 assert_eq!(writer.get_content(), "<input>");
6282 }
6283 {
6285 let mut fragments = HashMap::new();
6286 fragments.insert(
6287 "index.html".to_string(),
6288 FragmentList {
6289 fragments: vec![
6290 WebUIFragment::raw("<input"),
6291 WebUIFragment::attribute_boolean(
6292 "checked",
6293 ConditionExpr::identifier("checked"),
6294 ),
6295 WebUIFragment::raw(">"),
6296 ],
6297 contains_boundary: false,
6298 },
6299 );
6300 let protocol = WebUIProtocol::new(fragments);
6301 let state = test_json!({"checked": false});
6302 let mut writer = TestWriter::new();
6303 handle(
6304 &protocol,
6305 &state,
6306 &RenderOptions::new("index.html", "/"),
6307 &mut writer,
6308 )
6309 .unwrap();
6310 assert_eq!(writer.get_content(), "<input>");
6311 }
6312 {
6314 let mut fragments = HashMap::new();
6315 fragments.insert(
6316 "index.html".to_string(),
6317 FragmentList {
6318 fragments: vec![
6319 WebUIFragment::raw("<input"),
6320 WebUIFragment::attribute_boolean(
6321 "checked",
6322 ConditionExpr::identifier("checked"),
6323 ),
6324 WebUIFragment::raw(">"),
6325 ],
6326 contains_boundary: false,
6327 },
6328 );
6329 let protocol = WebUIProtocol::new(fragments);
6330 let state = test_json!({});
6331 let mut writer = TestWriter::new();
6332 handle(
6333 &protocol,
6334 &state,
6335 &RenderOptions::new("index.html", "/"),
6336 &mut writer,
6337 )
6338 .unwrap();
6339 assert_eq!(writer.get_content(), "<input>");
6340 }
6341 }
6342
6343 #[test]
6344 fn test_boolean_attr_expression_true() {
6345 let mut fragments = HashMap::new();
6346 fragments.insert(
6347 "index.html".to_string(),
6348 FragmentList {
6349 fragments: vec![
6350 WebUIFragment::raw("<button"),
6351 WebUIFragment::attribute_boolean(
6352 "disabled",
6353 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6354 ),
6355 WebUIFragment::raw(">Click</button>"),
6356 ],
6357 contains_boundary: false,
6358 },
6359 );
6360 let protocol = WebUIProtocol::new(fragments);
6361 let state = test_json!({"itemCount": 5});
6362 let mut writer = TestWriter::new();
6363 handle(
6364 &protocol,
6365 &state,
6366 &RenderOptions::new("index.html", "/"),
6367 &mut writer,
6368 )
6369 .unwrap();
6370 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6371 }
6372
6373 #[test]
6374 fn test_boolean_attr_expression_false() {
6375 let mut fragments = HashMap::new();
6376 fragments.insert(
6377 "index.html".to_string(),
6378 FragmentList {
6379 fragments: vec![
6380 WebUIFragment::raw("<button"),
6381 WebUIFragment::attribute_boolean(
6382 "disabled",
6383 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6384 ),
6385 WebUIFragment::raw(">Click</button>"),
6386 ],
6387 contains_boundary: false,
6388 },
6389 );
6390 let protocol = WebUIProtocol::new(fragments);
6391 let state = test_json!({"itemCount": 3});
6392 let mut writer = TestWriter::new();
6393 handle(
6394 &protocol,
6395 &state,
6396 &RenderOptions::new("index.html", "/"),
6397 &mut writer,
6398 )
6399 .unwrap();
6400 assert_eq!(writer.get_content(), "<button>Click</button>");
6401 }
6402
6403 #[test]
6406 fn test_nested_component_attr_capture() {
6407 let mut fragments = HashMap::new();
6408 fragments.insert(
6409 "index.html".to_string(),
6410 FragmentList {
6411 fragments: vec![
6412 WebUIFragment::raw("<parent-component"),
6413 WebUIFragment {
6414 fragment: Some(web_ui_fragment::Fragment::Attribute(
6415 WebUIFragmentAttribute {
6416 name: "title".into(),
6417 template: "parent-title".into(),
6418 attr_start: true,
6419 ..Default::default()
6420 },
6421 )),
6422 },
6423 WebUIFragment::raw(">"),
6424 WebUIFragment::component("parent-component"),
6425 WebUIFragment::raw("</parent-component>"),
6426 ],
6427
6428 contains_boundary: false,
6429 },
6430 );
6431 fragments.insert(
6432 "parent-title".to_string(),
6433 FragmentList {
6434 fragments: vec![
6435 WebUIFragment::raw("Hello "),
6436 WebUIFragment::signal("who", false),
6437 ],
6438 contains_boundary: false,
6439 },
6440 );
6441 fragments.insert(
6442 "parent-component".to_string(),
6443 FragmentList {
6444 fragments: vec![
6445 WebUIFragment::raw("<child-component"),
6446 WebUIFragment {
6447 fragment: Some(web_ui_fragment::Fragment::Attribute(
6448 WebUIFragmentAttribute {
6449 name: "title".into(),
6450 template: "child-title".into(),
6451 attr_start: true,
6452 ..Default::default()
6453 },
6454 )),
6455 },
6456 WebUIFragment::raw(">"),
6457 WebUIFragment::component("child-component"),
6458 WebUIFragment::raw("</child-component>"),
6459 ],
6460
6461 contains_boundary: false,
6462 },
6463 );
6464 fragments.insert(
6465 "child-title".to_string(),
6466 FragmentList {
6467 fragments: vec![
6468 WebUIFragment::raw("Child of "),
6469 WebUIFragment::signal("title", false),
6470 ],
6471 contains_boundary: false,
6472 },
6473 );
6474 fragments.insert(
6475 "child-component".to_string(),
6476 FragmentList {
6477 fragments: vec![
6478 WebUIFragment::raw("<span>"),
6479 WebUIFragment::signal("title", false),
6480 WebUIFragment::raw("</span>"),
6481 ],
6482 contains_boundary: false,
6483 },
6484 );
6485 let protocol = WebUIProtocol::new(fragments);
6486 let state = test_json!({"who": "<world>"});
6487 let mut writer = TestWriter::new();
6488 handle(
6489 &protocol,
6490 &state,
6491 &RenderOptions::new("index.html", "/"),
6492 &mut writer,
6493 )
6494 .unwrap();
6495 assert_eq!(
6496 writer.get_content(),
6497 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
6498 );
6499 }
6500
6501 #[test]
6502 fn test_grandchild_attr_propagation() {
6503 let mut fragments = HashMap::new();
6504 fragments.insert(
6505 "index.html".to_string(),
6506 FragmentList {
6507 fragments: vec![
6508 WebUIFragment::raw("<parent-component"),
6509 WebUIFragment {
6510 fragment: Some(web_ui_fragment::Fragment::Attribute(
6511 WebUIFragmentAttribute {
6512 name: "title".into(),
6513 template: "p-title".into(),
6514 attr_start: true,
6515 ..Default::default()
6516 },
6517 )),
6518 },
6519 WebUIFragment::raw(">"),
6520 WebUIFragment::component("parent-component"),
6521 WebUIFragment::raw("</parent-component>"),
6522 ],
6523
6524 contains_boundary: false,
6525 },
6526 );
6527 fragments.insert(
6528 "p-title".to_string(),
6529 FragmentList {
6530 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6531 contains_boundary: false,
6532 },
6533 );
6534 fragments.insert(
6535 "parent-component".to_string(),
6536 FragmentList {
6537 fragments: vec![
6538 WebUIFragment::raw("<child-component"),
6539 WebUIFragment {
6540 fragment: Some(web_ui_fragment::Fragment::Attribute(
6541 WebUIFragmentAttribute {
6542 name: "title".into(),
6543 template: "c-title".into(),
6544 attr_start: true,
6545 ..Default::default()
6546 },
6547 )),
6548 },
6549 WebUIFragment::raw(">"),
6550 WebUIFragment::component("child-component"),
6551 WebUIFragment::raw("</child-component>"),
6552 ],
6553
6554 contains_boundary: false,
6555 },
6556 );
6557 fragments.insert(
6558 "c-title".to_string(),
6559 FragmentList {
6560 fragments: vec![
6561 WebUIFragment::raw("C("),
6562 WebUIFragment::signal("title", false),
6563 WebUIFragment::raw(")-"),
6564 WebUIFragment::signal("cExtra", false),
6565 ],
6566 contains_boundary: false,
6567 },
6568 );
6569 fragments.insert(
6570 "child-component".to_string(),
6571 FragmentList {
6572 fragments: vec![
6573 WebUIFragment::raw("<grandchild-component"),
6574 WebUIFragment {
6575 fragment: Some(web_ui_fragment::Fragment::Attribute(
6576 WebUIFragmentAttribute {
6577 name: "title".into(),
6578 value: "title".into(),
6579 attr_start: true,
6580 ..Default::default()
6581 },
6582 )),
6583 },
6584 WebUIFragment::raw(">"),
6585 WebUIFragment::component("grandchild-component"),
6586 WebUIFragment::raw("</grandchild-component>"),
6587 ],
6588
6589 contains_boundary: false,
6590 },
6591 );
6592 fragments.insert(
6593 "grandchild-component".to_string(),
6594 FragmentList {
6595 fragments: vec![
6596 WebUIFragment::raw("<span>"),
6597 WebUIFragment::signal("title", false),
6598 WebUIFragment::raw("</span>"),
6599 ],
6600 contains_boundary: false,
6601 },
6602 );
6603 let protocol = WebUIProtocol::new(fragments);
6604 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6605 let mut writer = TestWriter::new();
6606 handle(
6607 &protocol,
6608 &state,
6609 &RenderOptions::new("index.html", "/"),
6610 &mut writer,
6611 )
6612 .unwrap();
6613 assert_eq!(
6614 writer.get_content(),
6615 "<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>"
6616 );
6617 }
6618
6619 #[test]
6620 fn test_for_loop_component_attr() {
6621 let mut fragments = HashMap::new();
6622 fragments.insert(
6623 "index.html".to_string(),
6624 FragmentList {
6625 fragments: vec![
6626 WebUIFragment::raw("<parent-component"),
6627 WebUIFragment {
6628 fragment: Some(web_ui_fragment::Fragment::Attribute(
6629 WebUIFragmentAttribute {
6630 name: "title".into(),
6631 template: "parent-title-loop".into(),
6632 attr_start: true,
6633 ..Default::default()
6634 },
6635 )),
6636 },
6637 WebUIFragment::raw(">"),
6638 WebUIFragment::component("parent-component"),
6639 WebUIFragment::raw("</parent-component>"),
6640 ],
6641
6642 contains_boundary: false,
6643 },
6644 );
6645 fragments.insert(
6646 "parent-title-loop".to_string(),
6647 FragmentList {
6648 fragments: vec![
6649 WebUIFragment::raw("Parent:"),
6650 WebUIFragment::signal("who", false),
6651 ],
6652 contains_boundary: false,
6653 },
6654 );
6655 fragments.insert(
6656 "parent-component".to_string(),
6657 FragmentList {
6658 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6659 contains_boundary: false,
6660 },
6661 );
6662 fragments.insert(
6663 "child-loop".to_string(),
6664 FragmentList {
6665 fragments: vec![
6666 WebUIFragment::raw("<child-component"),
6667 WebUIFragment {
6668 fragment: Some(web_ui_fragment::Fragment::Attribute(
6669 WebUIFragmentAttribute {
6670 name: "title".into(),
6671 template: "child-title-loop".into(),
6672 attr_start: true,
6673 ..Default::default()
6674 },
6675 )),
6676 },
6677 WebUIFragment::raw(">"),
6678 WebUIFragment::component("child-component"),
6679 WebUIFragment::raw("</child-component>"),
6680 ],
6681
6682 contains_boundary: false,
6683 },
6684 );
6685 fragments.insert(
6686 "child-title-loop".to_string(),
6687 FragmentList {
6688 fragments: vec![
6689 WebUIFragment::raw("Hi "),
6690 WebUIFragment::signal("item.name", false),
6691 WebUIFragment::raw(" / "),
6692 WebUIFragment::signal("title", false),
6693 ],
6694 contains_boundary: false,
6695 },
6696 );
6697 fragments.insert(
6698 "child-component".to_string(),
6699 FragmentList {
6700 fragments: vec![
6701 WebUIFragment::raw("<span>"),
6702 WebUIFragment::signal("title", false),
6703 WebUIFragment::raw("</span>"),
6704 ],
6705 contains_boundary: false,
6706 },
6707 );
6708 let protocol = WebUIProtocol::new(fragments);
6709 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6710 let mut writer = TestWriter::new();
6711 handle(
6712 &protocol,
6713 &state,
6714 &RenderOptions::new("index.html", "/"),
6715 &mut writer,
6716 )
6717 .unwrap();
6718 assert_eq!(
6719 writer.get_content(),
6720 "<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>"
6721 );
6722 }
6723
6724 #[test]
6725 fn test_multiple_template_attrs() {
6726 let mut fragments = HashMap::new();
6727 fragments.insert(
6728 "index.html".to_string(),
6729 FragmentList {
6730 fragments: vec![
6731 WebUIFragment::raw("<my-component"),
6732 WebUIFragment {
6733 fragment: Some(web_ui_fragment::Fragment::Attribute(
6734 WebUIFragmentAttribute {
6735 name: "title".into(),
6736 template: "attr-title".into(),
6737 attr_start: true,
6738 ..Default::default()
6739 },
6740 )),
6741 },
6742 WebUIFragment {
6743 fragment: Some(web_ui_fragment::Fragment::Attribute(
6744 WebUIFragmentAttribute {
6745 name: "data-title".into(),
6746 template: "attr-data-title".into(),
6747 attr_start: false,
6748 ..Default::default()
6749 },
6750 )),
6751 },
6752 WebUIFragment {
6753 fragment: Some(web_ui_fragment::Fragment::Attribute(
6754 WebUIFragmentAttribute {
6755 name: "aria-label".into(),
6756 template: "attr-aria-label".into(),
6757 attr_start: false,
6758 ..Default::default()
6759 },
6760 )),
6761 },
6762 WebUIFragment::raw(">"),
6763 WebUIFragment::component("my-component"),
6764 WebUIFragment::raw("</my-component>"),
6765 ],
6766
6767 contains_boundary: false,
6768 },
6769 );
6770 fragments.insert(
6771 "attr-title".to_string(),
6772 FragmentList {
6773 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6774 contains_boundary: false,
6775 },
6776 );
6777 fragments.insert(
6778 "attr-data-title".to_string(),
6779 FragmentList {
6780 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6781 contains_boundary: false,
6782 },
6783 );
6784 fragments.insert(
6785 "attr-aria-label".to_string(),
6786 FragmentList {
6787 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6788 contains_boundary: false,
6789 },
6790 );
6791 fragments.insert(
6792 "my-component".to_string(),
6793 FragmentList {
6794 fragments: vec![
6795 WebUIFragment::raw("<span>"),
6796 WebUIFragment::signal("title", false),
6797 WebUIFragment::raw("|"),
6798 WebUIFragment::signal("dataTitle", false),
6799 WebUIFragment::raw("|"),
6800 WebUIFragment::signal("ariaLabel", false),
6801 WebUIFragment::raw("</span>"),
6802 ],
6803 contains_boundary: false,
6804 },
6805 );
6806 let protocol = WebUIProtocol::new(fragments);
6807 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6808 let mut writer = TestWriter::new();
6809 handle(
6810 &protocol,
6811 &state,
6812 &RenderOptions::new("index.html", "/"),
6813 &mut writer,
6814 )
6815 .unwrap();
6816 assert_eq!(
6817 writer.get_content(),
6818 "<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>"
6819 );
6820 }
6821
6822 #[test]
6823 fn test_attr_priority_over_global() {
6824 let mut fragments = HashMap::new();
6825 fragments.insert(
6826 "index.html".to_string(),
6827 FragmentList {
6828 fragments: vec![
6829 WebUIFragment::raw("<my-component"),
6830 WebUIFragment {
6831 fragment: Some(web_ui_fragment::Fragment::Attribute(
6832 WebUIFragmentAttribute {
6833 name: "title".into(),
6834 value: "Attribute Title".into(),
6835 raw_value: true,
6836 attr_start: true,
6837 ..Default::default()
6838 },
6839 )),
6840 },
6841 WebUIFragment::raw(">"),
6842 WebUIFragment::component("my-component"),
6843 WebUIFragment::raw("</my-component>"),
6844 ],
6845
6846 contains_boundary: false,
6847 },
6848 );
6849 fragments.insert(
6850 "my-component".to_string(),
6851 FragmentList {
6852 fragments: vec![
6853 WebUIFragment::raw("<span>"),
6854 WebUIFragment::signal("title", false),
6855 WebUIFragment::raw("</span>"),
6856 ],
6857 contains_boundary: false,
6858 },
6859 );
6860 let protocol = WebUIProtocol::new(fragments);
6861 let state = test_json!({"title": "Global Title"});
6862 let mut writer = TestWriter::new();
6863 handle(
6864 &protocol,
6865 &state,
6866 &RenderOptions::new("index.html", "/"),
6867 &mut writer,
6868 )
6869 .unwrap();
6870 assert_eq!(
6871 writer.get_content(),
6872 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6873 );
6874 }
6875
6876 #[test]
6877 fn test_attr_priority_over_local_and_global() {
6878 let mut fragments = HashMap::new();
6879 fragments.insert(
6880 "index.html".to_string(),
6881 FragmentList {
6882 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6883 contains_boundary: false,
6884 },
6885 );
6886 fragments.insert(
6887 "loop".to_string(),
6888 FragmentList {
6889 fragments: vec![
6890 WebUIFragment::raw("<my-component"),
6891 WebUIFragment {
6892 fragment: Some(web_ui_fragment::Fragment::Attribute(
6893 WebUIFragmentAttribute {
6894 name: "title".into(),
6895 value: "Attribute Title".into(),
6896 raw_value: true,
6897 attr_start: true,
6898 ..Default::default()
6899 },
6900 )),
6901 },
6902 WebUIFragment::raw(">"),
6903 WebUIFragment::component("my-component"),
6904 WebUIFragment::raw("</my-component>"),
6905 ],
6906
6907 contains_boundary: false,
6908 },
6909 );
6910 fragments.insert(
6911 "my-component".to_string(),
6912 FragmentList {
6913 fragments: vec![
6914 WebUIFragment::raw("<span>"),
6915 WebUIFragment::signal("title", false),
6916 WebUIFragment::raw("</span>"),
6917 ],
6918 contains_boundary: false,
6919 },
6920 );
6921 let protocol = WebUIProtocol::new(fragments);
6922 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6923 let mut writer = TestWriter::new();
6924 handle(
6925 &protocol,
6926 &state,
6927 &RenderOptions::new("index.html", "/"),
6928 &mut writer,
6929 )
6930 .unwrap();
6931 assert_eq!(
6932 writer.get_content(),
6933 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6934 );
6935 }
6936
6937 #[test]
6938 fn test_boolean_attr_first_component_attr() {
6939 let mut fragments = HashMap::new();
6940 fragments.insert(
6941 "index.html".to_string(),
6942 FragmentList {
6943 fragments: vec![
6944 WebUIFragment::raw("<my-component"),
6945 WebUIFragment {
6946 fragment: Some(web_ui_fragment::Fragment::Attribute(
6947 WebUIFragmentAttribute {
6948 name: "disabled".into(),
6949 attr_start: true,
6950 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6951 ..Default::default()
6952 },
6953 )),
6954 },
6955 WebUIFragment {
6956 fragment: Some(web_ui_fragment::Fragment::Attribute(
6957 WebUIFragmentAttribute {
6958 name: "label".into(),
6959 value: "Component Label".into(),
6960 raw_value: true,
6961 attr_start: false,
6962 ..Default::default()
6963 },
6964 )),
6965 },
6966 WebUIFragment::raw(">"),
6967 WebUIFragment::component("my-component"),
6968 WebUIFragment::raw("</my-component>"),
6969 ],
6970
6971 contains_boundary: false,
6972 },
6973 );
6974 fragments.insert(
6975 "my-component".to_string(),
6976 FragmentList {
6977 fragments: vec![
6978 WebUIFragment::if_cond(
6979 ConditionExpr::identifier("disabled"),
6980 "disabledTemplate",
6981 ),
6982 WebUIFragment::raw("<span>"),
6983 WebUIFragment::signal("label", false),
6984 WebUIFragment::raw("</span>"),
6985 ],
6986 contains_boundary: false,
6987 },
6988 );
6989 fragments.insert(
6990 "disabledTemplate".to_string(),
6991 FragmentList {
6992 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6993 contains_boundary: false,
6994 },
6995 );
6996 let protocol = WebUIProtocol::new(fragments);
6997 let state = test_json!({"isDisabled": true});
6998 let mut writer = TestWriter::new();
6999 handle(
7000 &protocol,
7001 &state,
7002 &RenderOptions::new("index.html", "/"),
7003 &mut writer,
7004 )
7005 .unwrap();
7006 assert_eq!(
7007 writer.get_content(),
7008 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7009 );
7010 }
7011
7012 #[test]
7013 fn test_hyphenated_attr_camelcase() {
7014 let mut fragments = HashMap::new();
7015 fragments.insert(
7016 "index.html".to_string(),
7017 FragmentList {
7018 fragments: vec![
7019 WebUIFragment::raw("<my-component"),
7020 WebUIFragment {
7021 fragment: Some(web_ui_fragment::Fragment::Attribute(
7022 WebUIFragmentAttribute {
7023 name: "key-hyphen".into(),
7024 value: "Local Value".into(),
7025 raw_value: true,
7026 attr_start: true,
7027 ..Default::default()
7028 },
7029 )),
7030 },
7031 WebUIFragment::raw(">"),
7032 WebUIFragment::component("my-component"),
7033 WebUIFragment::raw("</my-component>"),
7034 ],
7035
7036 contains_boundary: false,
7037 },
7038 );
7039 fragments.insert(
7040 "my-component".to_string(),
7041 FragmentList {
7042 fragments: vec![
7043 WebUIFragment::raw("<span>"),
7044 WebUIFragment::signal("keyHyphen", false),
7045 WebUIFragment::raw("</span>"),
7046 ],
7047 contains_boundary: false,
7048 },
7049 );
7050 let protocol = WebUIProtocol::new(fragments);
7051 let state = test_json!({"keyHyphen": "Global Value"});
7052 let mut writer = TestWriter::new();
7053 handle(
7054 &protocol,
7055 &state,
7056 &RenderOptions::new("index.html", "/"),
7057 &mut writer,
7058 )
7059 .unwrap();
7060 assert_eq!(
7061 writer.get_content(),
7062 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7063 );
7064 }
7065
7066 #[test]
7067 fn test_skipped_component_attrs() {
7068 let mut fragments = HashMap::new();
7072 fragments.insert(
7073 "index.html".to_string(),
7074 FragmentList {
7075 fragments: vec![
7076 WebUIFragment::raw("<test-component"),
7077 WebUIFragment {
7079 fragment: Some(web_ui_fragment::Fragment::Attribute(
7080 WebUIFragmentAttribute {
7081 name: "class".into(),
7082 value: "skippedClass".into(),
7083 attr_start: true,
7084 attr_skip: true,
7085 ..Default::default()
7086 },
7087 )),
7088 },
7089 WebUIFragment {
7091 fragment: Some(web_ui_fragment::Fragment::Attribute(
7092 WebUIFragmentAttribute {
7093 name: "style".into(),
7094 value: "skippedStyle".into(),
7095 attr_skip: true,
7096 ..Default::default()
7097 },
7098 )),
7099 },
7100 WebUIFragment {
7102 fragment: Some(web_ui_fragment::Fragment::Attribute(
7103 WebUIFragmentAttribute {
7104 name: "role".into(),
7105 value: "skippedRole".into(),
7106 attr_skip: true,
7107 ..Default::default()
7108 },
7109 )),
7110 },
7111 WebUIFragment {
7113 fragment: Some(web_ui_fragment::Fragment::Attribute(
7114 WebUIFragmentAttribute {
7115 name: "data-testid".into(),
7116 value: "skippedDataTestid".into(),
7117 attr_skip: true,
7118 ..Default::default()
7119 },
7120 )),
7121 },
7122 WebUIFragment {
7124 fragment: Some(web_ui_fragment::Fragment::Attribute(
7125 WebUIFragmentAttribute {
7126 name: "aria-label".into(),
7127 value: "skippedAriaLabel".into(),
7128 attr_skip: true,
7129 ..Default::default()
7130 },
7131 )),
7132 },
7133 WebUIFragment {
7135 fragment: Some(web_ui_fragment::Fragment::Attribute(
7136 WebUIFragmentAttribute {
7137 name: "title".into(),
7138 value: "title".into(),
7139 ..Default::default()
7140 },
7141 )),
7142 },
7143 WebUIFragment::raw(">"),
7144 WebUIFragment::component("test-component"),
7145 WebUIFragment::raw("</test-component>"),
7146 ],
7147
7148 contains_boundary: false,
7149 },
7150 );
7151 fragments.insert(
7152 "test-component".to_string(),
7153 FragmentList {
7154 fragments: vec![
7155 WebUIFragment::raw("<span>"),
7156 WebUIFragment::signal("title", false),
7157 WebUIFragment::raw("-"),
7158 WebUIFragment::signal("class", false),
7159 WebUIFragment::raw("-"),
7160 WebUIFragment::signal("style", false),
7161 WebUIFragment::raw("-"),
7162 WebUIFragment::signal("role", false),
7163 WebUIFragment::raw("-"),
7164 WebUIFragment::signal("dataTestid", false),
7165 WebUIFragment::raw("-"),
7166 WebUIFragment::signal("ariaLabel", false),
7167 WebUIFragment::raw("</span>"),
7168 ],
7169 contains_boundary: false,
7170 },
7171 );
7172 let protocol = WebUIProtocol::new(fragments);
7173 let state = test_json!({
7174 "title": "Hello",
7175 "skippedClass": "my-class",
7176 "skippedStyle": "color:red",
7177 "skippedRole": "button",
7178 "skippedDataTestid": "test-id",
7179 "skippedAriaLabel": "label-text"
7180 });
7181 let mut writer = TestWriter::new();
7182 handle(
7183 &protocol,
7184 &state,
7185 &RenderOptions::new("index.html", "/"),
7186 &mut writer,
7187 )
7188 .unwrap();
7189 assert_eq!(
7193 writer.get_content(),
7194 "<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>"
7195 );
7196 }
7197
7198 #[test]
7201 fn test_attr_inherit_parent_to_child() {
7202 let mut fragments = HashMap::new();
7203 fragments.insert(
7204 "index.html".to_string(),
7205 FragmentList {
7206 fragments: vec![
7207 WebUIFragment::raw("<parent-component"),
7208 WebUIFragment {
7209 fragment: Some(web_ui_fragment::Fragment::Attribute(
7210 WebUIFragmentAttribute {
7211 name: "title".into(),
7212 value: "Parent Title".into(),
7213 raw_value: true,
7214 attr_start: true,
7215 ..Default::default()
7216 },
7217 )),
7218 },
7219 WebUIFragment::raw(">"),
7220 WebUIFragment::component("parent-component"),
7221 WebUIFragment::raw("</parent-component>"),
7222 ],
7223
7224 contains_boundary: false,
7225 },
7226 );
7227 fragments.insert(
7228 "parent-component".to_string(),
7229 FragmentList {
7230 fragments: vec![
7231 WebUIFragment::raw("<h1>"),
7232 WebUIFragment::signal("title", false),
7233 WebUIFragment::raw("</h1><child-component"),
7234 WebUIFragment {
7235 fragment: Some(web_ui_fragment::Fragment::Attribute(
7236 WebUIFragmentAttribute {
7237 name: "title".into(),
7238 value: "title".into(),
7239 attr_start: true,
7240 ..Default::default()
7241 },
7242 )),
7243 },
7244 WebUIFragment::raw(">"),
7245 WebUIFragment::component("child-component"),
7246 WebUIFragment::raw("</child-component>"),
7247 ],
7248
7249 contains_boundary: false,
7250 },
7251 );
7252 fragments.insert(
7253 "child-component".to_string(),
7254 FragmentList {
7255 fragments: vec![
7256 WebUIFragment::raw("<h2>"),
7257 WebUIFragment::signal("title", false),
7258 WebUIFragment::raw("</h2>"),
7259 ],
7260 contains_boundary: false,
7261 },
7262 );
7263 let protocol = WebUIProtocol::new(fragments);
7264 let state = test_json!({});
7265 let mut writer = TestWriter::new();
7266 handle(
7267 &protocol,
7268 &state,
7269 &RenderOptions::new("index.html", "/"),
7270 &mut writer,
7271 )
7272 .unwrap();
7273 assert_eq!(
7274 writer.get_content(),
7275 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7276 );
7277 }
7278
7279 #[test]
7280 fn test_attr_inherit_deep() {
7281 let mut fragments = HashMap::new();
7282 fragments.insert(
7283 "index.html".to_string(),
7284 FragmentList {
7285 fragments: vec![
7286 WebUIFragment::raw("<parent-component"),
7287 WebUIFragment {
7288 fragment: Some(web_ui_fragment::Fragment::Attribute(
7289 WebUIFragmentAttribute {
7290 name: "title".into(),
7291 value: "Parent Title".into(),
7292 raw_value: true,
7293 attr_start: true,
7294 ..Default::default()
7295 },
7296 )),
7297 },
7298 WebUIFragment::raw(">"),
7299 WebUIFragment::component("parent-component"),
7300 WebUIFragment::raw("</parent-component>"),
7301 ],
7302
7303 contains_boundary: false,
7304 },
7305 );
7306 fragments.insert(
7307 "parent-component".to_string(),
7308 FragmentList {
7309 fragments: vec![
7310 WebUIFragment::raw("<child-component"),
7311 WebUIFragment {
7312 fragment: Some(web_ui_fragment::Fragment::Attribute(
7313 WebUIFragmentAttribute {
7314 name: "title".into(),
7315 value: "Child Title".into(),
7316 raw_value: true,
7317 attr_start: true,
7318 ..Default::default()
7319 },
7320 )),
7321 },
7322 WebUIFragment::raw(">"),
7323 WebUIFragment::component("child-component"),
7324 WebUIFragment::raw("</child-component>"),
7325 ],
7326
7327 contains_boundary: false,
7328 },
7329 );
7330 fragments.insert(
7331 "child-component".to_string(),
7332 FragmentList {
7333 fragments: vec![
7334 WebUIFragment::raw("<grandchild-component"),
7335 WebUIFragment {
7336 fragment: Some(web_ui_fragment::Fragment::Attribute(
7337 WebUIFragmentAttribute {
7338 name: "title".into(),
7339 value: "title".into(),
7340 attr_start: true,
7341 ..Default::default()
7342 },
7343 )),
7344 },
7345 WebUIFragment::raw(">"),
7346 WebUIFragment::component("grandchild-component"),
7347 WebUIFragment::raw("</grandchild-component>"),
7348 ],
7349
7350 contains_boundary: false,
7351 },
7352 );
7353 fragments.insert(
7354 "grandchild-component".to_string(),
7355 FragmentList {
7356 fragments: vec![
7357 WebUIFragment::raw("<h3>"),
7358 WebUIFragment::signal("title", false),
7359 WebUIFragment::raw("</h3>"),
7360 ],
7361 contains_boundary: false,
7362 },
7363 );
7364 let protocol = WebUIProtocol::new(fragments);
7365 let state = test_json!({});
7366 let mut writer = TestWriter::new();
7367 handle(
7368 &protocol,
7369 &state,
7370 &RenderOptions::new("index.html", "/"),
7371 &mut writer,
7372 )
7373 .unwrap();
7374 assert_eq!(
7375 writer.get_content(),
7376 "<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>"
7377 );
7378 }
7379
7380 #[test]
7381 fn test_complex_attr_access() {
7382 let mut fragments = HashMap::new();
7383 fragments.insert(
7384 "index.html".to_string(),
7385 FragmentList {
7386 fragments: vec![
7387 WebUIFragment::raw("<my-component"),
7388 WebUIFragment {
7389 fragment: Some(web_ui_fragment::Fragment::Attribute(
7390 WebUIFragmentAttribute {
7391 name: ":item".into(),
7392 value: "complexItem".into(),
7393 attr_start: true,
7394 complex: true,
7395 ..Default::default()
7396 },
7397 )),
7398 },
7399 WebUIFragment::raw(">"),
7400 WebUIFragment::component("my-component"),
7401 WebUIFragment::raw("</my-component>"),
7402 ],
7403
7404 contains_boundary: false,
7405 },
7406 );
7407 fragments.insert(
7408 "my-component".to_string(),
7409 FragmentList {
7410 fragments: vec![
7411 WebUIFragment::raw("<span>"),
7412 WebUIFragment::signal("item.foo", false),
7413 WebUIFragment::raw("</span><p>"),
7414 WebUIFragment::signal("item.bar", false),
7415 WebUIFragment::raw("</p>"),
7416 ],
7417 contains_boundary: false,
7418 },
7419 );
7420 let protocol = WebUIProtocol::new(fragments);
7421 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7422 let mut writer = TestWriter::new();
7423 handle(
7424 &protocol,
7425 &state,
7426 &RenderOptions::new("index.html", "/"),
7427 &mut writer,
7428 )
7429 .unwrap();
7430 assert_eq!(
7431 writer.get_content(),
7432 "<my-component><span>1</span><p>true</p></my-component>"
7433 );
7434 }
7435
7436 #[test]
7437 fn test_complex_attr_for_loop() {
7438 let mut fragments = HashMap::new();
7439 fragments.insert(
7440 "index.html".to_string(),
7441 FragmentList {
7442 fragments: vec![WebUIFragment::for_loop(
7443 "item",
7444 "list.items",
7445 "listTemplate",
7446 )],
7447 contains_boundary: false,
7448 },
7449 );
7450 fragments.insert(
7451 "listTemplate".to_string(),
7452 FragmentList {
7453 fragments: vec![
7454 WebUIFragment {
7455 fragment: Some(web_ui_fragment::Fragment::Attribute(
7456 WebUIFragmentAttribute {
7457 name: ":item".into(),
7458 value: "item".into(),
7459 attr_start: true,
7460 complex: true,
7461 ..Default::default()
7462 },
7463 )),
7464 },
7465 WebUIFragment::component("item_component"),
7466 ],
7467
7468 contains_boundary: false,
7469 },
7470 );
7471 fragments.insert(
7472 "item_component".to_string(),
7473 FragmentList {
7474 fragments: vec![
7475 WebUIFragment::raw("<span>"),
7476 WebUIFragment::signal("item.name", false),
7477 WebUIFragment::raw("</span>"),
7478 ],
7479 contains_boundary: false,
7480 },
7481 );
7482 let protocol = WebUIProtocol::new(fragments);
7483 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7484 let mut writer = TestWriter::new();
7485 handle(
7486 &protocol,
7487 &state,
7488 &RenderOptions::new("index.html", "/"),
7489 &mut writer,
7490 )
7491 .unwrap();
7492 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7493 }
7494
7495 #[test]
7496 fn test_complex_attr_nested_for() {
7497 let mut fragments = HashMap::new();
7498 fragments.insert(
7499 "index.html".to_string(),
7500 FragmentList {
7501 fragments: vec![WebUIFragment::for_loop(
7502 "outer",
7503 "data.outer",
7504 "outerTemplate",
7505 )],
7506 contains_boundary: false,
7507 },
7508 );
7509 fragments.insert(
7510 "outerTemplate".to_string(),
7511 FragmentList {
7512 fragments: vec![WebUIFragment::for_loop(
7513 "middle",
7514 "outer.middle",
7515 "middleTemplate",
7516 )],
7517 contains_boundary: false,
7518 },
7519 );
7520 fragments.insert(
7521 "middleTemplate".to_string(),
7522 FragmentList {
7523 fragments: vec![WebUIFragment::for_loop(
7524 "inner",
7525 "middle.inner",
7526 "innerTemplate",
7527 )],
7528 contains_boundary: false,
7529 },
7530 );
7531 fragments.insert(
7532 "innerTemplate".to_string(),
7533 FragmentList {
7534 fragments: vec![
7535 WebUIFragment::raw("<card"),
7536 WebUIFragment {
7537 fragment: Some(web_ui_fragment::Fragment::Attribute(
7538 WebUIFragmentAttribute {
7539 name: ":outer".into(),
7540 value: "outer".into(),
7541 attr_start: true,
7542 complex: true,
7543 ..Default::default()
7544 },
7545 )),
7546 },
7547 WebUIFragment {
7548 fragment: Some(web_ui_fragment::Fragment::Attribute(
7549 WebUIFragmentAttribute {
7550 name: ":middle".into(),
7551 value: "middle".into(),
7552 attr_start: false,
7553 complex: true,
7554 ..Default::default()
7555 },
7556 )),
7557 },
7558 WebUIFragment {
7559 fragment: Some(web_ui_fragment::Fragment::Attribute(
7560 WebUIFragmentAttribute {
7561 name: ":inner".into(),
7562 value: "inner".into(),
7563 attr_start: false,
7564 complex: true,
7565 ..Default::default()
7566 },
7567 )),
7568 },
7569 WebUIFragment::raw(">"),
7570 WebUIFragment::component("card_component"),
7571 WebUIFragment::raw("</card>"),
7572 ],
7573
7574 contains_boundary: false,
7575 },
7576 );
7577 fragments.insert(
7578 "card_component".to_string(),
7579 FragmentList {
7580 fragments: vec![
7581 WebUIFragment::raw("<p>"),
7582 WebUIFragment::signal("outer.label", false),
7583 WebUIFragment::raw(" / "),
7584 WebUIFragment::signal("middle.label", false),
7585 WebUIFragment::raw(" / "),
7586 WebUIFragment::signal("inner.label", false),
7587 WebUIFragment::raw("</p>"),
7588 ],
7589 contains_boundary: false,
7590 },
7591 );
7592 let protocol = WebUIProtocol::new(fragments);
7593 let state = test_json!({"data": {"outer": [
7594 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7595 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7596 ]}});
7597 let mut writer = TestWriter::new();
7598 handle(
7599 &protocol,
7600 &state,
7601 &RenderOptions::new("index.html", "/"),
7602 &mut writer,
7603 )
7604 .unwrap();
7605 assert_eq!(
7606 writer.get_content(),
7607 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7608 );
7609 }
7610
7611 #[test]
7614 fn test_bool_component_state_true() {
7615 let mut fragments = HashMap::new();
7616 fragments.insert(
7617 "index.html".to_string(),
7618 FragmentList {
7619 fragments: vec![
7620 WebUIFragment::raw("<my-component"),
7621 WebUIFragment {
7622 fragment: Some(web_ui_fragment::Fragment::Attribute(
7623 WebUIFragmentAttribute {
7624 name: "disabled".into(),
7625 attr_start: true,
7626 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7627 ..Default::default()
7628 },
7629 )),
7630 },
7631 WebUIFragment::raw(">"),
7632 WebUIFragment::component("my-component"),
7633 WebUIFragment::raw("</my-component>"),
7634 ],
7635
7636 contains_boundary: false,
7637 },
7638 );
7639 fragments.insert(
7640 "my-component".to_string(),
7641 FragmentList {
7642 fragments: vec![
7643 WebUIFragment::if_cond(
7644 ConditionExpr::identifier("disabled"),
7645 "disabledTemplate",
7646 ),
7647 WebUIFragment::if_cond(
7648 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7649 "enabledTemplate",
7650 ),
7651 ],
7652 contains_boundary: false,
7653 },
7654 );
7655 fragments.insert(
7656 "disabledTemplate".to_string(),
7657 FragmentList {
7658 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7659 contains_boundary: false,
7660 },
7661 );
7662 fragments.insert(
7663 "enabledTemplate".to_string(),
7664 FragmentList {
7665 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7666 contains_boundary: false,
7667 },
7668 );
7669 let protocol = WebUIProtocol::new(fragments);
7670 let state = test_json!({"isDisabled": true});
7671 let mut writer = TestWriter::new();
7672 handle(
7673 &protocol,
7674 &state,
7675 &RenderOptions::new("index.html", "/"),
7676 &mut writer,
7677 )
7678 .unwrap();
7679 assert_eq!(
7680 writer.get_content(),
7681 "<my-component disabled><span>Disabled</span></my-component>"
7682 );
7683 }
7684
7685 #[test]
7686 fn test_bool_component_state_false() {
7687 let mut fragments = HashMap::new();
7688 fragments.insert(
7689 "index.html".to_string(),
7690 FragmentList {
7691 fragments: vec![
7692 WebUIFragment::raw("<my-component"),
7693 WebUIFragment {
7694 fragment: Some(web_ui_fragment::Fragment::Attribute(
7695 WebUIFragmentAttribute {
7696 name: "disabled".into(),
7697 attr_start: true,
7698 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7699 ..Default::default()
7700 },
7701 )),
7702 },
7703 WebUIFragment::raw(">"),
7704 WebUIFragment::component("my-component"),
7705 WebUIFragment::raw("</my-component>"),
7706 ],
7707
7708 contains_boundary: false,
7709 },
7710 );
7711 fragments.insert(
7712 "my-component".to_string(),
7713 FragmentList {
7714 fragments: vec![
7715 WebUIFragment::if_cond(
7716 ConditionExpr::identifier("disabled"),
7717 "disabledTemplate",
7718 ),
7719 WebUIFragment::if_cond(
7720 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7721 "enabledTemplate",
7722 ),
7723 ],
7724 contains_boundary: false,
7725 },
7726 );
7727 fragments.insert(
7728 "disabledTemplate".to_string(),
7729 FragmentList {
7730 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7731 contains_boundary: false,
7732 },
7733 );
7734 fragments.insert(
7735 "enabledTemplate".to_string(),
7736 FragmentList {
7737 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7738 contains_boundary: false,
7739 },
7740 );
7741 let protocol = WebUIProtocol::new(fragments);
7742 let state = test_json!({"isDisabled": false});
7743 let mut writer = TestWriter::new();
7744 handle(
7745 &protocol,
7746 &state,
7747 &RenderOptions::new("index.html", "/"),
7748 &mut writer,
7749 )
7750 .unwrap();
7751 assert_eq!(
7752 writer.get_content(),
7753 "<my-component><span>Enabled</span></my-component>"
7754 );
7755 }
7756
7757 #[test]
7758 fn test_bool_component_state_forward() {
7759 let mut fragments = HashMap::new();
7760 fragments.insert(
7761 "index.html".to_string(),
7762 FragmentList {
7763 fragments: vec![
7764 WebUIFragment::raw("<parent-component"),
7765 WebUIFragment {
7766 fragment: Some(web_ui_fragment::Fragment::Attribute(
7767 WebUIFragmentAttribute {
7768 name: "disabled".into(),
7769 attr_start: true,
7770 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7771 ..Default::default()
7772 },
7773 )),
7774 },
7775 WebUIFragment::raw(">"),
7776 WebUIFragment::component("parent-component"),
7777 WebUIFragment::raw("</parent-component>"),
7778 ],
7779
7780 contains_boundary: false,
7781 },
7782 );
7783 fragments.insert(
7784 "parent-component".to_string(),
7785 FragmentList {
7786 fragments: vec![
7787 WebUIFragment::if_cond(
7788 ConditionExpr::identifier("disabled"),
7789 "parentDisabledTemplate",
7790 ),
7791 WebUIFragment::raw("<child-component"),
7792 WebUIFragment {
7793 fragment: Some(web_ui_fragment::Fragment::Attribute(
7794 WebUIFragmentAttribute {
7795 name: "disabled".into(),
7796 attr_start: true,
7797 condition_tree: Some(ConditionExpr::identifier("disabled")),
7798 ..Default::default()
7799 },
7800 )),
7801 },
7802 WebUIFragment::raw(">"),
7803 WebUIFragment::component("child-component"),
7804 WebUIFragment::raw("</child-component>"),
7805 ],
7806
7807 contains_boundary: false,
7808 },
7809 );
7810 fragments.insert(
7811 "parentDisabledTemplate".to_string(),
7812 FragmentList {
7813 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7814 contains_boundary: false,
7815 },
7816 );
7817 fragments.insert(
7818 "child-component".to_string(),
7819 FragmentList {
7820 fragments: vec![
7821 WebUIFragment::if_cond(
7822 ConditionExpr::identifier("disabled"),
7823 "childDisabledTemplate",
7824 ),
7825 WebUIFragment::if_cond(
7826 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7827 "childEnabledTemplate",
7828 ),
7829 ],
7830 contains_boundary: false,
7831 },
7832 );
7833 fragments.insert(
7834 "childDisabledTemplate".to_string(),
7835 FragmentList {
7836 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7837 contains_boundary: false,
7838 },
7839 );
7840 fragments.insert(
7841 "childEnabledTemplate".to_string(),
7842 FragmentList {
7843 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7844 contains_boundary: false,
7845 },
7846 );
7847
7848 {
7850 let protocol = WebUIProtocol::new(fragments.clone());
7851 let state = test_json!({"isDisabled": true});
7852 let mut writer = TestWriter::new();
7853 handle(
7854 &protocol,
7855 &state,
7856 &RenderOptions::new("index.html", "/"),
7857 &mut writer,
7858 )
7859 .unwrap();
7860 assert_eq!(
7861 writer.get_content(),
7862 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7863 );
7864 }
7865
7866 {
7868 let protocol = WebUIProtocol::new(fragments.clone());
7869 let state = test_json!({"isDisabled": false});
7870 let mut writer = TestWriter::new();
7871 handle(
7872 &protocol,
7873 &state,
7874 &RenderOptions::new("index.html", "/"),
7875 &mut writer,
7876 )
7877 .unwrap();
7878 assert_eq!(
7879 writer.get_content(),
7880 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7881 );
7882 }
7883 }
7884
7885 #[test]
7892 fn test_component_with_template() {
7893 let mut fragments = HashMap::new();
7894 fragments.insert(
7895 "index.html".to_string(),
7896 FragmentList {
7897 fragments: vec![
7898 WebUIFragment::raw("<custom-element>"),
7899 WebUIFragment::component("custom-element"),
7900 WebUIFragment::raw("</custom-element>"),
7901 ],
7902 contains_boundary: false,
7903 },
7904 );
7905 fragments.insert(
7906 "custom-element".to_string(),
7907 FragmentList {
7908 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7909 contains_boundary: false,
7910 },
7911 );
7912 let protocol = WebUIProtocol::new(fragments);
7913 let state = test_json!({});
7914 let mut writer = TestWriter::new();
7915 handle(
7916 &protocol,
7917 &state,
7918 &RenderOptions::new("index.html", "/"),
7919 &mut writer,
7920 )
7921 .unwrap();
7922 assert_eq!(
7923 writer.get_content(),
7924 "<custom-element><div>Custom Element</div></custom-element>"
7925 );
7926 assert!(writer.is_ended());
7927 }
7928
7929 #[test]
7930 fn test_component_with_slots() {
7931 let mut fragments = HashMap::new();
7932 fragments.insert(
7933 "index.html".to_string(),
7934 FragmentList {
7935 fragments: vec![
7936 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7937 WebUIFragment::component("custom-element"),
7938 WebUIFragment::raw("Hello World</custom-element>"),
7939 ],
7940 contains_boundary: false,
7941 },
7942 );
7943 fragments.insert(
7944 "custom-element".to_string(),
7945 FragmentList {
7946 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7947 contains_boundary: false,
7948 },
7949 );
7950 let protocol = WebUIProtocol::new(fragments);
7951 let state = test_json!({});
7952 let mut writer = TestWriter::new();
7953 handle(
7954 &protocol,
7955 &state,
7956 &RenderOptions::new("index.html", "/"),
7957 &mut writer,
7958 )
7959 .unwrap();
7960 assert_eq!(
7961 writer.get_content(),
7962 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7963 );
7964 assert!(writer.is_ended());
7965 }
7966
7967 #[test]
7968 fn test_multiple_nested_components() {
7969 let mut fragments = HashMap::new();
7970 fragments.insert(
7971 "index.html".to_string(),
7972 FragmentList {
7973 fragments: vec![
7974 WebUIFragment::raw("<div>"),
7975 WebUIFragment::for_loop("item", "items", "templateRepeat"),
7976 WebUIFragment::raw("</div>"),
7977 ],
7978 contains_boundary: false,
7979 },
7980 );
7981 fragments.insert(
7982 "custom-button".to_string(),
7983 FragmentList {
7984 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7985 contains_boundary: false,
7986 },
7987 );
7988 fragments.insert(
7989 "custom-element".to_string(),
7990 FragmentList {
7991 fragments: vec![
7992 WebUIFragment::raw("<custom-child>"),
7993 WebUIFragment::component("custom-child"),
7994 WebUIFragment::raw("</custom-child><slot></slot>"),
7995 ],
7996 contains_boundary: false,
7997 },
7998 );
7999 fragments.insert(
8000 "custom-child".to_string(),
8001 FragmentList {
8002 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8003 contains_boundary: false,
8004 },
8005 );
8006 fragments.insert(
8007 "templateRepeat".to_string(),
8008 FragmentList {
8009 fragments: vec![
8010 WebUIFragment::raw("<custom-element>"),
8011 WebUIFragment::component("custom-element"),
8012 WebUIFragment::raw("<custom-button>"),
8013 WebUIFragment::component("custom-button"),
8014 WebUIFragment::raw("Ok</custom-button></custom-element>"),
8015 ],
8016 contains_boundary: false,
8017 },
8018 );
8019 let protocol = WebUIProtocol::new(fragments);
8020 let state = test_json!({"items": [{"name": "Item1"}]});
8021 let mut writer = TestWriter::new();
8022 handle(
8023 &protocol,
8024 &state,
8025 &RenderOptions::new("index.html", "/"),
8026 &mut writer,
8027 )
8028 .unwrap();
8029 assert_eq!(
8030 writer.get_content(),
8031 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8032 );
8033 assert!(writer.is_ended());
8034 }
8035
8036 #[test]
8039 fn test_if_with_binary_expression() {
8040 let mut fragments = HashMap::new();
8041 fragments.insert(
8042 "index.html".to_string(),
8043 FragmentList {
8044 fragments: vec![
8045 WebUIFragment::raw("<div>"),
8046 WebUIFragment::if_cond(
8047 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8048 "if-1",
8049 ),
8050 WebUIFragment::raw("</div>"),
8051 ],
8052 contains_boundary: false,
8053 },
8054 );
8055 fragments.insert(
8056 "if-1".to_string(),
8057 FragmentList {
8058 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8059 contains_boundary: false,
8060 },
8061 );
8062 let protocol = WebUIProtocol::new(fragments);
8063
8064 let state_true = test_json!({"x": 10});
8066 let mut writer_true = TestWriter::new();
8067 handle(
8068 &protocol,
8069 &state_true,
8070 &RenderOptions::new("index.html", "/"),
8071 &mut writer_true,
8072 )
8073 .unwrap();
8074 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8075
8076 let state_false = test_json!({"x": 1});
8078 let mut writer_false = TestWriter::new();
8079 handle(
8080 &protocol,
8081 &state_false,
8082 &RenderOptions::new("index.html", "/"),
8083 &mut writer_false,
8084 )
8085 .unwrap();
8086 assert_eq!(writer_false.get_content(), "<div></div>");
8087 }
8088
8089 #[test]
8090 fn test_for_if_overlapping_local_state() {
8091 let mut fragments = HashMap::new();
8092 fragments.insert(
8093 "index.html".to_string(),
8094 FragmentList {
8095 fragments: vec![
8096 WebUIFragment::raw("<div>"),
8097 WebUIFragment::for_loop("item", "items", "template1"),
8098 WebUIFragment::raw("</div>"),
8099 ],
8100 contains_boundary: false,
8101 },
8102 );
8103 fragments.insert(
8104 "template1".to_string(),
8105 FragmentList {
8106 fragments: vec![
8107 WebUIFragment::raw("<div>"),
8108 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8109 WebUIFragment::raw("</div>"),
8110 ],
8111 contains_boundary: false,
8112 },
8113 );
8114 fragments.insert(
8115 "ifBlock".to_string(),
8116 FragmentList {
8117 fragments: vec![
8118 WebUIFragment::raw("<span>"),
8119 WebUIFragment::signal("item.label", false),
8120 WebUIFragment::raw("</span>"),
8121 ],
8122 contains_boundary: false,
8123 },
8124 );
8125 let protocol = WebUIProtocol::new(fragments);
8126 let state = test_json!({
8127 "flag": false,
8128 "items": [
8129 {"label": "A", "flag": true},
8130 {"label": "B", "flag": false},
8131 {"label": "C", "flag": true}
8132 ]
8133 });
8134 let mut writer = TestWriter::new();
8135 handle(
8136 &protocol,
8137 &state,
8138 &RenderOptions::new("index.html", "/"),
8139 &mut writer,
8140 )
8141 .unwrap();
8142 assert_eq!(
8143 writer.get_content(),
8144 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8145 );
8146 }
8147
8148 #[test]
8149 fn test_for_if_global_flag_no_effect() {
8150 let mut fragments = HashMap::new();
8151 fragments.insert(
8152 "index.html".to_string(),
8153 FragmentList {
8154 fragments: vec![
8155 WebUIFragment::raw("<div>"),
8156 WebUIFragment::for_loop("item", "items", "template1"),
8157 WebUIFragment::raw("</div>"),
8158 ],
8159 contains_boundary: false,
8160 },
8161 );
8162 fragments.insert(
8163 "template1".to_string(),
8164 FragmentList {
8165 fragments: vec![
8166 WebUIFragment::raw("<div>"),
8167 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8168 WebUIFragment::raw("</div>"),
8169 ],
8170 contains_boundary: false,
8171 },
8172 );
8173 fragments.insert(
8174 "ifBlock".to_string(),
8175 FragmentList {
8176 fragments: vec![
8177 WebUIFragment::raw("<span>"),
8178 WebUIFragment::signal("item.label", false),
8179 WebUIFragment::raw("</span>"),
8180 ],
8181 contains_boundary: false,
8182 },
8183 );
8184 let protocol = WebUIProtocol::new(fragments);
8185 let state = test_json!({
8186 "item": {"flag": true},
8187 "items": [
8188 {"label": "A", "flag": false},
8189 {"label": "B", "flag": true},
8190 {"label": "C", "flag": false}
8191 ]
8192 });
8193 let mut writer = TestWriter::new();
8194 handle(
8195 &protocol,
8196 &state,
8197 &RenderOptions::new("index.html", "/"),
8198 &mut writer,
8199 )
8200 .unwrap();
8201 assert_eq!(
8202 writer.get_content(),
8203 "<div><div></div><div><span>B</span></div><div></div></div>"
8204 );
8205 }
8206
8207 #[test]
8210 fn test_recursive_template_refs() {
8211 let mut fragments = HashMap::new();
8212 fragments.insert(
8213 "index.html".to_string(),
8214 FragmentList {
8215 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8216 contains_boundary: false,
8217 },
8218 );
8219 fragments.insert(
8220 "static".to_string(),
8221 FragmentList {
8222 fragments: vec![
8223 WebUIFragment::raw("<div expanded=\""),
8224 WebUIFragment::signal("item.expanded", false),
8225 WebUIFragment::raw("\" class=\""),
8226 WebUIFragment::signal("testScenario", false),
8227 WebUIFragment::raw("\"><span>"),
8228 WebUIFragment::signal("item.name", false),
8229 WebUIFragment::raw("</span>"),
8230 WebUIFragment::for_loop("item", "item.children", "static"),
8231 WebUIFragment::raw("</div>"),
8232 ],
8233 contains_boundary: false,
8234 },
8235 );
8236 let protocol = WebUIProtocol::new(fragments);
8237 let state = test_json!({
8238 "testScenario": "RecursiveTemplatesWithGlobalState",
8239 "items": [
8240 {"name": "A", "expanded": "false", "children": []},
8241 {"name": "B", "expanded": "true", "children": [
8242 {"name": "C", "expanded": "false"},
8243 {"name": "D", "expanded": "false"}
8244 ]},
8245 {"name": "E", "expanded": "false"}
8246 ]
8247 });
8248 let mut writer = TestWriter::new();
8249 handle(
8250 &protocol,
8251 &state,
8252 &RenderOptions::new("index.html", "/"),
8253 &mut writer,
8254 )
8255 .unwrap();
8256 assert_eq!(
8257 writer.get_content(),
8258 "<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>"
8259 );
8260 }
8261
8262 #[test]
8265 fn test_component_in_for_no_local_access() {
8266 let mut fragments = HashMap::new();
8267 fragments.insert(
8268 "index.html".to_string(),
8269 FragmentList {
8270 fragments: vec![
8271 WebUIFragment::raw("<div>"),
8272 WebUIFragment::for_loop("item", "items", "templateComponent"),
8273 WebUIFragment::raw("</div>"),
8274 ],
8275 contains_boundary: false,
8276 },
8277 );
8278 fragments.insert(
8279 "templateComponent".to_string(),
8280 FragmentList {
8281 fragments: vec![
8282 WebUIFragment::raw("<component-tag>"),
8283 WebUIFragment::component("my-component"),
8284 WebUIFragment::raw("</component-tag>"),
8285 ],
8286 contains_boundary: false,
8287 },
8288 );
8289 fragments.insert(
8290 "my-component".to_string(),
8291 FragmentList {
8292 fragments: vec![
8293 WebUIFragment::raw("<span>"),
8294 WebUIFragment::signal("name", false),
8295 WebUIFragment::raw("</span>"),
8296 ],
8297 contains_boundary: false,
8298 },
8299 );
8300 let protocol = WebUIProtocol::new(fragments);
8301 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8302 let mut writer = TestWriter::new();
8303 handle(
8304 &protocol,
8305 &state,
8306 &RenderOptions::new("index.html", "/"),
8307 &mut writer,
8308 )
8309 .unwrap();
8310 assert_eq!(
8311 writer.get_content(),
8312 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8313 );
8314 }
8315
8316 #[test]
8317 fn test_nested_for_hierarchical_state() {
8318 let mut fragments = HashMap::new();
8319 fragments.insert(
8320 "index.html".to_string(),
8321 FragmentList {
8322 fragments: vec![
8323 WebUIFragment::raw("<div>"),
8324 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8325 WebUIFragment::raw("</div>"),
8326 ],
8327 contains_boundary: false,
8328 },
8329 );
8330 fragments.insert(
8331 "outerTemplate".to_string(),
8332 FragmentList {
8333 fragments: vec![
8334 WebUIFragment::raw("<section>"),
8335 WebUIFragment::signal("globalPrefix", false),
8336 WebUIFragment::signal("outerItem.outerLabel", false),
8337 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8338 WebUIFragment::raw("</section>"),
8339 ],
8340 contains_boundary: false,
8341 },
8342 );
8343 fragments.insert(
8344 "innerTemplate".to_string(),
8345 FragmentList {
8346 fragments: vec![
8347 WebUIFragment::raw("<p>"),
8348 WebUIFragment::signal("globalPrefix", false),
8349 WebUIFragment::signal("outerItem.outerLabel", false),
8350 WebUIFragment::raw(": "),
8351 WebUIFragment::signal("innerItem.innerLabel", false),
8352 WebUIFragment::raw("</p>"),
8353 ],
8354 contains_boundary: false,
8355 },
8356 );
8357 let protocol = WebUIProtocol::new(fragments);
8358 let state = test_json!({
8359 "globalPrefix": "Prefix: ",
8360 "outerItems": [
8361 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8362 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8363 ]
8364 });
8365 let mut writer = TestWriter::new();
8366 handle(
8367 &protocol,
8368 &state,
8369 &RenderOptions::new("index.html", "/"),
8370 &mut writer,
8371 )
8372 .unwrap();
8373 assert_eq!(
8374 writer.get_content(),
8375 "<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>"
8376 );
8377 }
8378
8379 #[test]
8380 fn test_component_in_for_global_only() {
8381 let mut fragments = HashMap::new();
8382 fragments.insert(
8383 "index.html".to_string(),
8384 FragmentList {
8385 fragments: vec![
8386 WebUIFragment::raw("<div>"),
8387 WebUIFragment::for_loop("item", "items", "templateComponent"),
8388 WebUIFragment::raw("</div>"),
8389 ],
8390 contains_boundary: false,
8391 },
8392 );
8393 fragments.insert(
8394 "templateComponent".to_string(),
8395 FragmentList {
8396 fragments: vec![
8397 WebUIFragment::raw("<component-tag>"),
8398 WebUIFragment::component("my-component"),
8399 WebUIFragment::raw("</component-tag>"),
8400 ],
8401 contains_boundary: false,
8402 },
8403 );
8404 fragments.insert(
8405 "my-component".to_string(),
8406 FragmentList {
8407 fragments: vec![
8408 WebUIFragment::raw("<span>"),
8409 WebUIFragment::signal("name", false),
8410 WebUIFragment::raw("-"),
8411 WebUIFragment::signal("globalSuffix", false),
8412 WebUIFragment::raw("</span>"),
8413 ],
8414 contains_boundary: false,
8415 },
8416 );
8417 let protocol = WebUIProtocol::new(fragments);
8418 let state =
8419 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8420 let mut writer = TestWriter::new();
8421 handle(
8422 &protocol,
8423 &state,
8424 &RenderOptions::new("index.html", "/"),
8425 &mut writer,
8426 )
8427 .unwrap();
8428 assert_eq!(
8429 writer.get_content(),
8430 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8431 );
8432 }
8433
8434 #[test]
8435 fn test_component_no_item_moniker() {
8436 let mut fragments = HashMap::new();
8437 fragments.insert(
8438 "index.html".to_string(),
8439 FragmentList {
8440 fragments: vec![
8441 WebUIFragment::raw("<div>"),
8442 WebUIFragment::for_loop("item", "items", "templateComponent"),
8443 WebUIFragment::raw("</div>"),
8444 ],
8445 contains_boundary: false,
8446 },
8447 );
8448 fragments.insert(
8449 "templateComponent".to_string(),
8450 FragmentList {
8451 fragments: vec![
8452 WebUIFragment::raw("<component-tag>"),
8453 WebUIFragment::component("my-component"),
8454 WebUIFragment::raw("</component-tag>"),
8455 ],
8456 contains_boundary: false,
8457 },
8458 );
8459 fragments.insert(
8460 "my-component".to_string(),
8461 FragmentList {
8462 fragments: vec![
8463 WebUIFragment::raw("<span>"),
8464 WebUIFragment::signal("item.name", false),
8465 WebUIFragment::raw("-"),
8466 WebUIFragment::signal("globalSuffix", false),
8467 WebUIFragment::raw("</span>"),
8468 ],
8469 contains_boundary: false,
8470 },
8471 );
8472 let protocol = WebUIProtocol::new(fragments);
8473 let state =
8474 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8475 let mut writer = TestWriter::new();
8476 handle(
8477 &protocol,
8478 &state,
8479 &RenderOptions::new("index.html", "/"),
8480 &mut writer,
8481 )
8482 .unwrap();
8483 assert_eq!(
8484 writer.get_content(),
8485 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8486 );
8487 }
8488
8489 #[test]
8490 fn test_for_nonqualified_uses_global() {
8491 let mut fragments = HashMap::new();
8492 fragments.insert(
8493 "index.html".to_string(),
8494 FragmentList {
8495 fragments: vec![
8496 WebUIFragment::raw("<div>"),
8497 WebUIFragment::for_loop("item", "items", "template1"),
8498 WebUIFragment::raw("</div>"),
8499 ],
8500 contains_boundary: false,
8501 },
8502 );
8503 fragments.insert(
8504 "template1".to_string(),
8505 FragmentList {
8506 fragments: vec![
8507 WebUIFragment::raw("<span>"),
8508 WebUIFragment::signal("name", false),
8509 WebUIFragment::raw("</span>"),
8510 ],
8511 contains_boundary: false,
8512 },
8513 );
8514 let protocol = WebUIProtocol::new(fragments);
8515 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8516 let mut writer = TestWriter::new();
8517 handle(
8518 &protocol,
8519 &state,
8520 &RenderOptions::new("index.html", "/"),
8521 &mut writer,
8522 )
8523 .unwrap();
8524 assert_eq!(
8525 writer.get_content(),
8526 "<div><span>GlobalName</span><span>GlobalName</span></div>"
8527 );
8528 }
8529
8530 #[test]
8531 fn test_nested_for_if_interleaved() {
8532 let mut fragments = HashMap::new();
8533 fragments.insert(
8534 "index.html".to_string(),
8535 FragmentList {
8536 fragments: vec![
8537 WebUIFragment::raw("<div>"),
8538 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8539 WebUIFragment::raw("</div>"),
8540 ],
8541 contains_boundary: false,
8542 },
8543 );
8544 fragments.insert(
8545 "outerTemplate".to_string(),
8546 FragmentList {
8547 fragments: vec![
8548 WebUIFragment::raw("<section>"),
8549 WebUIFragment::signal("globalPrefix", false),
8550 WebUIFragment::signal("outerItem.outerLabel", false),
8551 WebUIFragment::if_cond(
8552 ConditionExpr::identifier("outerItem.include"),
8553 "ifTemplate",
8554 ),
8555 WebUIFragment::raw("</section>"),
8556 ],
8557 contains_boundary: false,
8558 },
8559 );
8560 fragments.insert(
8561 "ifTemplate".to_string(),
8562 FragmentList {
8563 fragments: vec![
8564 WebUIFragment::raw("<div>"),
8565 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8566 WebUIFragment::raw("</div>"),
8567 ],
8568 contains_boundary: false,
8569 },
8570 );
8571 fragments.insert(
8572 "innerTemplate".to_string(),
8573 FragmentList {
8574 fragments: vec![
8575 WebUIFragment::raw("<p>"),
8576 WebUIFragment::signal("globalSuffix", false),
8577 WebUIFragment::raw(": "),
8578 WebUIFragment::signal("innerItem.innerLabel", false),
8579 WebUIFragment::raw("</p>"),
8580 ],
8581 contains_boundary: false,
8582 },
8583 );
8584 let protocol = WebUIProtocol::new(fragments);
8585 let state = test_json!({
8586 "globalPrefix": "Prefix: ",
8587 "globalSuffix": "Suffix",
8588 "outerItems": [
8589 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8590 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8591 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8592 ]
8593 });
8594 let mut writer = TestWriter::new();
8595 handle(
8596 &protocol,
8597 &state,
8598 &RenderOptions::new("index.html", "/"),
8599 &mut writer,
8600 )
8601 .unwrap();
8602 assert_eq!(
8603 writer.get_content(),
8604 "<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>"
8605 );
8606 }
8607
8608 #[test]
8609 fn test_nested_for_if_outer_state() {
8610 let mut fragments = HashMap::new();
8611 fragments.insert(
8612 "index.html".to_string(),
8613 FragmentList {
8614 fragments: vec![
8615 WebUIFragment::raw("<div>"),
8616 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8617 WebUIFragment::raw("</div>"),
8618 ],
8619 contains_boundary: false,
8620 },
8621 );
8622 fragments.insert(
8623 "outerTemplate".to_string(),
8624 FragmentList {
8625 fragments: vec![
8626 WebUIFragment::raw("<section>"),
8627 WebUIFragment::signal("globalPrefix", false),
8628 WebUIFragment::signal("outerItem.label", false),
8629 WebUIFragment::for_loop(
8630 "middleItem",
8631 "outerItem.middleItems",
8632 "middleTemplate",
8633 ),
8634 WebUIFragment::raw("</section>"),
8635 ],
8636 contains_boundary: false,
8637 },
8638 );
8639 fragments.insert(
8640 "middleTemplate".to_string(),
8641 FragmentList {
8642 fragments: vec![
8643 WebUIFragment::raw("<div>"),
8644 WebUIFragment::if_cond(
8645 ConditionExpr::identifier("outerItem.active"),
8646 "ifTemplate",
8647 ),
8648 WebUIFragment::raw("</div>"),
8649 ],
8650 contains_boundary: false,
8651 },
8652 );
8653 fragments.insert(
8654 "ifTemplate".to_string(),
8655 FragmentList {
8656 fragments: vec![
8657 WebUIFragment::raw("<p>"),
8658 WebUIFragment::signal("middleItem.value", false),
8659 WebUIFragment::raw("</p>"),
8660 ],
8661 contains_boundary: false,
8662 },
8663 );
8664 let protocol = WebUIProtocol::new(fragments);
8665 let state = test_json!({
8666 "globalPrefix": "GP-",
8667 "outerItems": [
8668 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8669 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8670 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8671 ]
8672 });
8673 let mut writer = TestWriter::new();
8674 handle(
8675 &protocol,
8676 &state,
8677 &RenderOptions::new("index.html", "/"),
8678 &mut writer,
8679 )
8680 .unwrap();
8681 assert_eq!(
8682 writer.get_content(),
8683 "<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>"
8684 );
8685 }
8686
8687 #[test]
8688 fn test_nested_for_if_inner_state() {
8689 let mut fragments = HashMap::new();
8690 fragments.insert(
8691 "index.html".to_string(),
8692 FragmentList {
8693 fragments: vec![
8694 WebUIFragment::raw("<div>"),
8695 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8696 WebUIFragment::raw("</div>"),
8697 ],
8698 contains_boundary: false,
8699 },
8700 );
8701 fragments.insert(
8702 "outerTemplate".to_string(),
8703 FragmentList {
8704 fragments: vec![
8705 WebUIFragment::raw("<section>"),
8706 WebUIFragment::signal("outerItem.label", false),
8707 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8708 WebUIFragment::raw("</section>"),
8709 ],
8710 contains_boundary: false,
8711 },
8712 );
8713 fragments.insert(
8714 "innerTemplate".to_string(),
8715 FragmentList {
8716 fragments: vec![
8717 WebUIFragment::raw("<article>"),
8718 WebUIFragment::if_cond(
8719 ConditionExpr::identifier("innerItem.show"),
8720 "ifTemplate",
8721 ),
8722 WebUIFragment::raw("</article>"),
8723 ],
8724 contains_boundary: false,
8725 },
8726 );
8727 fragments.insert(
8728 "ifTemplate".to_string(),
8729 FragmentList {
8730 fragments: vec![
8731 WebUIFragment::raw("<p>"),
8732 WebUIFragment::signal("innerItem.detail", false),
8733 WebUIFragment::raw("</p>"),
8734 ],
8735 contains_boundary: false,
8736 },
8737 );
8738 let protocol = WebUIProtocol::new(fragments);
8739 let state = test_json!({
8740 "outerItems": [
8741 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8742 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
8743 ]
8744 });
8745 let mut writer = TestWriter::new();
8746 handle(
8747 &protocol,
8748 &state,
8749 &RenderOptions::new("index.html", "/"),
8750 &mut writer,
8751 )
8752 .unwrap();
8753 assert_eq!(
8754 writer.get_content(),
8755 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8756 );
8757 }
8758
8759 #[test]
8760 fn test_for_merge_local_global_monikers() {
8761 let mut fragments = HashMap::new();
8762 fragments.insert(
8763 "index.html".to_string(),
8764 FragmentList {
8765 fragments: vec![
8766 WebUIFragment::raw("<div>"),
8767 WebUIFragment::for_loop("item", "items", "template1"),
8768 WebUIFragment::raw("</div>"),
8769 ],
8770 contains_boundary: false,
8771 },
8772 );
8773 fragments.insert(
8774 "template1".to_string(),
8775 FragmentList {
8776 fragments: vec![
8777 WebUIFragment::raw("<span>"),
8778 WebUIFragment::signal("item.name", false),
8779 WebUIFragment::raw("-"),
8780 WebUIFragment::signal("item.globalValue", false),
8781 WebUIFragment::raw("-"),
8782 WebUIFragment::signal("item.localOnly", false),
8783 WebUIFragment::raw("-"),
8784 WebUIFragment::signal("item.otherVal", false),
8785 WebUIFragment::raw("</span>"),
8786 ],
8787 contains_boundary: false,
8788 },
8789 );
8790 let protocol = WebUIProtocol::new(fragments);
8791 let state = test_json!({
8792 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8793 "items": [
8794 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8795 {"name": "Local2", "localOnly": "Only2"}
8796 ]
8797 });
8798 let mut writer = TestWriter::new();
8799 handle(
8800 &protocol,
8801 &state,
8802 &RenderOptions::new("index.html", "/"),
8803 &mut writer,
8804 )
8805 .unwrap();
8806 assert_eq!(
8807 writer.get_content(),
8808 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8809 );
8810 }
8811
8812 #[test]
8813 fn test_component_in_for_global_moniker_shadow() {
8814 let mut fragments = HashMap::new();
8815 fragments.insert(
8816 "index.html".to_string(),
8817 FragmentList {
8818 fragments: vec![
8819 WebUIFragment::raw("<div>"),
8820 WebUIFragment::for_loop("item", "items", "templateComponent"),
8821 WebUIFragment::raw("</div>"),
8822 ],
8823 contains_boundary: false,
8824 },
8825 );
8826 fragments.insert(
8827 "templateComponent".to_string(),
8828 FragmentList {
8829 fragments: vec![
8830 WebUIFragment::raw("<component-tag>"),
8831 WebUIFragment::component("my-component"),
8832 WebUIFragment::raw("</component-tag>"),
8833 ],
8834 contains_boundary: false,
8835 },
8836 );
8837 fragments.insert(
8838 "my-component".to_string(),
8839 FragmentList {
8840 fragments: vec![
8841 WebUIFragment::raw("<span>"),
8842 WebUIFragment::signal("name", false),
8843 WebUIFragment::raw("-"),
8844 WebUIFragment::signal("item.globalValue", false),
8845 WebUIFragment::raw("-"),
8846 WebUIFragment::signal("localOnly", false),
8847 WebUIFragment::raw("-"),
8848 WebUIFragment::signal("item.otherVal", false),
8849 WebUIFragment::raw("</span>"),
8850 ],
8851 contains_boundary: false,
8852 },
8853 );
8854 let protocol = WebUIProtocol::new(fragments);
8855 let state = test_json!({
8856 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8857 "items": [
8858 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8859 {"name": "Local2", "localOnly": "Only2"}
8860 ]
8861 });
8862 let mut writer = TestWriter::new();
8863 handle(
8864 &protocol,
8865 &state,
8866 &RenderOptions::new("index.html", "/"),
8867 &mut writer,
8868 )
8869 .unwrap();
8870 assert_eq!(
8871 writer.get_content(),
8872 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8873 );
8874 }
8875
8876 #[test]
8877 fn test_if_in_nested_for_local_flag() {
8878 let mut fragments = HashMap::new();
8879 fragments.insert(
8880 "index.html".to_string(),
8881 FragmentList {
8882 fragments: vec![
8883 WebUIFragment::raw("<div>"),
8884 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8885 WebUIFragment::raw("</div>"),
8886 ],
8887 contains_boundary: false,
8888 },
8889 );
8890 fragments.insert(
8891 "outerTemplate".to_string(),
8892 FragmentList {
8893 fragments: vec![
8894 WebUIFragment::raw("<section>"),
8895 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8896 WebUIFragment::raw("</section>"),
8897 ],
8898 contains_boundary: false,
8899 },
8900 );
8901 fragments.insert(
8902 "innerTemplate".to_string(),
8903 FragmentList {
8904 fragments: vec![WebUIFragment::if_cond(
8905 ConditionExpr::identifier("inner_item.flag"),
8906 "ifInner",
8907 )],
8908 contains_boundary: false,
8909 },
8910 );
8911 fragments.insert(
8912 "ifInner".to_string(),
8913 FragmentList {
8914 fragments: vec![
8915 WebUIFragment::raw("<p>"),
8916 WebUIFragment::signal("inner_item.value", false),
8917 WebUIFragment::raw("</p>"),
8918 ],
8919 contains_boundary: false,
8920 },
8921 );
8922 let protocol = WebUIProtocol::new(fragments);
8923 let state = test_json!({
8924 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8925 "inner_item": {"flag": false}
8926 });
8927 let mut writer = TestWriter::new();
8928 handle(
8929 &protocol,
8930 &state,
8931 &RenderOptions::new("index.html", "/"),
8932 &mut writer,
8933 )
8934 .unwrap();
8935 assert_eq!(
8936 writer.get_content(),
8937 "<div><section><p>X</p></section></div>"
8938 );
8939 }
8940
8941 #[test]
8942 fn test_if_in_nested_for_global_fallback() {
8943 let mut fragments = HashMap::new();
8944 fragments.insert(
8945 "index.html".to_string(),
8946 FragmentList {
8947 fragments: vec![
8948 WebUIFragment::raw("<div>"),
8949 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8950 WebUIFragment::raw("</div>"),
8951 ],
8952 contains_boundary: false,
8953 },
8954 );
8955 fragments.insert(
8956 "outerTemplate".to_string(),
8957 FragmentList {
8958 fragments: vec![
8959 WebUIFragment::raw("<section>"),
8960 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8961 WebUIFragment::raw("</section>"),
8962 ],
8963 contains_boundary: false,
8964 },
8965 );
8966 fragments.insert(
8967 "innerTemplate".to_string(),
8968 FragmentList {
8969 fragments: vec![WebUIFragment::if_cond(
8970 ConditionExpr::identifier("inner_item.flag"),
8971 "ifInner",
8972 )],
8973 contains_boundary: false,
8974 },
8975 );
8976 fragments.insert(
8977 "ifInner".to_string(),
8978 FragmentList {
8979 fragments: vec![
8980 WebUIFragment::raw("<p>"),
8981 WebUIFragment::signal("inner_item.value", false),
8982 WebUIFragment::raw("</p>"),
8983 ],
8984 contains_boundary: false,
8985 },
8986 );
8987 let protocol = WebUIProtocol::new(fragments);
8988 let state = test_json!({
8989 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8990 "inner_item": {"flag": true}
8991 });
8992 let mut writer = TestWriter::new();
8993 handle(
8994 &protocol,
8995 &state,
8996 &RenderOptions::new("index.html", "/"),
8997 &mut writer,
8998 )
8999 .unwrap();
9000 assert_eq!(
9001 writer.get_content(),
9002 "<div><section><p>X</p><p>Y</p></section></div>"
9003 );
9004 }
9005
9006 #[test]
9007 fn test_if_mixed_for_monikers() {
9008 let mut fragments = HashMap::new();
9009 fragments.insert(
9010 "index.html".to_string(),
9011 FragmentList {
9012 fragments: vec![
9013 WebUIFragment::raw("<div>"),
9014 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9015 WebUIFragment::raw("</div>"),
9016 ],
9017 contains_boundary: false,
9018 },
9019 );
9020 fragments.insert(
9021 "outerTemplate".to_string(),
9022 FragmentList {
9023 fragments: vec![
9024 WebUIFragment::raw("<section>"),
9025 WebUIFragment::signal("outer.outerLabel", false),
9026 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9027 WebUIFragment::raw("</section>"),
9028 ],
9029 contains_boundary: false,
9030 },
9031 );
9032 fragments.insert(
9033 "innerTemplate".to_string(),
9034 FragmentList {
9035 fragments: vec![WebUIFragment::if_cond(
9036 ConditionExpr::compound(
9037 ConditionExpr::identifier("outer.active"),
9038 LogicalOperator::And,
9039 ConditionExpr::predicate(
9040 "inner.value",
9041 ComparisonOperator::GreaterThan,
9042 "globalLimit",
9043 ),
9044 ),
9045 "ifInner",
9046 )],
9047 contains_boundary: false,
9048 },
9049 );
9050 fragments.insert(
9051 "ifInner".to_string(),
9052 FragmentList {
9053 fragments: vec![
9054 WebUIFragment::raw("<p>"),
9055 WebUIFragment::signal("inner.value", false),
9056 WebUIFragment::raw("</p>"),
9057 ],
9058 contains_boundary: false,
9059 },
9060 );
9061 let protocol = WebUIProtocol::new(fragments);
9062 let state = test_json!({
9063 "globalLimit": 10,
9064 "list": {"outerItems": [
9065 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9066 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9067 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9068 ]}
9069 });
9070 let mut writer = TestWriter::new();
9071 handle(
9072 &protocol,
9073 &state,
9074 &RenderOptions::new("index.html", "/"),
9075 &mut writer,
9076 )
9077 .unwrap();
9078 assert_eq!(
9079 writer.get_content(),
9080 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9081 );
9082 }
9083
9084 fn make_route_protocol() -> WebUIProtocol {
9087 use webui_protocol::WebUiFragmentRoute;
9088
9089 let mut fragments = HashMap::new();
9090
9091 fragments.insert(
9093 "index.html".to_string(),
9094 FragmentList {
9095 fragments: vec![
9096 WebUIFragment::raw("<h1>Shell</h1>"),
9097 WebUIFragment::route_from(WebUiFragmentRoute {
9098 path: "/".into(),
9099 fragment_id: "dash-page".into(),
9100 exact: true,
9101 keep_alive: false,
9102 ..Default::default()
9103 }),
9104 WebUIFragment::route_from(WebUiFragmentRoute {
9105 path: "/contacts/:id".into(),
9106 fragment_id: "detail-page".into(),
9107 exact: true,
9108 keep_alive: true,
9109 ..Default::default()
9110 }),
9111 ],
9112
9113 contains_boundary: false,
9114 },
9115 );
9116
9117 fragments.insert(
9119 "dash-page".to_string(),
9120 FragmentList {
9121 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9122 contains_boundary: false,
9123 },
9124 );
9125
9126 fragments.insert(
9128 "detail-page".to_string(),
9129 FragmentList {
9130 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9131 contains_boundary: false,
9132 },
9133 );
9134
9135 WebUIProtocol::new(fragments)
9136 }
9137
9138 fn make_nested_route_protocol() -> WebUIProtocol {
9139 use webui_protocol::WebUiFragmentRoute;
9140
9141 let mut fragments = HashMap::new();
9142
9143 fragments.insert(
9144 "index.html".to_string(),
9145 FragmentList {
9146 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9147 path: "/".into(),
9148 fragment_id: "app-shell".into(),
9149 exact: false,
9150 children: vec![WebUiFragmentRoute {
9151 path: "sections/:id".into(),
9152 fragment_id: "section-comp".into(),
9153 exact: false,
9154 children: vec![WebUiFragmentRoute {
9155 path: "topics/:topicId".into(),
9156 fragment_id: "topic-comp".into(),
9157 exact: true,
9158 children: vec![],
9159 keep_alive: false,
9160 ..Default::default()
9161 }],
9162 keep_alive: false,
9163 ..Default::default()
9164 }],
9165 keep_alive: false,
9166 ..Default::default()
9167 })],
9168
9169 contains_boundary: false,
9170 },
9171 );
9172
9173 fragments.insert(
9174 "app-shell".to_string(),
9175 FragmentList {
9176 fragments: vec![
9177 WebUIFragment::raw("<h1>Shell</h1>"),
9178 WebUIFragment::outlet(),
9179 ],
9180 contains_boundary: false,
9181 },
9182 );
9183
9184 fragments.insert(
9185 "section-comp".to_string(),
9186 FragmentList {
9187 fragments: vec![
9188 WebUIFragment::raw("<h2>Section</h2>"),
9189 WebUIFragment::outlet(),
9190 ],
9191 contains_boundary: false,
9192 },
9193 );
9194
9195 fragments.insert(
9196 "topic-comp".to_string(),
9197 FragmentList {
9198 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9199 contains_boundary: false,
9200 },
9201 );
9202
9203 WebUIProtocol::new(fragments)
9204 }
9205
9206 #[test]
9207 fn test_route_renders_shell_always() {
9208 let protocol = make_route_protocol();
9209 let state = test_json!({});
9210 let mut writer = TestWriter::new();
9211 handle(
9212 &protocol,
9213 &state,
9214 &RenderOptions::new("index.html", "/"),
9215 &mut writer,
9216 )
9217 .unwrap();
9218 let html = writer.get_content();
9219
9220 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9222 assert!(html.contains(" active>"), "matched route should be active");
9224 assert!(
9226 html.contains("style=\"display:none\""),
9227 "non-matched routes should be hidden"
9228 );
9229 }
9230
9231 #[test]
9232 fn test_route_matched_renders_visible() {
9233 let protocol = make_route_protocol();
9234 let state = test_json!({});
9235 let mut writer = TestWriter::new();
9236 handle(
9237 &protocol,
9238 &state,
9239 &RenderOptions::new("index.html", "/"),
9240 &mut writer,
9241 )
9242 .unwrap();
9243 let html = writer.get_content();
9244
9245 assert!(
9247 html.contains("<webui-route path=\"/\""),
9248 "dashboard route should exist"
9249 );
9250 assert!(
9251 html.contains("active>") && html.contains("<dash-page"),
9252 "matched route should be active with component tag: {html}"
9253 );
9254 assert!(
9255 html.contains("<p>Dashboard</p>"),
9256 "matched route should have content"
9257 );
9258 }
9259
9260 #[test]
9261 fn test_route_non_matched_renders_hidden_empty() {
9262 let protocol = make_route_protocol();
9263 let state = test_json!({});
9264 let mut writer = TestWriter::new();
9265 handle(
9266 &protocol,
9267 &state,
9268 &RenderOptions::new("index.html", "/"),
9269 &mut writer,
9270 )
9271 .unwrap();
9272 let html = writer.get_content();
9273
9274 assert!(
9276 html.contains("<webui-route path=\"/contacts/:id\""),
9277 "detail route element should exist"
9278 );
9279 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9281 let after_detail = &html[detail_start..];
9282 assert!(
9283 after_detail.contains("style=\"display:none\">")
9284 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9285 "non-matched route should be hidden: {after_detail}"
9286 );
9287 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9289 let detail_body = &after_detail[..detail_end];
9290 assert!(
9291 !detail_body.contains("<detail-page"),
9292 "non-matched route should not render component content: {detail_body}"
9293 );
9294 }
9295
9296 #[test]
9297 fn test_route_parameterized_match() {
9298 let protocol = make_route_protocol();
9299 let state = test_json!({});
9300 let mut writer = TestWriter::new();
9301 handle(
9302 &protocol,
9303 &state,
9304 &RenderOptions::new("index.html", "/contacts/42"),
9305 &mut writer,
9306 )
9307 .unwrap();
9308 let html = writer.get_content();
9309
9310 assert!(
9312 html.contains("active>") && html.contains("<detail-page"),
9313 "detail route should be active: {html}"
9314 );
9315 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9316 let dash_start = html
9318 .find("component=\"dash-page\"")
9319 .expect("dashboard route");
9320 let after_dash = &html[dash_start..];
9321 assert!(
9322 after_dash.contains("style=\"display:none\">"),
9323 "dashboard should be hidden when detail matches: {after_dash}"
9324 );
9325 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9326 let dash_body = &after_dash[..dash_end];
9327 assert!(
9328 !dash_body.contains("<dash-page"),
9329 "dashboard should not render component content: {dash_body}"
9330 );
9331 }
9332
9333 #[test]
9334 fn test_route_no_match_all_hidden_empty() {
9335 let protocol = make_route_protocol();
9336 let state = test_json!({});
9337 let mut writer = TestWriter::new();
9338 handle(
9339 &protocol,
9340 &state,
9341 &RenderOptions::new("index.html", "/nonexistent"),
9342 &mut writer,
9343 )
9344 .unwrap();
9345 let html = writer.get_content();
9346
9347 assert!(html.contains("<h1>Shell</h1>"));
9349 assert!(
9351 !html.contains("<p>Dashboard</p>"),
9352 "no route content when nothing matches"
9353 );
9354 assert!(
9355 !html.contains("<p>Detail</p>"),
9356 "no route content when nothing matches"
9357 );
9358 }
9359
9360 #[test]
9361 fn test_route_component_attr_emitted() {
9362 let protocol = make_route_protocol();
9363 let state = test_json!({});
9364 let mut writer = TestWriter::new();
9365 handle(
9366 &protocol,
9367 &state,
9368 &RenderOptions::new("index.html", "/"),
9369 &mut writer,
9370 )
9371 .unwrap();
9372 let html = writer.get_content();
9373 assert!(
9375 html.contains("component=\"dash-page\""),
9376 "component attr should be on webui-route: {html}"
9377 );
9378 assert!(
9379 html.contains("component=\"detail-page\""),
9380 "component attr should be on webui-route: {html}"
9381 );
9382 assert!(
9383 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9384 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9385 );
9386 }
9387
9388 #[test]
9389 fn test_no_plugin_no_state_attributes() {
9390 let protocol = make_route_protocol();
9391 let state = test_json!({
9392 "title": "Fish & Chips",
9393 "cartOpen": true,
9394 "items": [{"name": "A&B"}]
9395 });
9396 let mut writer = TestWriter::new();
9397
9398 handle(
9399 &protocol,
9400 &state,
9401 &RenderOptions::new("index.html", "/"),
9402 &mut writer,
9403 )
9404 .unwrap();
9405
9406 let html = writer.get_content();
9407 assert!(
9409 !html.contains("data-state"),
9410 "no data-state without plugin: {html}"
9411 );
9412 assert!(
9413 !html.contains(r#"title="Fish"#),
9414 "no scalar attrs without plugin: {html}"
9415 );
9416 }
9417
9418 #[test]
9419 fn test_nested_routes_render_webui_route_as_light_dom() {
9420 let protocol = make_nested_route_protocol();
9421 let state = test_json!({"title": "Test"});
9422 let handler = WebUIHandler::new();
9423 let mut writer = TestWriter::new();
9424
9425 handler
9426 .handle(
9427 &protocol,
9428 &state,
9429 &RenderOptions::new("index.html", "/sections/frontend"),
9430 &mut writer,
9431 )
9432 .expect("render failed");
9433
9434 let html = writer.get_content();
9435
9436 assert!(
9437 html.contains("component=\"app-shell\"") && html.contains("active>"),
9438 "root route should be active: {html}"
9439 );
9440 assert!(
9442 !html.contains("<template shadowrootmode"),
9443 "webui-route should be light DOM (no shadow template): {html}"
9444 );
9445 }
9446
9447 #[test]
9448 fn test_nested_routes_render_outlet_as_light_dom() {
9449 let protocol = make_nested_route_protocol();
9450 let state = test_json!({"title": "Test"});
9451 let handler = WebUIHandler::new();
9452 let mut writer = TestWriter::new();
9453
9454 handler
9455 .handle(
9456 &protocol,
9457 &state,
9458 &RenderOptions::new("index.html", "/sections/frontend"),
9459 &mut writer,
9460 )
9461 .expect("render failed");
9462
9463 let html = writer.get_content();
9464
9465 assert!(
9467 !html.contains("<webui-outlet>"),
9468 "should not contain webui-outlet wrapper: {html}"
9469 );
9470 assert!(
9472 html.contains("<webui-route"),
9473 "should contain webui-route elements: {html}"
9474 );
9475 }
9476
9477 #[test]
9478 fn test_nested_routes_match_child_at_outlet() {
9479 let protocol = make_nested_route_protocol();
9480 let state = test_json!({"title": "Test"});
9481 let handler = WebUIHandler::new();
9482 let mut writer = TestWriter::new();
9483
9484 handler
9485 .handle(
9486 &protocol,
9487 &state,
9488 &RenderOptions::new("index.html", "/sections/frontend"),
9489 &mut writer,
9490 )
9491 .expect("render failed");
9492
9493 let html = writer.get_content();
9494
9495 assert!(
9496 html.contains("component=\"section-comp\"") && html.contains("active>"),
9497 "section route should be active: {html}"
9498 );
9499 assert!(
9500 html.contains("<h2>Section</h2>"),
9501 "section content should be present: {html}"
9502 );
9503 }
9504
9505 #[test]
9506 fn test_nested_routes_three_levels_deep() {
9507 let protocol = make_nested_route_protocol();
9508 let state = test_json!({"title": "Test"});
9509 let handler = WebUIHandler::new();
9510 let mut writer = TestWriter::new();
9511
9512 handler
9513 .handle(
9514 &protocol,
9515 &state,
9516 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9517 &mut writer,
9518 )
9519 .expect("render failed");
9520
9521 let html = writer.get_content();
9522
9523 assert!(
9524 html.contains("component=\"app-shell\"") && html.contains("active>"),
9525 "root active: {html}"
9526 );
9527 assert!(
9528 html.contains("component=\"section-comp\"") && html.contains("active>"),
9529 "section active: {html}"
9530 );
9531 assert!(
9532 html.contains("component=\"topic-comp\"")
9533 && html.contains("exact")
9534 && html.contains("active>"),
9535 "topic active: {html}"
9536 );
9537 assert!(
9538 html.contains("<p>Topic content</p>"),
9539 "leaf content present: {html}"
9540 );
9541 }
9542
9543 #[test]
9544 fn test_nested_routes_nonmatched_siblings_hidden() {
9545 let protocol = make_nested_route_protocol();
9546 let state = test_json!({"title": "Test"});
9547 let handler = WebUIHandler::new();
9548 let mut writer = TestWriter::new();
9549
9550 handler
9551 .handle(
9552 &protocol,
9553 &state,
9554 &RenderOptions::new("index.html", "/sections/frontend"),
9555 &mut writer,
9556 )
9557 .expect("render failed");
9558
9559 let html = writer.get_content();
9560
9561 assert!(
9562 html.contains(r#"component="topic-comp" exact style="display:none">"#),
9563 "topic should be hidden: {html}"
9564 );
9565 }
9566
9567 #[test]
9568 fn test_nested_routes_root_only() {
9569 let protocol = make_nested_route_protocol();
9570 let state = test_json!({"title": "Test"});
9571 let handler = WebUIHandler::new();
9572 let mut writer = TestWriter::new();
9573
9574 handler
9575 .handle(
9576 &protocol,
9577 &state,
9578 &RenderOptions::new("index.html", "/"),
9579 &mut writer,
9580 )
9581 .expect("render failed");
9582
9583 let html = writer.get_content();
9584
9585 assert!(
9586 html.contains("component=\"app-shell\"") && html.contains("active>"),
9587 "root active at /: {html}"
9588 );
9589 assert!(
9590 html.contains("<h1>Shell</h1>"),
9591 "shell renders at /: {html}"
9592 );
9593 assert!(
9594 html.contains(r#"component="section-comp" style="display:none">"#),
9595 "section hidden at /: {html}"
9596 );
9597 }
9598
9599 #[test]
9602 fn test_css_module_emitted_once_inline_in_component() {
9603 let template = r#"<p><slot></slot></p>"#;
9606
9607 let mut fragments = HashMap::new();
9608 fragments.insert(
9609 "index.html".to_string(),
9610 FragmentList {
9611 fragments: vec![
9612 WebUIFragment::raw("<html><head>".to_string()),
9613 structural_fragment("head_end"),
9614 WebUIFragment::raw("</head><body><div>".to_string()),
9615 WebUIFragment::component("my-card"),
9616 WebUIFragment::raw("A".to_string()),
9617 WebUIFragment::component("my-card"),
9618 WebUIFragment::raw("B</div>".to_string()),
9619 structural_fragment("body_end"),
9620 WebUIFragment::raw("</body></html>".to_string()),
9621 ],
9622 contains_boundary: false,
9623 },
9624 );
9625 fragments.insert(
9626 "my-card".to_string(),
9627 FragmentList {
9628 fragments: vec![WebUIFragment::raw(template.to_string())],
9629 contains_boundary: false,
9630 },
9631 );
9632
9633 let mut protocol = WebUIProtocol::new(fragments);
9634 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9635 protocol
9636 .components
9637 .entry("my-card".to_string())
9638 .or_default()
9639 .css = "p{color:red}".to_string();
9640 protocol.populate_style_closures(&["index.html"]);
9641 let state = test_json!({});
9642 let mut writer = TestWriter::new();
9643
9644 handle(
9645 &protocol,
9646 &state,
9647 &RenderOptions::new("index.html", "/"),
9648 &mut writer,
9649 )
9650 .unwrap();
9651
9652 let html = writer.get_content();
9653
9654 let count = html.matches(r#"<script type="importmap""#).count();
9656 assert_eq!(
9657 count, 1,
9658 "CSS module importmap should be emitted once, got {count} in: {html}"
9659 );
9660 assert!(
9661 html.contains(r#""my-card":"data:text/css,"#),
9662 "Importmap must register my-card under a data: URI: {html}"
9663 );
9664
9665 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9667 assert_eq!(
9668 tmpl_count, 2,
9669 "Template should render twice, got {tmpl_count} in: {html}"
9670 );
9671
9672 let css_pos = html
9674 .find(r#"<script type="importmap""#)
9675 .expect("CSS module importmap missing");
9676 let body_pos = html.find("<body>").expect("<body> missing");
9677 assert!(
9678 css_pos > body_pos,
9679 "CSS module should be inline in component, not in <head>: {html}"
9680 );
9681 }
9682
9683 #[test]
9684 fn test_component_without_css_renders_normally() {
9685 let mut fragments = HashMap::new();
9687 fragments.insert(
9688 "index.html".to_string(),
9689 FragmentList {
9690 fragments: vec![WebUIFragment::component("my-card")],
9691 contains_boundary: false,
9692 },
9693 );
9694 fragments.insert(
9695 "my-card".to_string(),
9696 FragmentList {
9697 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9698 contains_boundary: false,
9699 },
9700 );
9701
9702 let protocol = WebUIProtocol::new(fragments);
9703 let state = test_json!({});
9704 let mut writer = TestWriter::new();
9705
9706 handle(
9707 &protocol,
9708 &state,
9709 &RenderOptions::new("index.html", "/"),
9710 &mut writer,
9711 )
9712 .unwrap();
9713
9714 let html = writer.get_content();
9715 assert!(
9716 html.contains("<p>hello</p>"),
9717 "Non-module component should render normally: {html}"
9718 );
9719 }
9720
9721 #[test]
9722 fn test_non_module_strategy_no_css_in_head() {
9723 let template = r#"<p>hello</p>"#;
9726
9727 let mut fragments = HashMap::new();
9728 fragments.insert(
9729 "index.html".to_string(),
9730 FragmentList {
9731 fragments: vec![
9732 WebUIFragment::raw("<html><head>".to_string()),
9733 structural_fragment("head_end"),
9734 WebUIFragment::raw("</head><body>".to_string()),
9735 WebUIFragment::component("my-card"),
9736 WebUIFragment::raw("</body></html>".to_string()),
9737 ],
9738 contains_boundary: false,
9739 },
9740 );
9741 fragments.insert(
9742 "my-card".to_string(),
9743 FragmentList {
9744 fragments: vec![WebUIFragment::raw(template.to_string())],
9745 contains_boundary: false,
9746 },
9747 );
9748
9749 let protocol = WebUIProtocol::new(fragments);
9751 let state = test_json!({});
9752 let mut writer = TestWriter::new();
9753
9754 handle(
9755 &protocol,
9756 &state,
9757 &RenderOptions::new("index.html", "/"),
9758 &mut writer,
9759 )
9760 .unwrap();
9761
9762 let html = writer.get_content();
9763
9764 assert!(
9765 !html.contains(r#"<style type="module""#),
9766 "Non-module strategy should not emit CSS module tags in <head>: {html}"
9767 );
9768 assert!(
9769 !html.contains(r#"<script type="importmap""#),
9770 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9771 );
9772 assert!(
9773 html.contains("<p>hello</p>"),
9774 "Component should still render: {html}"
9775 );
9776 }
9777
9778 #[test]
9779 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9780 let mut fragments = HashMap::new();
9781 fragments.insert(
9782 "index.html".to_string(),
9783 FragmentList {
9784 fragments: vec![
9785 WebUIFragment::raw("<html><head>".to_string()),
9786 structural_fragment("head_end"),
9787 WebUIFragment::raw("</head><body><my-card>".to_string()),
9788 WebUIFragment::component("my-card"),
9789 WebUIFragment::raw("</my-card></body></html>".to_string()),
9790 ],
9791 contains_boundary: false,
9792 },
9793 );
9794 fragments.insert(
9795 "my-card".to_string(),
9796 FragmentList {
9797 fragments: vec![WebUIFragment::raw(
9798 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9799 .to_string(),
9800 )],
9801 contains_boundary: false,
9802},
9803 );
9804
9805 let protocol = WebUIProtocol::new(fragments);
9806 let state = test_json!({});
9807 let mut writer = TestWriter::new();
9808
9809 handle(
9810 &protocol,
9811 &state,
9812 &RenderOptions::new("index.html", "/"),
9813 &mut writer,
9814 )
9815 .unwrap();
9816
9817 let html = writer.get_content();
9818
9819 assert!(
9820 html.contains("<style>.card{color:red}</style>"),
9821 "Style strategy should embed inline CSS in shadow template: {html}"
9822 );
9823 assert!(
9824 !html.contains(r#"<style type="module""#),
9825 "Style strategy should not emit legacy module CSS in <head>: {html}"
9826 );
9827 assert!(
9828 !html.contains(r#"<script type="importmap""#),
9829 "Style strategy should not emit CSS module importmaps in <head>: {html}"
9830 );
9831 }
9832
9833 #[test]
9834 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9835 let mut fragments = HashMap::new();
9838 fragments.insert(
9839 "index.html".to_string(),
9840 FragmentList {
9841 fragments: vec![
9842 WebUIFragment::raw("<html><head>".to_string()),
9843 structural_fragment("head_end"),
9844 WebUIFragment::raw("</head><body><my-card>".to_string()),
9845 WebUIFragment::component("my-card"),
9846 WebUIFragment::raw("</my-card>".to_string()),
9847 structural_fragment("body_end"),
9848 WebUIFragment::raw("</body></html>".to_string()),
9849 ],
9850 contains_boundary: false,
9851 },
9852 );
9853 fragments.insert(
9854 "my-card".to_string(),
9855 FragmentList {
9856 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9857 contains_boundary: false,
9858 },
9859 );
9860
9861 let mut protocol = WebUIProtocol::new(fragments);
9862 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9863
9864 let comp = protocol
9865 .components
9866 .entry("my-card".to_string())
9867 .or_default();
9868 comp.css_href = "my-card.css".to_string();
9869 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9870 protocol.populate_style_closures(&["index.html"]);
9871
9872 let state = test_json!({});
9873 let mut writer = TestWriter::new();
9874
9875 handle(
9876 &protocol,
9877 &state,
9878 &RenderOptions::new("index.html", "/"),
9879 &mut writer,
9880 )
9881 .unwrap();
9882
9883 let html = writer.get_content();
9884
9885 let head_end = html.find("</head>").expect("</head> missing");
9886 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9887 assert!(
9888 link_pos.is_some_and(|p| p < head_end),
9889 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9890 );
9891 assert!(
9892 !html.contains(r#"<link rel="preload""#),
9893 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9894 );
9895 assert!(
9896 !html.contains(r#"<style type="module""#),
9897 "Link strategy should not emit legacy module CSS: {html}"
9898 );
9899 assert!(
9900 !html.contains(r#"<script type="importmap""#),
9901 "Link strategy should not emit CSS module importmaps: {html}"
9902 );
9903 }
9904
9905 #[test]
9906 fn link_strategy_preloads_static_shadow_css_in_head() {
9907 let mut fragments = HashMap::new();
9908 fragments.insert(
9909 "index.html".to_string(),
9910 FragmentList {
9911 fragments: vec![
9912 WebUIFragment::raw("<html><head>".to_string()),
9913 structural_fragment("head_end"),
9914 WebUIFragment::raw("</head><body><my-card>".to_string()),
9915 WebUIFragment::component("my-card"),
9916 WebUIFragment::raw("</my-card></body></html>".to_string()),
9917 ],
9918 contains_boundary: false,
9919 },
9920 );
9921 fragments.insert(
9922 "o-loading-state".to_string(),
9923 FragmentList {
9924 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9925 contains_boundary: false,
9926 },
9927 );
9928 fragments.insert(
9929 "my-card".to_string(),
9930 FragmentList {
9931 fragments: vec![
9932 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9933 structural_fragment("shadow_styles:my-card"),
9934 WebUIFragment::raw("<div>card</div></template>".to_string()),
9935 ],
9936 contains_boundary: false,
9937 },
9938 );
9939
9940 let mut protocol = WebUIProtocol::new(fragments);
9941 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9942 let component = protocol
9943 .components
9944 .entry("my-card".to_string())
9945 .or_default();
9946 component.css_href = "my-card.css".to_string();
9947 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9948 component.uses_shadow_dom = true;
9949 protocol.populate_style_closures(&["index.html"]);
9950
9951 let mut writer = TestWriter::new();
9952 handle(
9953 &protocol,
9954 &test_json!({}),
9955 &RenderOptions::new("index.html", "/"),
9956 &mut writer,
9957 )
9958 .unwrap();
9959
9960 let html = writer.get_content();
9961 let head_end = html.find("</head>").expect("</head> missing");
9962 let preload = html
9963 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9964 .expect("Shadow preload missing");
9965 let stylesheet = html
9966 .find(r#"<link rel="stylesheet" href="my-card.css""#)
9967 .expect("Shadow stylesheet missing");
9968 assert!(preload < head_end);
9969 assert!(stylesheet > head_end);
9970 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9971 }
9972
9973 #[test]
9974 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9975 let fragments = HashMap::from([
9976 (
9977 "index.html".to_string(),
9978 FragmentList {
9979 fragments: vec![
9980 WebUIFragment::raw("<html><head>".to_string()),
9981 structural_fragment("head_end"),
9982 WebUIFragment::raw("</head><body><outer-box>".to_string()),
9983 WebUIFragment::component("outer-box"),
9984 WebUIFragment::raw("</outer-box></body></html>".to_string()),
9985 ],
9986 contains_boundary: false,
9987 },
9988 ),
9989 (
9990 "outer-box".to_string(),
9991 FragmentList {
9992 fragments: vec![
9993 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9994 structural_fragment("shadow_styles:outer-box"),
9995 WebUIFragment::raw("<light-card>".to_string()),
9996 WebUIFragment::component("light-card"),
9997 WebUIFragment::raw("</light-card><light-card>".to_string()),
9998 WebUIFragment::component("light-card"),
9999 WebUIFragment::raw("</light-card><inner-box>".to_string()),
10000 WebUIFragment::component("inner-box"),
10001 WebUIFragment::raw("</inner-box><light-card>".to_string()),
10002 WebUIFragment::component("light-card"),
10003 WebUIFragment::raw("</light-card></template>".to_string()),
10004 ],
10005 contains_boundary: false,
10006 },
10007 ),
10008 (
10009 "inner-box".to_string(),
10010 FragmentList {
10011 fragments: vec![
10012 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10013 structural_fragment("shadow_styles:inner-box"),
10014 WebUIFragment::raw("<light-card>".to_string()),
10015 WebUIFragment::component("light-card"),
10016 WebUIFragment::raw("</light-card></template>".to_string()),
10017 ],
10018 contains_boundary: false,
10019 },
10020 ),
10021 (
10022 "light-card".to_string(),
10023 FragmentList {
10024 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10025 contains_boundary: false,
10026 },
10027 ),
10028 ]);
10029 let mut protocol = WebUIProtocol::new(fragments);
10030 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10031 for (tag, css, uses_shadow_dom) in [
10032 ("outer-box", ".outer{}", true),
10033 ("inner-box", ".inner{}", true),
10034 ("light-card", ".card{}", false),
10035 ] {
10036 let component = protocol.components.entry(tag.to_string()).or_default();
10037 component.css = css.to_string();
10038 component.uses_shadow_dom = uses_shadow_dom;
10039 }
10040 protocol.populate_style_closures(&["index.html"]);
10041
10042 let mut writer = TestWriter::new();
10043 handle(
10044 &protocol,
10045 &test_json!({}),
10046 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10047 &mut writer,
10048 )
10049 .unwrap();
10050 let html = writer.get_content();
10051
10052 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10053 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10054 assert_eq!(
10055 html.matches("data-webui-resource=\"light-card\"").count(),
10056 2,
10057 "the Light resource installs once in each Shadow tree: {html}"
10058 );
10059 assert_eq!(
10060 html.matches("data-webui-resource=").count(),
10061 4,
10062 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10063 );
10064 assert_eq!(
10065 html.matches("nonce=\"css-nonce\"").count(),
10066 4,
10067 "each inline style resource carries the nonce"
10068 );
10069 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10070 let inner_end = html.find("</inner-box>").expect("inner host close");
10071 let caller_continuation = &html[inner_end..];
10072 let trailing_card = caller_continuation
10073 .find("<p>card</p>")
10074 .expect("caller continues after nested Shadow root");
10075 assert!(trailing_card > 0);
10076 assert!(
10077 !caller_continuation.contains("data-webui-resource=\"light-card\""),
10078 "returning from the nested Shadow root must restore the outer frame's delivered set"
10079 );
10080 }
10081
10082 #[test]
10083 fn routed_document_styles_are_hoisted_for_the_active_chain() {
10084 let route = WebUiFragmentRoute {
10085 path: "/".to_string(),
10086 fragment_id: "app-shell".to_string(),
10087 children: vec![
10088 WebUiFragmentRoute {
10089 path: String::new(),
10090 fragment_id: "dashboard-page".to_string(),
10091 exact: true,
10092 ..Default::default()
10093 },
10094 WebUiFragmentRoute {
10095 path: "details".to_string(),
10096 fragment_id: "detail-page".to_string(),
10097 exact: true,
10098 ..Default::default()
10099 },
10100 ],
10101 ..Default::default()
10102 };
10103 let fragments = HashMap::from([
10104 (
10105 "index.html".to_string(),
10106 FragmentList {
10107 fragments: vec![
10108 WebUIFragment::raw("<html><head>"),
10109 structural_fragment("head_start"),
10110 structural_fragment("head_end"),
10111 WebUIFragment::raw("</head><body>"),
10112 structural_fragment("body_start"),
10113 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10114 WebUIFragment::route_from(route),
10115 WebUIFragment::boundary_end(0),
10116 structural_fragment("body_end"),
10117 WebUIFragment::raw("</body></html>"),
10118 ],
10119 contains_boundary: true,
10120 },
10121 ),
10122 (
10123 "app-shell".to_string(),
10124 FragmentList {
10125 fragments: vec![
10126 WebUIFragment::raw("<header>App</header>"),
10127 WebUIFragment::outlet(),
10128 ],
10129 contains_boundary: false,
10130 },
10131 ),
10132 (
10133 "dashboard-page".to_string(),
10134 FragmentList {
10135 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10136 contains_boundary: false,
10137 },
10138 ),
10139 (
10140 "detail-page".to_string(),
10141 FragmentList {
10142 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10143 contains_boundary: false,
10144 },
10145 ),
10146 ]);
10147 let mut protocol = WebUIProtocol::new(fragments);
10148 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10149 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10150 let component = protocol.components.entry(tag.to_string()).or_default();
10151 component.css_href = format!("/{tag}.css");
10152 component.template_json = r#"{"h":""}"#.to_string();
10153 }
10154 protocol.populate_style_closures(&["index.html"]);
10155
10156 let mut writer = TestWriter::new();
10157 handle(
10158 &protocol,
10159 &test_json!({}),
10160 &RenderOptions::new("index.html", "/"),
10161 &mut writer,
10162 )
10163 .unwrap();
10164 let html = writer.get_content();
10165
10166 assert_eq!(
10167 html.matches(r#"data-webui-resource="app-shell""#).count(),
10168 1,
10169 "the matched route shell must install once: {html}"
10170 );
10171 assert_eq!(
10172 html.matches(r#"data-webui-resource="dashboard-page""#)
10173 .count(),
10174 1,
10175 "the matched child route must install once: {html}"
10176 );
10177 assert!(
10178 !html.contains(r#"data-webui-resource="detail-page""#),
10179 "inactive route CSS must not be delivered: {html}"
10180 );
10181 let head_end = html.find("</head>").expect("head close");
10182 let head = &html[..head_end];
10183 for (resource, href) in [
10184 ("app-shell", "/app-shell.css"),
10185 ("dashboard-page", "/dashboard-page.css"),
10186 ] {
10187 let encoded_href = crate::html_encode::encode_safe(href);
10188 assert!(
10189 head.contains(&format!(
10190 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10191 )),
10192 "Document-targeted route CSS must be render-blocking in head: {html}"
10193 );
10194 assert!(
10195 !head.contains(&format!(
10196 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10197 )),
10198 "a hoisted stylesheet must not also be preloaded: {html}"
10199 );
10200 }
10201 assert!(
10202 !head.contains("detail-page.css"),
10203 "inactive route CSS must not be preloaded: {html}"
10204 );
10205 let app_style = html
10206 .find(r#"data-webui-resource="app-shell""#)
10207 .expect("app style");
10208 let dashboard_style = html
10209 .find(r#"data-webui-resource="dashboard-page""#)
10210 .expect("dashboard style");
10211 let app_host = html.find("<app-shell").expect("app route host");
10212 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10213 assert!(
10214 app_style < dashboard_style
10215 && dashboard_style < head_end
10216 && head_end < app_host
10217 && app_host < dashboard_host,
10218 "matched Document route closures must be hoisted in chain order: {html}"
10219 );
10220
10221 let protocol = Protocol::new(protocol);
10222 let mut streamed = FlushTestWriter::default();
10223 WebUIHandler::new()
10224 .render_streaming(
10225 &protocol,
10226 &test_json!({}),
10227 &RenderOptions::new("index.html", "/"),
10228 &mut streamed,
10229 )
10230 .unwrap();
10231 let streamed_head_end = streamed
10232 .output
10233 .find("</head>")
10234 .expect("streamed head close");
10235 for resource in ["app-shell", "dashboard-page"] {
10236 let marker = format!(r#"data-webui-resource="{resource}""#);
10237 let position = streamed.output.find(&marker).expect("streamed route style");
10238 assert!(
10239 position < streamed_head_end,
10240 "streaming must hoist {resource} into head: {}",
10241 streamed.output
10242 );
10243 assert_eq!(
10244 streamed.output.matches(&marker).count(),
10245 1,
10246 "streaming must not emit {resource} twice: {}",
10247 streamed.output
10248 );
10249 }
10250 }
10251
10252 #[test]
10253 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10254 let route = WebUiFragmentRoute {
10255 path: "/".to_string(),
10256 fragment_id: "dashboard-page".to_string(),
10257 exact: true,
10258 ..Default::default()
10259 };
10260 let mut protocol = WebUIProtocol::new(HashMap::from([
10261 (
10262 "index.html".to_string(),
10263 FragmentList {
10264 fragments: vec![
10265 WebUIFragment::raw("<html><head>"),
10266 structural_fragment("head_start"),
10267 structural_fragment("head_end"),
10268 WebUIFragment::raw("</head><body>"),
10269 WebUIFragment::route_from(route),
10270 structural_fragment("body_end"),
10271 WebUIFragment::raw("</body></html>"),
10272 ],
10273 contains_boundary: false,
10274 },
10275 ),
10276 (
10277 "dashboard-page".to_string(),
10278 FragmentList {
10279 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10280 contains_boundary: false,
10281 },
10282 ),
10283 ]));
10284 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10285 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10286 protocol
10287 .components
10288 .entry(tag.to_string())
10289 .or_default()
10290 .css_href = format!("/{tag}.css");
10291 }
10292 protocol.style_closures.insert(
10293 "index.html".to_string(),
10294 webui_protocol::ComponentStyleClosure::default(),
10295 );
10296 protocol.style_closures.insert(
10297 "dashboard-page".to_string(),
10298 webui_protocol::ComponentStyleClosure {
10299 component_tags: vec![
10300 "dashboard-page".to_string(),
10301 "summary-card".to_string(),
10302 "activity-list".to_string(),
10303 ],
10304 style_chunks: vec![0],
10305 },
10306 );
10307 protocol.style_chunks.push(webui_protocol::StyleChunk {
10308 name: "_chunk-dashboard-page-3".to_string(),
10309 css: String::new(),
10310 css_href: "/_chunk-dashboard-page-3.css".to_string(),
10311 component_tags: vec![
10312 "dashboard-page".to_string(),
10313 "summary-card".to_string(),
10314 "activity-list".to_string(),
10315 ],
10316 });
10317
10318 let mut writer = TestWriter::new();
10319 handle(
10320 &protocol,
10321 &test_json!({}),
10322 &RenderOptions::new("index.html", "/"),
10323 &mut writer,
10324 )
10325 .unwrap();
10326 let html = writer.get_content();
10327 let head_end = html.find("</head>").expect("head close");
10328 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10329 let style = html.find(marker).expect("bundled route stylesheet");
10330
10331 assert!(
10332 style < head_end,
10333 "bundled route CSS must be in head: {html}"
10334 );
10335 assert_eq!(
10336 html.matches(marker).count(),
10337 1,
10338 "the route host must not emit the hoisted chunk again: {html}"
10339 );
10340 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10341 assert!(
10342 !html.contains(&format!(
10343 r#"<link rel="preload" as="style" href="{bundled_href}">"#
10344 )),
10345 "the applied bundled stylesheet must not also be preloaded: {html}"
10346 );
10347 }
10348
10349 #[test]
10350 fn routed_document_inline_styles_are_hoisted() {
10351 for strategy in [
10352 webui_protocol::CssStrategy::Style,
10353 webui_protocol::CssStrategy::Module,
10354 ] {
10355 let route = WebUiFragmentRoute {
10356 path: "/".to_string(),
10357 fragment_id: "dashboard-page".to_string(),
10358 exact: true,
10359 ..Default::default()
10360 };
10361 let mut protocol = WebUIProtocol::new(HashMap::from([
10362 (
10363 "index.html".to_string(),
10364 FragmentList {
10365 fragments: vec![
10366 WebUIFragment::raw("<html><head>"),
10367 structural_fragment("head_start"),
10368 structural_fragment("head_end"),
10369 WebUIFragment::raw("</head><body>"),
10370 WebUIFragment::route_from(route),
10371 structural_fragment("body_end"),
10372 WebUIFragment::raw("</body></html>"),
10373 ],
10374 contains_boundary: false,
10375 },
10376 ),
10377 (
10378 "dashboard-page".to_string(),
10379 FragmentList {
10380 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10381 contains_boundary: false,
10382 },
10383 ),
10384 ]));
10385 protocol.set_css_strategy(strategy);
10386 protocol
10387 .components
10388 .entry("dashboard-page".to_string())
10389 .or_default()
10390 .css = ".dashboard{display:grid}".to_string();
10391 protocol.populate_style_closures(&["index.html"]);
10392
10393 let mut writer = TestWriter::new();
10394 handle(
10395 &protocol,
10396 &test_json!({}),
10397 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10398 &mut writer,
10399 )
10400 .unwrap();
10401 let html = writer.get_content();
10402 let head_end = html.find("</head>").expect("head close");
10403 let marker = r#"data-webui-resource="dashboard-page""#;
10404 let style = html.find(marker).expect("route style fallback");
10405 let host = html.find("<dashboard-page").expect("dashboard route host");
10406 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10407 "module"
10408 } else {
10409 "style"
10410 };
10411 let style_marker = format!(
10412 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10413 );
10414
10415 assert!(
10416 style < head_end && head_end < host,
10417 "{strategy:?} route CSS must be applied in head before its host: {html}"
10418 );
10419 assert_eq!(
10420 html.matches(&style_marker).count(),
10421 1,
10422 "{strategy:?} route CSS must not be emitted twice: {html}"
10423 );
10424 }
10425 }
10426
10427 #[test]
10428 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10429 let route = WebUiFragmentRoute {
10430 path: "/".to_string(),
10431 fragment_id: "dashboard-page".to_string(),
10432 exact: true,
10433 ..Default::default()
10434 };
10435 let mut protocol = WebUIProtocol::new(HashMap::from([
10436 (
10437 "index.html".to_string(),
10438 FragmentList {
10439 fragments: vec![
10440 WebUIFragment::raw("<html><head>"),
10441 structural_fragment("head_start"),
10442 structural_fragment("head_end"),
10443 WebUIFragment::raw("</head><body><outer-box>"),
10444 WebUIFragment::component("outer-box"),
10445 WebUIFragment::raw("</outer-box></body></html>"),
10446 ],
10447 contains_boundary: false,
10448 },
10449 ),
10450 (
10451 "outer-box".to_string(),
10452 FragmentList {
10453 fragments: vec![
10454 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10455 structural_fragment("shadow_styles:outer-box"),
10456 WebUIFragment::route_from(route),
10457 WebUIFragment::raw("</template>"),
10458 ],
10459 contains_boundary: false,
10460 },
10461 ),
10462 (
10463 "dashboard-page".to_string(),
10464 FragmentList {
10465 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10466 contains_boundary: false,
10467 },
10468 ),
10469 ]));
10470 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10471 for tag in ["outer-box", "dashboard-page"] {
10472 protocol
10473 .components
10474 .entry(tag.to_string())
10475 .or_default()
10476 .css_href = format!("/{tag}.css");
10477 }
10478 protocol
10479 .components
10480 .get_mut("outer-box")
10481 .expect("outer-box component")
10482 .uses_shadow_dom = true;
10483 protocol.populate_style_closures(&["index.html"]);
10484
10485 let mut writer = TestWriter::new();
10486 handle(
10487 &protocol,
10488 &test_json!({}),
10489 &RenderOptions::new("index.html", "/"),
10490 &mut writer,
10491 )
10492 .unwrap();
10493 let html = writer.get_content();
10494 let head_end = html.find("</head>").expect("head close");
10495 let head = &html[..head_end];
10496 let dashboard_style = html
10497 .find(r#"data-webui-resource="dashboard-page""#)
10498 .expect("tree-local dashboard stylesheet");
10499 let shadow_start = html
10500 .find("<template shadowrootmode=\"open\">")
10501 .expect("Shadow template");
10502 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10503 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10504
10505 assert!(
10506 head.contains(&format!(
10507 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10508 )),
10509 "Shadow-targeted route CSS must start loading from head: {html}"
10510 );
10511 assert!(
10512 !head.contains(r#"data-webui-resource="dashboard-page""#),
10513 "Shadow-targeted route CSS must not be applied to Document: {html}"
10514 );
10515 assert!(
10516 shadow_start < dashboard_style && dashboard_style < dashboard_host,
10517 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10518 );
10519 }
10520
10521 #[test]
10522 fn isolated_shadow_entry_does_not_emit_document_preloads() {
10523 let route = WebUiFragmentRoute {
10524 path: "/".to_string(),
10525 fragment_id: "dashboard-page".to_string(),
10526 exact: true,
10527 ..Default::default()
10528 };
10529 let mut protocol = WebUIProtocol::new(HashMap::from([
10530 (
10531 "outer-box".to_string(),
10532 FragmentList {
10533 fragments: vec![
10534 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10535 structural_fragment("shadow_styles:outer-box"),
10536 WebUIFragment::route_from(route),
10537 WebUIFragment::raw("</template>"),
10538 ],
10539 contains_boundary: false,
10540 },
10541 ),
10542 (
10543 "dashboard-page".to_string(),
10544 FragmentList {
10545 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10546 contains_boundary: false,
10547 },
10548 ),
10549 ]));
10550 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10551 for tag in ["outer-box", "dashboard-page"] {
10552 protocol
10553 .components
10554 .entry(tag.to_string())
10555 .or_default()
10556 .css_href = format!("/{tag}.css");
10557 }
10558 protocol
10559 .components
10560 .get_mut("outer-box")
10561 .expect("outer-box component")
10562 .uses_shadow_dom = true;
10563 protocol.populate_style_closures(&["outer-box"]);
10564
10565 let mut writer = TestWriter::new();
10566 handle(
10567 &protocol,
10568 &test_json!({}),
10569 &RenderOptions::new("outer-box", "/"),
10570 &mut writer,
10571 )
10572 .unwrap();
10573 let html = writer.get_content();
10574
10575 assert!(
10576 html.starts_with("<template shadowrootmode=\"open\">"),
10577 "isolated component output must not be prefixed with document metadata: {html}"
10578 );
10579 assert!(
10580 !html.contains(r#"<link rel="preload""#),
10581 "an isolated Shadow entry has no Document head for preloads: {html}"
10582 );
10583 assert!(
10584 html.contains(r#"data-webui-resource="dashboard-page""#),
10585 "the route stylesheet must still install inside the ShadowRoot: {html}"
10586 );
10587 }
10588
10589 #[test]
10590 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10591 let route = WebUiFragmentRoute {
10592 path: "/".to_string(),
10593 fragment_id: "app-shell".to_string(),
10594 children: vec![
10595 WebUiFragmentRoute {
10596 path: String::new(),
10597 fragment_id: "dashboard-page".to_string(),
10598 exact: true,
10599 ..Default::default()
10600 },
10601 WebUiFragmentRoute {
10602 path: "details".to_string(),
10603 fragment_id: "detail-page".to_string(),
10604 exact: true,
10605 ..Default::default()
10606 },
10607 ],
10608 ..Default::default()
10609 };
10610 let fragments = HashMap::from([
10611 (
10612 "index.html".to_string(),
10613 FragmentList {
10614 fragments: vec![
10615 WebUIFragment::raw("<html><head>"),
10616 structural_fragment("head_start"),
10617 structural_fragment("head_end"),
10618 WebUIFragment::raw("</head><body>"),
10619 structural_fragment("body_start"),
10620 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10621 WebUIFragment::route_from(route),
10622 WebUIFragment::boundary_end(0),
10623 structural_fragment("body_end"),
10624 WebUIFragment::raw("</body></html>"),
10625 ],
10626 contains_boundary: true,
10627 },
10628 ),
10629 (
10630 "app-shell".to_string(),
10631 FragmentList {
10632 fragments: vec![
10633 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10634 structural_fragment("shadow_styles:app-shell"),
10635 WebUIFragment::raw("<shared-card"),
10636 structural_fragment("streaming_root:shared-card"),
10637 WebUIFragment::raw(">"),
10638 WebUIFragment::component("shared-card"),
10639 WebUIFragment::raw("</shared-card>"),
10640 WebUIFragment::outlet(),
10641 WebUIFragment::raw("</template>"),
10642 ],
10643 contains_boundary: false,
10644 },
10645 ),
10646 (
10647 "dashboard-page".to_string(),
10648 FragmentList {
10649 fragments: vec![
10650 WebUIFragment::raw("<shared-card"),
10651 structural_fragment("streaming_root:shared-card"),
10652 WebUIFragment::raw(">"),
10653 WebUIFragment::component("shared-card"),
10654 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10655 ],
10656 contains_boundary: false,
10657 },
10658 ),
10659 (
10660 "detail-page".to_string(),
10661 FragmentList {
10662 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10663 contains_boundary: false,
10664 },
10665 ),
10666 (
10667 "shared-card".to_string(),
10668 FragmentList {
10669 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10670 contains_boundary: false,
10671 },
10672 ),
10673 ]);
10674 let mut protocol = WebUIProtocol::new(fragments);
10675 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10676 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10677 let component = protocol.components.entry(tag.to_string()).or_default();
10678 component.css = format!(".{tag}{{display:block}}");
10679 component.template_json = r#"{"h":""}"#.to_string();
10680 }
10681 protocol
10682 .components
10683 .get_mut("app-shell")
10684 .expect("app shell component")
10685 .uses_shadow_dom = true;
10686 protocol.populate_style_closures(&["index.html"]);
10687
10688 let mut writer = TestWriter::new();
10689 handle(
10690 &protocol,
10691 &test_json!({}),
10692 &RenderOptions::new("index.html", "/"),
10693 &mut writer,
10694 )
10695 .unwrap();
10696 let html = writer.get_content();
10697
10698 for resource in ["app-shell", "shared-card", "dashboard-page"] {
10699 assert_eq!(
10700 html.matches(&format!(r#"data-webui-resource="{resource}""#))
10701 .count(),
10702 1,
10703 "{resource} must install once in the active ShadowRoot: {html}"
10704 );
10705 }
10706 assert!(
10707 !html.contains(r#"data-webui-resource="detail-page""#),
10708 "inactive route CSS must not enter the ShadowRoot: {html}"
10709 );
10710 let shadow_start = html
10711 .find("<template shadowrootmode=\"open\">")
10712 .expect("Shadow template");
10713 let dashboard_style = html
10714 .find(r#"data-webui-resource="dashboard-page""#)
10715 .expect("dashboard style");
10716 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10717 let shadow_end = html.find("</template>").expect("Shadow template close");
10718 assert!(
10719 shadow_start < dashboard_style
10720 && dashboard_style < dashboard_host
10721 && dashboard_host < shadow_end,
10722 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10723 );
10724
10725 let protocol = Protocol::new(protocol);
10726 let mut streamed = FlushTestWriter::default();
10727 WebUIHandler::new()
10728 .render_streaming(
10729 &protocol,
10730 &test_json!({}),
10731 &RenderOptions::new("index.html", "/"),
10732 &mut streamed,
10733 )
10734 .unwrap();
10735 for resource in ["app-shell", "shared-card"] {
10736 assert_eq!(
10737 streamed
10738 .output
10739 .matches(&format!(r#"data-webui-resource="{resource}""#))
10740 .count(),
10741 1,
10742 "streaming must preserve tree-local delivery for {resource}: {}",
10743 streamed.output
10744 );
10745 }
10746 assert!(
10747 !streamed
10748 .output
10749 .contains(r#"data-webui-resource="dashboard-page""#),
10750 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10751 streamed.output
10752 );
10753 assert!(
10754 streamed
10755 .output
10756 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10757 && streamed
10758 .output
10759 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10760 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10761 streamed.output
10762 );
10763 assert!(
10764 !streamed
10765 .output
10766 .contains(r#"data-webui-resource="detail-page""#),
10767 "streaming must omit inactive route CSS: {}",
10768 streamed.output
10769 );
10770 }
10771
10772 #[test]
10773 fn document_styles_follow_doctype_before_headless_document_content() {
10774 for entry_fragments in [
10775 vec![
10776 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10777 structural_fragment("body_start"),
10778 WebUIFragment::raw("<my-card>"),
10779 WebUIFragment::component("my-card"),
10780 WebUIFragment::raw("</my-card></body></html>"),
10781 ],
10782 vec![
10783 WebUIFragment::raw("<!doctype html><html><my-card>"),
10784 WebUIFragment::component("my-card"),
10785 WebUIFragment::raw("</my-card></html>"),
10786 ],
10787 ] {
10788 let mut protocol = WebUIProtocol::new(HashMap::from([
10789 (
10790 "index.html".to_string(),
10791 FragmentList {
10792 fragments: entry_fragments,
10793 contains_boundary: false,
10794 },
10795 ),
10796 (
10797 "my-card".to_string(),
10798 FragmentList {
10799 fragments: vec![WebUIFragment::raw("<p>card</p>")],
10800 contains_boundary: false,
10801 },
10802 ),
10803 ]));
10804 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10805 let component = protocol
10806 .components
10807 .entry("my-card".to_string())
10808 .or_default();
10809 component.css = ".card{color:red}".to_string();
10810 component.uses_shadow_dom = false;
10811 protocol.populate_style_closures(&["index.html"]);
10812
10813 let mut writer = TestWriter::new();
10814 handle(
10815 &protocol,
10816 &test_json!({}),
10817 &RenderOptions::new("index.html", "/"),
10818 &mut writer,
10819 )
10820 .expect("render");
10821 let html = writer.get_content();
10822
10823 assert!(
10824 html.to_ascii_lowercase().starts_with("<!doctype html>"),
10825 "the doctype must remain the first document token: {html}"
10826 );
10827 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10828 let document = html.find("<html").expect("document root");
10829 let component = html.find("<my-card>").expect("component host");
10830 assert!(
10831 style < document && style < component,
10832 "headless document styles must precede document content: {html}"
10833 );
10834 }
10835 }
10836
10837 #[test]
10838 fn authored_shadow_component_can_render_as_entry_fragment() {
10839 let mut protocol = WebUIProtocol::new(HashMap::from([(
10840 "my-card".to_string(),
10841 FragmentList {
10842 fragments: vec![
10843 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10844 structural_fragment("shadow_styles:my-card"),
10845 WebUIFragment::raw("<p>card</p></template>"),
10846 ],
10847 contains_boundary: false,
10848 },
10849 )]));
10850 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10851 let component = protocol
10852 .components
10853 .entry("my-card".to_string())
10854 .or_default();
10855 component.css = ".card{color:red}".to_string();
10856 component.uses_shadow_dom = true;
10857 protocol.populate_style_closures(&["my-card"]);
10858
10859 let mut writer = TestWriter::new();
10860 handle(
10861 &protocol,
10862 &test_json!({}),
10863 &RenderOptions::new("my-card", "/"),
10864 &mut writer,
10865 )
10866 .expect("render");
10867 let html = writer.get_content();
10868
10869 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10870 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10871 assert!(html.ends_with("<p>card</p></template>"));
10872 }
10873
10874 #[test]
10875 fn component_style_closures_escape_mixed_case_style_end_tags() {
10876 for strategy in [
10877 webui_protocol::CssStrategy::Style,
10878 webui_protocol::CssStrategy::Module,
10879 ] {
10880 let mut protocol = WebUIProtocol::new(HashMap::from([
10881 (
10882 "index.html".to_string(),
10883 FragmentList {
10884 fragments: vec![
10885 WebUIFragment::raw("<html><head>"),
10886 structural_fragment("head_end"),
10887 WebUIFragment::raw("</head><body><safe-card>"),
10888 WebUIFragment::component("safe-card"),
10889 WebUIFragment::raw("</safe-card></body></html>"),
10890 ],
10891 contains_boundary: false,
10892 },
10893 ),
10894 (
10895 "safe-card".to_string(),
10896 FragmentList {
10897 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10898 contains_boundary: false,
10899 },
10900 ),
10901 ]));
10902 protocol.set_css_strategy(strategy);
10903 protocol.components.insert(
10904 "safe-card".to_string(),
10905 webui_protocol::ComponentData {
10906 css: ".safe{content:'</StYlE>'}".to_string(),
10907 ..Default::default()
10908 },
10909 );
10910 protocol.populate_style_closures(&["index.html"]);
10911 let mut writer = TestWriter::new();
10912
10913 handle(
10914 &protocol,
10915 &test_json!({}),
10916 &RenderOptions::new("index.html", "/"),
10917 &mut writer,
10918 )
10919 .unwrap();
10920
10921 let html = writer.get_content();
10922 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10923 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10924 }
10925 }
10926
10927 #[test]
10928 fn shadow_closure_supports_link_and_module_ssr_paths() {
10929 for strategy in [
10930 webui_protocol::CssStrategy::Link,
10931 webui_protocol::CssStrategy::Module,
10932 ] {
10933 let mut protocol = WebUIProtocol::new(HashMap::from([
10934 (
10935 "index.html".to_string(),
10936 FragmentList {
10937 fragments: vec![
10938 WebUIFragment::raw("<html><head>".to_string()),
10939 structural_fragment("head_end"),
10940 WebUIFragment::raw("</head><body><my-card>".to_string()),
10941 WebUIFragment::component("my-card"),
10942 WebUIFragment::raw("</my-card></body></html>".to_string()),
10943 ],
10944 contains_boundary: false,
10945 },
10946 ),
10947 (
10948 "my-card".to_string(),
10949 FragmentList {
10950 fragments: vec![
10951 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10952 structural_fragment("shadow_styles:my-card"),
10953 WebUIFragment::raw("<p>card</p></template>".to_string()),
10954 ],
10955 contains_boundary: false,
10956 },
10957 ),
10958 ]));
10959 protocol.set_css_strategy(strategy);
10960 let component = protocol
10961 .components
10962 .entry("my-card".to_string())
10963 .or_default();
10964 component.css = ".card{color:red}".to_string();
10965 component.css_href = "/my-card.css".to_string();
10966 component.uses_shadow_dom = true;
10967 protocol.populate_style_closures(&["index.html"]);
10968
10969 let mut writer = TestWriter::new();
10970 handle(
10971 &protocol,
10972 &test_json!({}),
10973 &RenderOptions::new("index.html", "/"),
10974 &mut writer,
10975 )
10976 .unwrap();
10977 let html = writer.get_content();
10978 match strategy {
10979 webui_protocol::CssStrategy::Link => {
10980 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10981 assert!(html.contains(&format!(
10982 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10983 )));
10984 }
10985 webui_protocol::CssStrategy::Module => {
10986 assert!(html.contains(
10987 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10988 ));
10989 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10990 }
10991 webui_protocol::CssStrategy::Style => unreachable!(),
10992 }
10993 }
10994 }
10995
10996 #[test]
10997 fn test_module_preloads_emit_in_head_in_compiler_order() {
10998 let mut fragments = HashMap::new();
11002 fragments.insert(
11003 "index.html".to_string(),
11004 FragmentList {
11005 fragments: vec![
11006 WebUIFragment::raw(
11007 r#"<html><head><script type="module" async src="/index.js"></script>"#
11008 .to_string(),
11009 ),
11010 structural_fragment("head_end"),
11011 WebUIFragment::raw("</head><body>".to_string()),
11012 structural_fragment("body_end"),
11013 WebUIFragment::raw("</body></html>".to_string()),
11014 ],
11015 contains_boundary: false,
11016 },
11017 );
11018
11019 let mut protocol = WebUIProtocol::new(fragments);
11020 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11021
11022 let state = test_json!({});
11023 let mut writer = TestWriter::new();
11024 handle(
11025 &protocol,
11026 &state,
11027 &RenderOptions::new("index.html", "/"),
11028 &mut writer,
11029 )
11030 .unwrap();
11031
11032 let html = writer.get_content();
11033 let head_end = html.find("</head>").expect("</head> missing");
11034 let head = &html[..head_end];
11035 assert!(
11036 head.contains(
11037 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11038 ),
11039 "hints must appear in <head> in the compiler's order: {html}"
11040 );
11041 }
11042
11043 #[test]
11044 fn test_no_module_preloads_emits_nothing() {
11045 let mut fragments = HashMap::new();
11046 fragments.insert(
11047 "index.html".to_string(),
11048 FragmentList {
11049 fragments: vec![
11050 WebUIFragment::raw("<html><head>".to_string()),
11051 structural_fragment("head_end"),
11052 WebUIFragment::raw("</head><body>".to_string()),
11053 structural_fragment("body_end"),
11054 WebUIFragment::raw("</body></html>".to_string()),
11055 ],
11056 contains_boundary: false,
11057 },
11058 );
11059 let protocol = WebUIProtocol::new(fragments);
11060
11061 let state = test_json!({});
11062 let mut writer = TestWriter::new();
11063 handle(
11064 &protocol,
11065 &state,
11066 &RenderOptions::new("index.html", "/"),
11067 &mut writer,
11068 )
11069 .unwrap();
11070
11071 assert!(
11072 !writer.get_content().contains("modulepreload"),
11073 "a build without hints must be byte-identical to before"
11074 );
11075 }
11076
11077 #[test]
11078 fn test_link_strategy_head_links_follow_document_order() {
11079 let mut fragments = HashMap::new();
11084 fragments.insert(
11085 "index.html".to_string(),
11086 FragmentList {
11087 fragments: vec![
11088 WebUIFragment::raw("<html><head>".to_string()),
11089 structural_fragment("head_end"),
11090 WebUIFragment::raw("</head><body><z-widget>".to_string()),
11091 WebUIFragment::component("z-widget"),
11092 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11093 WebUIFragment::component("a-widget"),
11094 WebUIFragment::raw("</a-widget>".to_string()),
11095 structural_fragment("body_end"),
11096 WebUIFragment::raw("</body></html>".to_string()),
11097 ],
11098 contains_boundary: false,
11099 },
11100 );
11101 fragments.insert(
11102 "z-widget".to_string(),
11103 FragmentList {
11104 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11105 contains_boundary: false,
11106 },
11107 );
11108 fragments.insert(
11109 "a-widget".to_string(),
11110 FragmentList {
11111 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11112 contains_boundary: false,
11113 },
11114 );
11115
11116 let mut protocol = WebUIProtocol::new(fragments);
11117 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11118
11119 let z = protocol
11120 .components
11121 .entry("z-widget".to_string())
11122 .or_default();
11123 z.css_href = "z-widget.css".to_string();
11124 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11125
11126 let a = protocol
11127 .components
11128 .entry("a-widget".to_string())
11129 .or_default();
11130 a.css_href = "a-widget.css".to_string();
11131 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11132 protocol.populate_style_closures(&["index.html"]);
11133
11134 let state = test_json!({});
11135 let mut writer = TestWriter::new();
11136
11137 handle(
11138 &protocol,
11139 &state,
11140 &RenderOptions::new("index.html", "/"),
11141 &mut writer,
11142 )
11143 .unwrap();
11144
11145 let html = writer.get_content();
11146 let head_end = html.find("</head>").expect("</head> missing");
11147 let head_section = &html[..head_end];
11148
11149 let z_pos = head_section
11150 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11151 .expect("z-widget stylesheet link missing from <head>");
11152 let a_pos = head_section
11153 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11154 .expect("a-widget stylesheet link missing from <head>");
11155
11156 assert!(
11157 z_pos < a_pos,
11158 "CSS <link> tags must follow document order (z-widget before \
11159 a-widget), not alphabetical order: {html}"
11160 );
11161 }
11162
11163 #[test]
11164 fn test_css_module_emitted_in_component_light_dom() {
11165 let mut fragments = HashMap::new();
11169 fragments.insert(
11170 "index.html".to_string(),
11171 FragmentList {
11172 fragments: vec![
11173 WebUIFragment::raw("<html><head>".to_string()),
11174 structural_fragment("head_end"),
11175 WebUIFragment::raw("</head><body><my-card>".to_string()),
11176 WebUIFragment::component("my-card"),
11177 WebUIFragment::raw("</my-card>".to_string()),
11178 WebUIFragment::raw("</body></html>".to_string()),
11179 ],
11180 contains_boundary: false,
11181 },
11182 );
11183 fragments.insert(
11184 "my-card".to_string(),
11185 FragmentList {
11186 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11187 contains_boundary: false,
11188 },
11189 );
11190
11191 let mut protocol = WebUIProtocol::new(fragments);
11192 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11193 protocol
11194 .components
11195 .entry("my-card".to_string())
11196 .or_default()
11197 .css = "p{color:red}".to_string();
11198 protocol.populate_style_closures(&["index.html"]);
11199 let state = test_json!({});
11200 let mut writer = TestWriter::new();
11201
11202 handle(
11203 &protocol,
11204 &state,
11205 &RenderOptions::new("index.html", "/"),
11206 &mut writer,
11207 )
11208 .unwrap();
11209
11210 let html = writer.get_content();
11211
11212 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11214 let css_pos = html
11215 .find(r#"<script type="importmap""#)
11216 .expect("CSS module importmap missing");
11217 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11218 assert!(
11219 css_pos > tag_open && css_pos < tag_close,
11220 "CSS module should be inside component light DOM: {html}"
11221 );
11222
11223 let head_end = html.find("</head>").expect("</head> missing");
11225 assert!(
11226 css_pos > head_end,
11227 "CSS module should not be in <head>: {html}"
11228 );
11229 }
11230
11231 #[test]
11232 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11233 let mut protocol = WebUIProtocol::default();
11234 protocol.fragments = HashMap::from([
11235 (
11236 "index.html".to_string(),
11237 FragmentList {
11238 fragments: vec![
11239 WebUIFragment::raw("<legacy-card>"),
11240 WebUIFragment::component("legacy-card"),
11241 WebUIFragment::raw("</legacy-card>"),
11242 ],
11243 contains_boundary: false,
11244 },
11245 ),
11246 (
11247 "legacy-card".to_string(),
11248 FragmentList {
11249 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11250 contains_boundary: false,
11251 },
11252 ),
11253 ]);
11254 protocol.components.insert(
11255 "legacy-card".to_string(),
11256 webui_protocol::ComponentData {
11257 css: ".legacy{color:red}".to_string(),
11258 ..Default::default()
11259 },
11260 );
11261 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11262 let mut writer = TestWriter::new();
11263
11264 let error = handle(
11265 &protocol,
11266 &test_json!({}),
11267 &RenderOptions::new("index.html", "/"),
11268 &mut writer,
11269 )
11270 .expect_err("styled protocols require closure metadata");
11271 assert!(error
11272 .to_string()
11273 .contains("component style closure metadata is required"));
11274 }
11275
11276 #[test]
11277 fn test_css_module_emitted_for_route_components() {
11278 let template = r#"<h1>Dashboard</h1>"#;
11280
11281 let mut fragments = HashMap::new();
11282 fragments.insert(
11283 "index.html".to_string(),
11284 FragmentList {
11285 fragments: vec![
11286 WebUIFragment::raw("<html><head>".to_string()),
11287 structural_fragment("head_end"),
11288 WebUIFragment::raw("</head><body>".to_string()),
11289 WebUIFragment::route("/", "dash-page"),
11290 structural_fragment("body_end"),
11291 WebUIFragment::raw("</body></html>".to_string()),
11292 ],
11293 contains_boundary: false,
11294 },
11295 );
11296 fragments.insert(
11297 "dash-page".to_string(),
11298 FragmentList {
11299 fragments: vec![WebUIFragment::raw(template.to_string())],
11300 contains_boundary: false,
11301 },
11302 );
11303
11304 let mut protocol = WebUIProtocol::new(fragments);
11305 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11306 let comp = protocol
11307 .components
11308 .entry("dash-page".to_string())
11309 .or_default();
11310 comp.css = "h1{font-size:2rem}".to_string();
11311 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11312 protocol.populate_style_closures(&["index.html"]);
11313 let state = test_json!({});
11314 let mut writer = TestWriter::new();
11315
11316 handle(
11317 &protocol,
11318 &state,
11319 &RenderOptions::new("index.html", "/"),
11320 &mut writer,
11321 )
11322 .unwrap();
11323
11324 let html = writer.get_content();
11325
11326 assert!(
11327 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11328 "Route component should have CSS module importmap with data: URI: {html}"
11329 );
11330 assert!(
11331 html.contains("<h1>Dashboard</h1>"),
11332 "Route component should render content: {html}"
11333 );
11334 let fallback = html
11335 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11336 .expect("route Module fallback");
11337 let host_open = html.find("<dash-page>").expect("route host");
11338 let content = html.find("<h1>Dashboard</h1>").expect("route content");
11339 assert!(
11340 fallback < host_open && host_open < content,
11341 "the Module fallback must precede the generated Light route host: {html}"
11342 );
11343 let importmap = html
11349 .find(r#"<script type="importmap""#)
11350 .expect("route CSS module importmap");
11351 assert!(
11352 host_open < importmap,
11353 "the CSS module importmap must live inside the route host, not as a route child: {html}"
11354 );
11355 }
11356
11357 #[test]
11358 fn test_head_css_link_skipped_for_components_without_css() {
11359 let mut fragments = HashMap::new();
11362 fragments.insert(
11363 "index.html".to_string(),
11364 FragmentList {
11365 fragments: vec![
11366 WebUIFragment::raw("<html><head>".to_string()),
11367 structural_fragment("head_end"),
11368 WebUIFragment::raw("</head><body>".to_string()),
11369 WebUIFragment::component("has-css"),
11370 WebUIFragment::component("no-css"),
11371 structural_fragment("body_end"),
11372 WebUIFragment::raw("</body></html>".to_string()),
11373 ],
11374 contains_boundary: false,
11375 },
11376 );
11377 fragments.insert(
11378 "has-css".to_string(),
11379 FragmentList {
11380 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11381 contains_boundary: false,
11382 },
11383 );
11384 fragments.insert(
11385 "no-css".to_string(),
11386 FragmentList {
11387 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11388 contains_boundary: false,
11389 },
11390 );
11391
11392 let mut protocol = WebUIProtocol::new(fragments);
11393 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11394
11395 protocol
11397 .components
11398 .entry("has-css".to_string())
11399 .or_default()
11400 .css_href = "has-css.css".to_string();
11401 protocol
11402 .components
11403 .entry("no-css".to_string())
11404 .or_default()
11405 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11406 protocol.populate_style_closures(&["index.html"]);
11407
11408 let state = test_json!({});
11409 let mut writer = TestWriter::new();
11410
11411 let handler = WebUIHandler::with_plugin(|| {
11412 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11413 });
11414 handler
11415 .handle(
11416 &protocol,
11417 &state,
11418 &RenderOptions::new("index.html", "/"),
11419 &mut writer,
11420 )
11421 .unwrap();
11422
11423 let html = writer.get_content();
11424 assert!(
11425 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11426 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11427 );
11428 assert!(
11429 !html.contains("no-css.css"),
11430 "Component without CSS must NOT get a <link> in <head>: {html}"
11431 );
11432 }
11433
11434 #[test]
11435 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11436 let mut fragments = HashMap::new();
11444 fragments.insert(
11445 "index.html".to_string(),
11446 FragmentList {
11447 fragments: vec![
11448 WebUIFragment::raw("<html><head>".to_string()),
11449 structural_fragment("head_end"),
11450 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11451 WebUIFragment::component("app-shell"),
11452 WebUIFragment::raw("</app-shell>".to_string()),
11453 structural_fragment("body_end"),
11454 WebUIFragment::raw("</body></html>".to_string()),
11455 ],
11456 contains_boundary: false,
11457 },
11458 );
11459 fragments.insert(
11461 "app-shell".to_string(),
11462 FragmentList {
11463 fragments: vec![
11464 WebUIFragment::raw("<div>Shell</div>".to_string()),
11465 WebUIFragment::component("cart-panel"),
11466 ],
11467 contains_boundary: false,
11468 },
11469 );
11470 fragments.insert(
11472 "cart-panel".to_string(),
11473 FragmentList {
11474 fragments: vec![
11475 WebUIFragment::raw("<aside>".to_string()),
11476 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11477 WebUIFragment::raw("</aside>".to_string()),
11478 ],
11479 contains_boundary: false,
11480 },
11481 );
11482 fragments.insert(
11484 "cart-items".to_string(),
11485 FragmentList {
11486 fragments: vec![WebUIFragment::component("product-card")],
11487 contains_boundary: false,
11488 },
11489 );
11490 fragments.insert(
11491 "product-card".to_string(),
11492 FragmentList {
11493 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11494 contains_boundary: false,
11495 },
11496 );
11497
11498 let mut protocol = WebUIProtocol::new(fragments);
11499 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11500 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11501 for name in ["app-shell", "cart-panel", "product-card"] {
11502 let comp = protocol.components.entry(name.to_string()).or_default();
11503 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11504 comp.css = format!(".{name}{{display:block}}");
11505 if name == "cart-panel" {
11506 comp.hydration_mode = StateProjectionMode::Keys as i32;
11507 comp.hydration_keys = vec!["hasItems".to_string()];
11508 }
11509 if name == "product-card" {
11510 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11511 }
11512 }
11513 protocol.populate_style_closures(&["index.html"]);
11514
11515 let state = test_json!({ "hasItems": false });
11517 let mut writer = TestWriter::new();
11518
11519 let handler = WebUIHandler::with_plugin(|| {
11520 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11521 });
11522 handler
11523 .handle(
11524 &protocol,
11525 &state,
11526 &RenderOptions::new("index.html", "/"),
11527 &mut writer,
11528 )
11529 .unwrap();
11530
11531 let html = writer.get_content();
11532
11533 assert!(
11534 html.contains(r#"<script type="application/json" id="webui-data">"#),
11535 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11536 );
11537 assert!(
11538 html.contains(r#""state":{"hasItems":false}"#),
11539 "SSR state should live in the JSON data block: {html}"
11540 );
11541 assert!(
11542 html.contains(r#""inventory":"#),
11543 "SSR inventory should live in the JSON data block: {html}"
11544 );
11545 assert!(
11546 !html.contains("window.__webui={\""),
11547 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11548 );
11549 assert!(
11550 !html.contains(r#"document.getElementById("webui-data")"#),
11551 "SSR must not parse webui-data; client packages own that lazy load: {html}"
11552 );
11553 assert!(
11554 !html.contains("window.__webui=w;"),
11555 "executable bootstrap must not replace existing window.__webui registrations: {html}"
11556 );
11557 assert!(
11558 !html.contains("w.templateFns={\""),
11559 "template function emission must not replace existing templateFns registrations: {html}"
11560 );
11561 assert!(
11562 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11563 "template functions should merge into the flat templateFns registry: {html}"
11564 );
11565
11566 assert!(
11569 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11570 "product-card template should be emitted even when unrendered: {html}"
11571 );
11572
11573 assert!(
11576 html.contains(r#""product-card":"data:text/css,"#),
11577 "reachable product-card CSS module importmap should be emitted: {html}"
11578 );
11579
11580 assert!(
11582 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11583 "rendered app-shell template should be emitted: {html}"
11584 );
11585 assert!(
11586 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11587 "rendered cart-panel template should be emitted: {html}"
11588 );
11589 }
11590
11591 #[test]
11592 fn component_asset_manifest_falls_back_to_body_start() {
11593 let fragments = HashMap::from([(
11594 "index.html".to_string(),
11595 FragmentList {
11596 fragments: vec![
11597 WebUIFragment::raw("<body>".to_string()),
11598 structural_fragment("body_start"),
11599 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11600 ],
11601 contains_boundary: false,
11602 },
11603 )]);
11604 let mut protocol = WebUIProtocol::new(fragments);
11605 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11606 root: "lazy-panel".to_string(),
11607 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11608 }];
11609 let mut writer = TestWriter::new();
11610
11611 handle(
11612 &protocol,
11613 &Value::Null,
11614 &RenderOptions::new("index.html", "/"),
11615 &mut writer,
11616 )
11617 .unwrap();
11618
11619 assert_eq!(
11620 writer.get_content(),
11621 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11622 );
11623 }
11624
11625 #[test]
11626 fn component_asset_styles_emit_once_when_body_precedes_head() {
11627 let fragments = HashMap::from([(
11628 "index.html".to_string(),
11629 FragmentList {
11630 fragments: vec![
11631 WebUIFragment::raw("<body>".to_string()),
11632 structural_fragment("body_start"),
11633 WebUIFragment::raw("</body><head>".to_string()),
11634 structural_fragment("head_end"),
11635 WebUIFragment::raw("</head>".to_string()),
11636 ],
11637 contains_boundary: false,
11638 },
11639 )]);
11640 let mut protocol = WebUIProtocol::new(fragments);
11641 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11642 root: "lazy-panel".to_string(),
11643 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11644 }];
11645 let mut writer = TestWriter::new();
11646
11647 handle(
11648 &protocol,
11649 &Value::Null,
11650 &RenderOptions::new("index.html", "/"),
11651 &mut writer,
11652 )
11653 .unwrap();
11654
11655 assert_eq!(
11656 writer
11657 .get_content()
11658 .matches(r#"id="webui-component-assets""#)
11659 .count(),
11660 1
11661 );
11662 }
11663
11664 #[test]
11665 fn light_component_asset_styles_apply_at_head_end() {
11666 let fragments = HashMap::from([(
11667 "index.html".to_string(),
11668 FragmentList {
11669 fragments: vec![
11670 WebUIFragment::raw("<html><head>".to_string()),
11671 structural_fragment("head_end"),
11672 WebUIFragment::raw("</head><body></body></html>".to_string()),
11673 ],
11674 contains_boundary: false,
11675 },
11676 )]);
11677 let mut protocol = WebUIProtocol::new(fragments);
11678 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11679 for root in ["lazy-panel", "secondary-panel"] {
11680 protocol.components.insert(
11681 root.to_string(),
11682 webui_protocol::ComponentData {
11683 uses_shadow_dom: false,
11684 ..Default::default()
11685 },
11686 );
11687 }
11688 protocol.component_asset_style_preloads = vec![
11689 ComponentAssetStylePreload {
11690 root: "lazy-panel".to_string(),
11691 style_hrefs: vec![
11692 "/assets/lazy-panel.css".to_string(),
11693 "/assets/shared.css".to_string(),
11694 ],
11695 },
11696 ComponentAssetStylePreload {
11697 root: "secondary-panel".to_string(),
11698 style_hrefs: vec![
11699 "/assets/secondary-panel.css".to_string(),
11700 "/assets/shared.css".to_string(),
11701 ],
11702 },
11703 ];
11704 let mut writer = TestWriter::new();
11705
11706 handle(
11707 &protocol,
11708 &Value::Null,
11709 &RenderOptions::new("index.html", "/"),
11710 &mut writer,
11711 )
11712 .unwrap();
11713
11714 let html = writer.get_content();
11715 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11716 assert_eq!(html.matches("/assets/shared.css").count(), 1);
11717 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11718 let links_end = html
11719 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11720 .expect("asset stylesheet must be emitted");
11721 let head_end = html.find("</head>").expect("head must close");
11722 assert!(links_end < head_end);
11723 }
11724
11725 #[test]
11735 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11736 let template = r#"<h1>Dashboard</h1>"#;
11738
11739 let mut fragments = HashMap::new();
11740 fragments.insert(
11741 "index.html".to_string(),
11742 FragmentList {
11743 fragments: vec![
11744 WebUIFragment::raw("<html><head>".to_string()),
11745 structural_fragment("head_end"),
11746 WebUIFragment::raw("</head><body>".to_string()),
11747 WebUIFragment::route("/", "dash-page"),
11748 structural_fragment("body_end"),
11749 WebUIFragment::raw("</body></html>".to_string()),
11750 ],
11751 contains_boundary: false,
11752 },
11753 );
11754 fragments.insert(
11755 "dash-page".to_string(),
11756 FragmentList {
11757 fragments: vec![WebUIFragment::raw(template.to_string())],
11758 contains_boundary: false,
11759 },
11760 );
11761
11762 let mut protocol = WebUIProtocol::new(fragments);
11763 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11764 let comp = protocol
11765 .components
11766 .entry("dash-page".to_string())
11767 .or_default();
11768 comp.css = "h1{font-size:2rem}".to_string();
11769 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11770 protocol.populate_style_closures(&["index.html"]);
11771 let state = test_json!({});
11772 let mut writer = TestWriter::new();
11773
11774 handle(
11775 &protocol,
11776 &state,
11777 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11778 &mut writer,
11779 )
11780 .unwrap();
11781
11782 let html = writer.get_content();
11783
11784 assert!(
11785 html.contains(
11786 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11787 ),
11788 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11789 );
11790 }
11791
11792 #[test]
11793 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11794 let mut fragments = HashMap::new();
11799 fragments.insert(
11800 "index.html".to_string(),
11801 FragmentList {
11802 fragments: vec![
11803 WebUIFragment::raw("<html><head>".to_string()),
11804 structural_fragment("head_end"),
11805 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11806 WebUIFragment::component("app-shell"),
11807 WebUIFragment::raw("</app-shell>".to_string()),
11808 structural_fragment("body_end"),
11809 WebUIFragment::raw("</body></html>".to_string()),
11810 ],
11811 contains_boundary: false,
11812 },
11813 );
11814 fragments.insert(
11815 "app-shell".to_string(),
11816 FragmentList {
11817 fragments: vec![WebUIFragment::if_cond(
11818 ConditionExpr::identifier("hasItems"),
11819 "cart-items",
11820 )],
11821 contains_boundary: false,
11822 },
11823 );
11824 fragments.insert(
11825 "cart-items".to_string(),
11826 FragmentList {
11827 fragments: vec![WebUIFragment::component("product-card")],
11828 contains_boundary: false,
11829 },
11830 );
11831 fragments.insert(
11832 "product-card".to_string(),
11833 FragmentList {
11834 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11835 contains_boundary: false,
11836 },
11837 );
11838
11839 let mut protocol = WebUIProtocol::new(fragments);
11840 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11841 for name in ["app-shell", "product-card"] {
11842 let comp = protocol.components.entry(name.to_string()).or_default();
11843 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11844 comp.css = format!(".{name}{{display:block}}");
11845 }
11846 protocol.populate_style_closures(&["index.html"]);
11847
11848 let state = test_json!({ "hasItems": false });
11851 let mut writer = TestWriter::new();
11852
11853 let handler = WebUIHandler::with_plugin(|| {
11854 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11855 });
11856 handler
11857 .handle(
11858 &protocol,
11859 &state,
11860 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11861 &mut writer,
11862 )
11863 .unwrap();
11864
11865 let html = writer.get_content();
11866
11867 assert!(
11868 html.contains(
11869 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11870 ),
11871 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11872 );
11873 }
11874
11875 #[test]
11876 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11877 let mut fragments = HashMap::new();
11878 fragments.insert(
11879 "index.html".to_string(),
11880 FragmentList {
11881 fragments: vec![
11882 WebUIFragment::raw("<html><body><style>".to_string()),
11883 WebUIFragment::signal("tokens.light", true),
11884 WebUIFragment::raw("</style>".to_string()),
11885 structural_fragment("body_end"),
11886 WebUIFragment::raw("</body></html>".to_string()),
11887 ],
11888 contains_boundary: false,
11889 },
11890 );
11891 fragments.insert(
11892 "app-shell".to_string(),
11893 FragmentList {
11894 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
11895 contains_boundary: false,
11896 },
11897 );
11898 let index_fragments = fragments
11899 .get_mut("index.html")
11900 .expect("index fixture should exist");
11901 index_fragments
11902 .fragments
11903 .insert(1, WebUIFragment::component("app-shell"));
11904 let mut protocol = WebUIProtocol::new(fragments);
11905 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11906 protocol.components.insert(
11911 "app-shell".to_string(),
11912 webui_protocol::ComponentData {
11913 hydration_mode: StateProjectionMode::Keys as i32,
11914 hydration_keys: vec!["name".to_string()],
11915 ..Default::default()
11916 },
11917 );
11918 let state = test_json!({
11919 "name": "Alice",
11920 "tokens": {
11921 "light": "--color-brand: red;"
11922 }
11923 });
11924 let mut writer = TestWriter::new();
11925 let handler = WebUIHandler::with_plugin(|| {
11926 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11927 });
11928 handler.handle(
11929 &protocol,
11930 &state,
11931 &RenderOptions::new("index.html", "/"),
11932 &mut writer,
11933 )?;
11934 let output = writer.get_content();
11935
11936 assert!(output.contains("--color-brand: red;"));
11938 assert!(output.contains(r#""name":"Alice""#));
11940 assert!(!output.contains(r#""tokens""#));
11941 Ok(())
11942 }
11943
11944 #[test]
11945 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11946 let mut fragments = HashMap::new();
11947 fragments.insert(
11948 "index.html".to_string(),
11949 FragmentList {
11950 fragments: vec![
11951 WebUIFragment::raw("<html><body>"),
11952 structural_fragment("body_end"),
11953 WebUIFragment::raw("</body></html>"),
11954 ],
11955 contains_boundary: false,
11956 },
11957 );
11958 let protocol = WebUIProtocol::new(fragments);
11959 let state = test_json!({
11960 "client": "visible",
11961 "serverOnly": "also preserved",
11962 });
11963 let handler = WebUIHandler::with_plugin(|| {
11964 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11965 });
11966 let mut writer = TestWriter::new();
11967 handler.handle(
11968 &protocol,
11969 &state,
11970 &RenderOptions::new("index.html", "/"),
11971 &mut writer,
11972 )?;
11973 let output = writer.get_content();
11974 assert!(output.contains(r#""client":"visible""#));
11975 assert!(output.contains(r#""serverOnly":"also preserved""#));
11976 Ok(())
11977 }
11978
11979 #[test]
11980 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11981 let mut fragments = HashMap::new();
11982 fragments.insert(
11983 "index.html".to_string(),
11984 FragmentList {
11985 fragments: vec![
11986 WebUIFragment::raw("<html><body>"),
11987 WebUIFragment::component("app-shell"),
11988 structural_fragment("body_end"),
11989 WebUIFragment::raw("</body></html>"),
11990 ],
11991 contains_boundary: false,
11992 },
11993 );
11994 fragments.insert(
11995 "app-shell".to_string(),
11996 FragmentList {
11997 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11998 contains_boundary: false,
11999 },
12000 );
12001 let mut protocol = WebUIProtocol::new(fragments);
12002 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12003 protocol.components.insert(
12004 "app-shell".to_string(),
12005 webui_protocol::ComponentData {
12006 hydration_mode: StateProjectionMode::All as i32,
12007 ..Default::default()
12008 },
12009 );
12010 let state = test_json!({
12011 "known": "value",
12012 "possiblyInherited": "must not be dropped",
12013 });
12014 let handler = WebUIHandler::with_plugin(|| {
12015 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12016 });
12017 let mut writer = TestWriter::new();
12018 handler.handle(
12019 &protocol,
12020 &state,
12021 &RenderOptions::new("index.html", "/"),
12022 &mut writer,
12023 )?;
12024 let output = writer.get_content();
12025 assert!(output.contains(r#""known":"value""#));
12026 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12027 Ok(())
12028 }
12029
12030 #[test]
12031 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12032 let mut fragments = HashMap::new();
12033 fragments.insert(
12034 "index.html".to_string(),
12035 FragmentList {
12036 fragments: vec![
12037 WebUIFragment::raw("<html><body>"),
12038 WebUIFragment::component("app-shell"),
12039 structural_fragment("body_end"),
12040 WebUIFragment::raw("</body></html>"),
12041 ],
12042 contains_boundary: false,
12043 },
12044 );
12045 fragments.insert(
12046 "app-shell".to_string(),
12047 FragmentList {
12048 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12049 contains_boundary: false,
12050 },
12051 );
12052 let mut protocol = WebUIProtocol::new(fragments);
12053 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12054 let state = test_json!({
12055 "known": "value",
12056 "serverOnly": "must not be dropped",
12057 });
12058 let handler = WebUIHandler::with_plugin(|| {
12059 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12060 });
12061 let mut writer = TestWriter::new();
12062 handler.handle(
12063 &protocol,
12064 &state,
12065 &RenderOptions::new("index.html", "/"),
12066 &mut writer,
12067 )?;
12068 let output = writer.get_content();
12069 assert!(output.contains(r#""known":"value""#));
12070 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12071 Ok(())
12072 }
12073
12074 #[test]
12075 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12076 let mut fragments = HashMap::new();
12077 fragments.insert(
12078 "index.html".to_string(),
12079 FragmentList {
12080 fragments: vec![
12081 WebUIFragment::raw("<html><body>"),
12082 WebUIFragment::component("app-shell"),
12083 structural_fragment("body_end"),
12084 WebUIFragment::raw("</body></html>"),
12085 ],
12086 contains_boundary: false,
12087 },
12088 );
12089 fragments.insert(
12090 "app-shell".to_string(),
12091 FragmentList {
12092 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12093 contains_boundary: false,
12094 },
12095 );
12096 let mut protocol = WebUIProtocol::new(fragments);
12097 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12098 protocol.components.insert(
12099 "app-shell".to_string(),
12100 webui_protocol::ComponentData {
12101 hydration_mode: i32::MAX,
12102 ..Default::default()
12103 },
12104 );
12105 let state = test_json!({
12106 "known": "value",
12107 "serverOnly": "must not be dropped",
12108 });
12109 let handler = WebUIHandler::with_plugin(|| {
12110 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12111 });
12112 let mut writer = TestWriter::new();
12113 handler.handle(
12114 &protocol,
12115 &state,
12116 &RenderOptions::new("index.html", "/"),
12117 &mut writer,
12118 )?;
12119 let output = writer.get_content();
12120 assert!(output.contains(r#""known":"value""#));
12121 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12122 Ok(())
12123 }
12124
12125 #[test]
12126 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12127 let mut protocol = WebUIProtocol::new(HashMap::new());
12128 protocol.components.insert(
12129 "app-shell".to_string(),
12130 webui_protocol::ComponentData {
12131 navigation_keys: vec!["selected".to_string()],
12132 ..Default::default()
12133 },
12134 );
12135
12136 match collect_navigation_state(&protocol, ["app-shell"]) {
12137 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12138 StateSelection::Full
12139 | StateSelection::KeyIds(_)
12140 | StateSelection::FullExceptKeyIds(_) => {
12141 panic!("legacy navigation keys should remain owned and projected")
12142 }
12143 }
12144 }
12145
12146 #[test]
12147 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12148 let mut protocol = WebUIProtocol::new(HashMap::new());
12149 protocol.components.insert(
12150 "app-shell".to_string(),
12151 webui_protocol::ComponentData::default(),
12152 );
12153
12154 assert!(matches!(
12155 collect_navigation_state(&protocol, ["app-shell"]),
12156 StateSelection::Full
12157 ));
12158 }
12159
12160 #[test]
12161 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12162 let mut fragments = HashMap::new();
12163 fragments.insert(
12164 "index.html".to_string(),
12165 FragmentList {
12166 fragments: vec![
12167 WebUIFragment::raw("<html><body>".to_string()),
12168 structural_fragment("body_end"),
12169 WebUIFragment::raw("</body></html>".to_string()),
12170 ],
12171 contains_boundary: false,
12172 },
12173 );
12174 let mut protocol = WebUIProtocol::new(fragments);
12175 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12176 let state = test_json!({
12177 "title": "Legacy state",
12178 "serverOnly": "preserved",
12179 });
12180 let handler = WebUIHandler::with_plugin(|| {
12181 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12182 });
12183
12184 let mut writer = TestWriter::new();
12185 handler.handle(
12186 &protocol,
12187 &state,
12188 &RenderOptions::new("index.html", "/"),
12189 &mut writer,
12190 )?;
12191 assert!(writer.get_content().contains(r#""state":{}"#));
12192 assert!(!writer.get_content().contains("Legacy state"));
12193 assert!(!writer.get_content().contains("preserved"));
12194 Ok(())
12195 }
12196
12197 #[test]
12198 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12199 let mut fragments = HashMap::new();
12200 fragments.insert(
12201 "index.html".to_string(),
12202 FragmentList {
12203 fragments: vec![
12204 WebUIFragment::raw("<html><body>"),
12205 WebUIFragment::component("items-page"),
12206 structural_fragment("body_end"),
12207 WebUIFragment::raw("</body></html>"),
12208 ],
12209 contains_boundary: false,
12210 },
12211 );
12212 fragments.insert(
12213 "items-page".to_string(),
12214 FragmentList {
12215 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12216 contains_boundary: false,
12217 },
12218 );
12219 let mut protocol = WebUIProtocol::new(fragments);
12220 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12221 protocol.components.insert(
12222 "items-page".to_string(),
12223 webui_protocol::ComponentData {
12224 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12225 navigation_mode: Some(StateProjectionMode::Keys as i32),
12226 navigation_keys: vec!["items".into()],
12227 ..Default::default()
12228 },
12229 );
12230 let state = test_json!({
12231 "items": ["STATE_SENTINEL"],
12232 "serverOnly": "SECRET_SENTINEL",
12233 });
12234 let handler = WebUIHandler::with_plugin(|| {
12235 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12236 });
12237 let mut writer = TestWriter::new();
12238
12239 handler.handle(
12240 &protocol,
12241 &state,
12242 &RenderOptions::new("index.html", "/"),
12243 &mut writer,
12244 )?;
12245
12246 let output = writer.get_content();
12247 assert!(output.contains(r#""state":{}"#));
12248 assert!(!output.contains("STATE_SENTINEL"));
12249 assert!(!output.contains("SECRET_SENTINEL"));
12250 Ok(())
12251 }
12252
12253 #[test]
12254 fn write_selected_state_projects_and_escapes() {
12255 let state = test_json!({
12258 "drop": "secret",
12259 "keep": "</script><b>"
12260 });
12261 let keys = ["keep"];
12262 let selection = StateSelection::Keys(keys.to_vec());
12263 let mut sink = TestWriter::new();
12264 let mut scratch = Vec::new();
12265 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12266 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12267 }
12268
12269 #[test]
12270 fn write_selected_state_non_object_projection_emits_empty_object() {
12271 let state = test_json!("scalar state has nothing hydratable");
12272 let keys: [&str; 0] = [];
12273 let selection = StateSelection::Keys(keys.to_vec());
12274 let mut sink = TestWriter::new();
12275 let mut scratch = Vec::new();
12276 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12277 assert_eq!(sink.get_content(), "{}");
12278 }
12279
12280 #[test]
12281 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12282 let state = test_json!({
12283 "keptA": 1,
12284 "keptB": 2,
12285 "serverOnlyA": 3,
12286 "serverOnlyB": 4,
12287 });
12288 let keys = ["keptA", "keptA", "keptB", "missing"];
12289 let selection = StateSelection::Keys(keys.to_vec());
12290 let mut sink = TestWriter::new();
12291 let mut scratch = Vec::new();
12292 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12293 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12294 }
12295
12296 #[test]
12297 fn write_selected_state_map_first_matches_schema_first_output() {
12298 let state = test_json!({
12299 "keptA": 1,
12300 "keptB": 2,
12301 });
12302 let keys = ["keptA", "keptB", "missingA", "missingB"];
12303 let selection = StateSelection::Keys(keys.to_vec());
12304 let mut sink = TestWriter::new();
12305 let mut scratch = Vec::new();
12306 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12307 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12308 }
12309
12310 #[test]
12311 fn write_selected_state_full_preserves_and_escapes_state() {
12312 let state = test_json!({
12313 "serverOnly": "</script><b>",
12314 "value": 42,
12315 });
12316 let mut sink = TestWriter::new();
12317 let mut scratch = Vec::new();
12318 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12319 assert_eq!(
12320 sink.get_content(),
12321 r#"{"serverOnly":"<\/script><b>","value":42}"#
12322 );
12323 }
12324
12325 #[test]
12326 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12327 let preloads = vec![ComponentAssetStylePreload {
12328 root: "lazy-panel".to_string(),
12329 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12330 }];
12331
12332 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12333
12334 assert_eq!(
12335 manifest,
12336 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12337 );
12338 }
12339
12340 #[test]
12341 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12342 let mut sink = TestWriter::new();
12348 let mut scratch: Vec<u8> = Vec::new();
12349 assert_eq!(scratch.capacity(), 0);
12351
12352 let mut large = serde_json::Map::new();
12354 for index in 0..64 {
12355 large.insert(
12356 format!("component_{index:04}"),
12357 Value::String("x".repeat(48)),
12358 );
12359 }
12360 let large = Value::Object(large);
12361 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12362 let high_water = scratch.capacity();
12363 assert!(high_water > 0);
12364
12365 for seq in 0..8 {
12368 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12369 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12370 assert_eq!(
12371 scratch.capacity(),
12372 high_water,
12373 "scratch reallocated on checkpoint {seq}"
12374 );
12375 }
12376 }
12377
12378 #[test]
12379 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12380 let mut fragments = HashMap::new();
12381 fragments.insert(
12382 "index.html".to_string(),
12383 FragmentList {
12384 fragments: vec![
12385 WebUIFragment::raw("<html><body>"),
12386 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12387 path: "/".to_string(),
12388 fragment_id: "home-page".to_string(),
12389 exact: true,
12390 ..Default::default()
12391 }),
12392 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12393 path: "/admin".to_string(),
12394 fragment_id: "admin-page".to_string(),
12395 exact: true,
12396 ..Default::default()
12397 }),
12398 structural_fragment("body_end"),
12399 WebUIFragment::raw("</body></html>"),
12400 ],
12401
12402 contains_boundary: false,
12403 },
12404 );
12405 fragments.insert(
12406 "home-page".to_string(),
12407 FragmentList {
12408 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12409 contains_boundary: false,
12410 },
12411 );
12412 fragments.insert(
12413 "admin-page".to_string(),
12414 FragmentList {
12415 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12416 contains_boundary: false,
12417 },
12418 );
12419
12420 let mut protocol = WebUIProtocol::new(fragments);
12421 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12422 protocol.components.insert(
12423 "home-page".to_string(),
12424 webui_protocol::ComponentData {
12425 template_json: "{}".to_string(),
12426 hydration_mode: StateProjectionMode::Keys as i32,
12427 hydration_keys: vec!["homeTitle".to_string()],
12428 ..Default::default()
12429 },
12430 );
12431 protocol.components.insert(
12432 "admin-page".to_string(),
12433 webui_protocol::ComponentData {
12434 template_json: "{}".to_string(),
12435 hydration_mode: StateProjectionMode::Keys as i32,
12436 hydration_keys: vec!["adminToken".to_string()],
12437 ..Default::default()
12438 },
12439 );
12440 let state = test_json!({
12441 "homeTitle": "Welcome",
12442 "adminToken": "TOP_SECRET_SENTINEL",
12443 });
12444 let handler = WebUIHandler::with_plugin(|| {
12445 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12446 });
12447 let mut writer = TestWriter::new();
12448 handler.handle(
12449 &protocol,
12450 &state,
12451 &RenderOptions::new("index.html", "/"),
12452 &mut writer,
12453 )?;
12454 let output = writer.get_content();
12455 assert!(output.contains(r#""homeTitle":"Welcome""#));
12456 assert!(!output.contains("TOP_SECRET_SENTINEL"));
12457 assert!(!output.contains(r#""adminToken""#));
12458 Ok(())
12459 }
12460
12461 #[test]
12462 fn test_component_attr_name_aria() {
12463 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12465 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12466 assert_eq!(
12467 component_attr_name("aria-activedescendant"),
12468 "ariaActiveDescendant"
12469 );
12470 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12471 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12472 }
12473
12474 #[test]
12475 fn test_component_attr_name_html_global() {
12476 assert_eq!(component_attr_name("readonly"), "readOnly");
12477 assert_eq!(component_attr_name("tabindex"), "tabIndex");
12478 assert_eq!(component_attr_name("accesskey"), "accessKey");
12479 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12480 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12481 assert_eq!(component_attr_name("inputmode"), "inputMode");
12482 assert_eq!(component_attr_name("maxlength"), "maxLength");
12483 assert_eq!(component_attr_name("minlength"), "minLength");
12484 assert_eq!(component_attr_name("novalidate"), "noValidate");
12485 assert_eq!(component_attr_name("formaction"), "formAction");
12486 assert_eq!(component_attr_name("ismap"), "isMap");
12487 assert_eq!(component_attr_name("usemap"), "useMap");
12488 }
12489
12490 #[test]
12491 fn test_component_attr_name_strips_colon() {
12492 assert_eq!(component_attr_name(":readonly"), "readOnly");
12493 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12494 assert_eq!(component_attr_name(":data-title"), "dataTitle");
12495 }
12496
12497 #[test]
12498 fn test_component_attr_name_regular() {
12499 assert_eq!(component_attr_name("data-title"), "dataTitle");
12500 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12501 assert_eq!(component_attr_name("simple"), "simple");
12502 }
12503
12504 fn make_query_route_protocol() -> WebUIProtocol {
12507 use webui_protocol::WebUiFragmentRoute;
12508
12509 let mut fragments = HashMap::new();
12510
12511 fragments.insert(
12512 "index.html".to_string(),
12513 FragmentList {
12514 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12515 path: "/".into(),
12516 fragment_id: "app-shell".into(),
12517 exact: false,
12518 children: vec![
12519 WebUiFragmentRoute {
12520 path: "compose".into(),
12521 fragment_id: "compose-page".into(),
12522 exact: true,
12523 allowed_query: "action,to,subject".into(),
12524 keep_alive: false,
12525 ..Default::default()
12526 },
12527 WebUiFragmentRoute {
12528 path: "settings".into(),
12529 fragment_id: "settings-page".into(),
12530 exact: true,
12531 keep_alive: false,
12532 ..Default::default()
12533 },
12534 ],
12535 keep_alive: false,
12536 ..Default::default()
12537 })],
12538
12539 contains_boundary: false,
12540 },
12541 );
12542
12543 fragments.insert(
12544 "app-shell".to_string(),
12545 FragmentList {
12546 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12547 contains_boundary: false,
12548 },
12549 );
12550 fragments.insert(
12551 "compose-page".to_string(),
12552 FragmentList {
12553 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12554 contains_boundary: false,
12555 },
12556 );
12557 fragments.insert(
12558 "settings-page".to_string(),
12559 FragmentList {
12560 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12561 contains_boundary: false,
12562 },
12563 );
12564
12565 WebUIProtocol::new(fragments)
12566 }
12567
12568 #[test]
12569 fn test_matched_route_omits_query_attr_from_dom() {
12570 let protocol = make_query_route_protocol();
12571 let state = test_json!({});
12572 let handler = WebUIHandler::new();
12573 let mut writer = TestWriter::new();
12574
12575 handler
12576 .handle(
12577 &protocol,
12578 &state,
12579 &RenderOptions::new("index.html", "/compose"),
12580 &mut writer,
12581 )
12582 .expect("render failed");
12583
12584 let html = writer.get_content();
12585 assert!(
12587 !html.contains(r#"query="action,to,subject""#),
12588 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12589 );
12590 }
12591
12592 #[test]
12593 fn test_nonmatched_route_omits_query_attr_from_dom() {
12594 let protocol = make_query_route_protocol();
12595 let state = test_json!({});
12596 let handler = WebUIHandler::new();
12597 let mut writer = TestWriter::new();
12598
12599 handler
12600 .handle(
12601 &protocol,
12602 &state,
12603 &RenderOptions::new("index.html", "/settings"),
12604 &mut writer,
12605 )
12606 .expect("render failed");
12607
12608 let html = writer.get_content();
12609 assert!(
12611 !html.contains(r#"query="#),
12612 "hidden route should not have query attr: {html}"
12613 );
12614 }
12615
12616 #[test]
12617 fn test_route_without_query_has_no_query_attr() {
12618 let protocol = make_query_route_protocol();
12619 let state = test_json!({});
12620 let handler = WebUIHandler::new();
12621 let mut writer = TestWriter::new();
12622
12623 handler
12624 .handle(
12625 &protocol,
12626 &state,
12627 &RenderOptions::new("index.html", "/settings"),
12628 &mut writer,
12629 )
12630 .expect("render failed");
12631
12632 let html = writer.get_content();
12633 let settings_idx = html
12635 .find(r#"component="settings-page""#)
12636 .expect("settings route should exist");
12637 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12638 assert!(
12639 !settings_tag.contains("query="),
12640 "route without allowed_query should not emit query attr: {settings_tag}"
12641 );
12642 }
12643
12644 fn build_head_body_protocol() -> WebUIProtocol {
12649 let mut fragments = HashMap::new();
12650 fragments.insert(
12651 "index.html".to_string(),
12652 FragmentList {
12653 fragments: vec![
12654 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12655 structural_fragment("head_end"),
12656 WebUIFragment::raw("</head><body>hello".to_string()),
12657 structural_fragment("body_end"),
12658 WebUIFragment::raw("</body></html>".to_string()),
12659 ],
12660 contains_boundary: false,
12661 },
12662 );
12663 WebUIProtocol::new(fragments)
12664 }
12665
12666 #[test]
12667 fn head_inject_emits_at_head_end_boundary() {
12668 let protocol = build_head_body_protocol();
12669 let state = test_json!({});
12670 let mut writer = TestWriter::new();
12671 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12672 handle(&protocol, &state, &opts, &mut writer).unwrap();
12673 let html = writer.get_content();
12674 let inject_idx = html
12676 .find("<link rel=preload>")
12677 .expect("inject HTML missing");
12678 let head_close_idx = html.find("</head>").expect("</head> missing");
12679 assert!(
12680 inject_idx < head_close_idx,
12681 "head_inject must appear before </head>: {html}"
12682 );
12683 assert_eq!(html.matches("<link rel=preload>").count(), 1);
12685 }
12686
12687 #[test]
12688 fn component_render_policy_css_emits_once_in_head_with_nonce() {
12689 let mut protocol = build_head_body_protocol();
12690 protocol.component_render_css = concat!(
12691 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12692 "contain-intrinsic-block-size:auto 18rem;}"
12693 )
12694 .to_string();
12695 let state = test_json!({});
12696 let mut writer = TestWriter::new();
12697 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12698 handle(&protocol, &state, &opts, &mut writer).unwrap();
12699 let html = writer.get_content();
12700
12701 let style = concat!(
12702 r#"<style data-webui-render-policy nonce="test-nonce">"#,
12703 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12704 "contain-intrinsic-block-size:auto 18rem;}</style>"
12705 );
12706 let style_index = html.find(style).expect("render policy style missing");
12707 let head_close = html.find("</head>").expect("</head> missing");
12708 assert!(style_index < head_close);
12709 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12710 }
12711
12712 #[test]
12713 fn body_inject_emits_at_body_end_boundary() {
12714 let protocol = build_head_body_protocol();
12715 let state = test_json!({});
12716 let mut writer = TestWriter::new();
12717 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12718 handle(&protocol, &state, &opts, &mut writer).unwrap();
12719 let html = writer.get_content();
12720 let inject_idx = html
12721 .find("<script>lr</script>")
12722 .expect("inject HTML missing");
12723 let body_close_idx = html.find("</body>").expect("</body> missing");
12724 assert!(
12725 inject_idx < body_close_idx,
12726 "body_inject must appear before </body>: {html}"
12727 );
12728 assert_eq!(html.matches("<script>lr</script>").count(), 1);
12729 }
12730
12731 fn build_all_boundaries_protocol() -> WebUIProtocol {
12736 let mut fragments = HashMap::new();
12737 fragments.insert(
12738 "index.html".to_string(),
12739 FragmentList {
12740 fragments: vec![
12741 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12742 structural_fragment("head_end"),
12743 WebUIFragment::raw("</head><body>".to_string()),
12744 structural_fragment("body_start"),
12745 WebUIFragment::raw("hello".to_string()),
12746 structural_fragment("body_end"),
12747 WebUIFragment::raw("</body></html>".to_string()),
12748 ],
12749 contains_boundary: false,
12750 },
12751 );
12752 WebUIProtocol::new(fragments)
12753 }
12754
12755 fn state_inject_options<'a>() -> RenderOptions<'a> {
12756 RenderOptions::new("index.html", "/")
12757 }
12758
12759 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12760 let mut writer = TestWriter::new();
12761 handle(protocol, state, options, &mut writer).unwrap();
12762 writer.get_content().to_string()
12763 }
12764
12765 #[test]
12766 fn state_inject_emits_at_every_structural_boundary() {
12767 let protocol = build_all_boundaries_protocol();
12768 let state = test_json!({
12769 "$webui": {
12770 "headEnd": "<meta name=he>",
12771 "bodyStart": "<span id=bs></span>",
12772 "bodyEnd": "<script>be</script>",
12773 }
12774 });
12775 let html = render_with(&protocol, &state, &state_inject_options());
12776
12777 let head_end = html.find("<meta name=he>").expect("headEnd missing");
12778 let head_close = html.find("</head>").expect("</head> missing");
12779 let body_open = html.find("<body>").expect("<body> missing");
12780 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12781 let hello = html.find("hello").expect("body content missing");
12782 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12783 let body_close = html.find("</body>").expect("</body> missing");
12784
12785 assert!(
12786 head_end < head_close,
12787 "headEnd must precede </head>: {html}"
12788 );
12789 assert!(
12790 body_open < body_start && body_start < hello,
12791 "bodyStart must sit immediately after <body>: {html}"
12792 );
12793 assert!(
12794 hello < body_end && body_end < body_close,
12795 "bodyEnd must precede </body>: {html}"
12796 );
12797
12798 for needle in [
12799 "<meta name=he>",
12800 "<span id=bs></span>",
12801 "<script>be</script>",
12802 ] {
12803 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12804 }
12805 }
12806
12807 #[test]
12808 fn state_inject_follows_render_options_inject() {
12809 let protocol = build_all_boundaries_protocol();
12810 let state = test_json!({
12811 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12812 });
12813 let options = RenderOptions::new("index.html", "/")
12814 .with_head_inject("<!--opt-he-->")
12815 .with_body_inject("<!--opt-be-->");
12816 let html = render_with(&protocol, &state, &options);
12817
12818 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12819 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12820 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12821 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12822
12823 assert!(
12824 opt_he < state_he,
12825 "RenderOptions head_inject must precede the state-supplied value: {html}"
12826 );
12827 assert!(
12828 opt_be < state_be,
12829 "RenderOptions body_inject must precede the state-supplied value: {html}"
12830 );
12831 }
12832
12833 #[test]
12834 fn malformed_state_inject_values_are_inert() {
12835 let protocol = build_all_boundaries_protocol();
12836 for state in [
12839 test_json!({}),
12840 test_json!({ "$webui": "not-an-object" }),
12841 test_json!({ "$webui": [] }),
12842 test_json!({ "$webui": null }),
12843 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12844 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12845 ] {
12846 let html = render_with(&protocol, &state, &state_inject_options());
12847 assert!(
12848 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12849 "malformed reserved state must render the document unchanged: {html}"
12850 );
12851 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12852 assert!(!html.contains("42"), "non-string member leaked: {html}");
12853 }
12854 }
12855
12856 #[test]
12857 fn state_inject_never_reaches_the_hydration_payload() {
12858 let protocol = build_all_boundaries_protocol();
12859 let state = test_json!({
12860 "visible": "keep",
12861 "$webui": { "bodyEnd": "<script>secret</script>" }
12862 });
12863 let handler = WebUIHandler::with_plugin(|| {
12864 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12865 });
12866 let runtime = Protocol::new(protocol.clone());
12867 let mut writer = TestWriter::new();
12868 handler
12869 .render(&runtime, &state, &state_inject_options(), &mut writer)
12870 .unwrap();
12871 let html = writer.get_content();
12872
12873 let data_start = html
12874 .find(r#"<script type="application/json" id="webui-data""#)
12875 .expect("hydration block missing");
12876 let data_end = html[data_start..]
12877 .find("</script>")
12878 .map(|offset| data_start + offset)
12879 .expect("hydration block never closes");
12880 let payload = &html[data_start..data_end];
12881 assert!(
12882 !payload.contains("$webui"),
12883 "reserved key must be stripped from the hydration payload: {payload}"
12884 );
12885 assert!(
12886 payload.contains("visible"),
12887 "ordinary state must survive the filter: {payload}"
12888 );
12889 let inject = html
12892 .find("<script>secret</script>")
12893 .expect("inject missing");
12894 let body_close = html.find("</body>").expect("</body> missing");
12895 assert!(
12896 data_end < inject && inject < body_close,
12897 "state bodyEnd must follow the hydration block and precede </body>: {html}"
12898 );
12899 }
12900
12901 #[test]
12902 fn projected_hydration_strips_reserved_state_inject_key() {
12903 let mut fragments = HashMap::new();
12904 fragments.insert(
12905 "index.html".to_string(),
12906 FragmentList {
12907 fragments: vec![
12908 WebUIFragment::raw("<html><body>"),
12909 WebUIFragment::component("app-shell"),
12910 structural_fragment("body_end"),
12911 WebUIFragment::raw("</body></html>"),
12912 ],
12913 contains_boundary: false,
12914 },
12915 );
12916 fragments.insert(
12917 "app-shell".to_string(),
12918 FragmentList {
12919 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12920 contains_boundary: false,
12921 },
12922 );
12923 let mut document = WebUIProtocol::new(fragments);
12924 document.initial_state_strategy = InitialStateStrategy::Components as i32;
12925 document.components.insert(
12926 "app-shell".to_string(),
12927 webui_protocol::ComponentData {
12928 hydration_mode: StateProjectionMode::Keys as i32,
12929 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12930 ..Default::default()
12931 },
12932 );
12933 let state = test_json!({
12934 "$webui": { "bodyEnd": "<script>secret</script>" },
12935 "serverOnly": "drop",
12936 "visible": "keep",
12937 });
12938 let handler = WebUIHandler::with_plugin(|| {
12939 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12940 });
12941 let mut writer = TestWriter::new();
12942
12943 handler
12944 .render(
12945 &Protocol::new(document),
12946 &state,
12947 &RenderOptions::new("index.html", "/"),
12948 &mut writer,
12949 )
12950 .unwrap();
12951 let html = writer.get_content();
12952 let data_start = html
12953 .find(r#"<script type="application/json" id="webui-data""#)
12954 .expect("hydration block missing");
12955 let data_end = html[data_start..]
12956 .find("</script>")
12957 .map(|offset| data_start + offset)
12958 .expect("hydration block never closes");
12959 let payload = &html[data_start..data_end];
12960
12961 assert!(
12962 !payload.contains(STATE_INJECT_KEY),
12963 "reserved key leaked: {payload}"
12964 );
12965 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12966 assert!(!payload.contains("serverOnly"), "{payload}");
12967 assert!(html.contains("<script>secret</script>"), "{html}");
12968 }
12969
12970 #[test]
12971 fn write_selected_state_strips_reserved_key_from_full_state() {
12972 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12973 let mut sink = TestWriter::new();
12974 let mut scratch = Vec::new();
12975 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12976 let json = sink.get_content();
12977 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12978 assert!(
12979 json.contains("\"a\":1") && json.contains("\"z\":2"),
12980 "{json}"
12981 );
12982 }
12983
12984 #[test]
12985 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12986 let mut protocol = WebUIProtocol::new(HashMap::new());
12989 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12990 protocol.fragments.insert(
12991 "keep-card".to_string(),
12992 webui_protocol::FragmentList::default(),
12993 );
12994 protocol.components.insert(
12995 "keep-card".to_string(),
12996 webui_protocol::ComponentData {
12997 hydration_mode: StateProjectionMode::Keys as i32,
12998 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12999 ..Default::default()
13000 },
13001 );
13002 let protocol = Protocol::new(protocol);
13003 let index = protocol.component_reachability();
13004 let component = protocol.component_index()["keep-card"];
13005 let mut ids = Vec::new();
13006 assert!(!collect_hydration_key_ids_into(
13007 protocol.protocol(),
13008 index,
13009 [component],
13010 &mut ids
13011 ));
13012
13013 let state = test_json!({
13014 "$webui": { "bodyEnd": "<b>x</b>" },
13015 "keep": 1,
13016 });
13017 let mut sink = TestWriter::new();
13018 let mut scratch = Vec::new();
13019
13020 write_selected_state(
13021 &mut sink,
13022 &mut scratch,
13023 &state,
13024 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13025 )
13026 .unwrap();
13027
13028 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13029 }
13030
13031 #[test]
13032 fn write_selected_state_full_is_unchanged_without_reserved_key() {
13033 let state = test_json!({ "a": 1, "z": 2 });
13034 let mut sink = TestWriter::new();
13035 let mut scratch = Vec::new();
13036 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13037 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13038 }
13039
13040 #[test]
13041 fn body_start_hook_dedupes_on_malformed_protocol() {
13042 let mut fragments = HashMap::new();
13043 fragments.insert(
13044 "index.html".to_string(),
13045 FragmentList {
13046 fragments: vec![
13047 WebUIFragment::raw("<html><head></head><body>".to_string()),
13048 structural_fragment("body_start"),
13049 structural_fragment("body_start"),
13050 WebUIFragment::raw("</body></html>".to_string()),
13051 ],
13052 contains_boundary: false,
13053 },
13054 );
13055 let protocol = WebUIProtocol::new(fragments);
13056 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13057 let html = render_with(&protocol, &state, &state_inject_options());
13058 assert_eq!(
13059 html.matches("<i>once</i>").count(),
13060 1,
13061 "duplicate body_start signals must not duplicate the inject: {html}"
13062 );
13063 }
13064
13065 #[test]
13066 fn injects_are_no_op_when_unset() {
13067 let protocol = build_head_body_protocol();
13068 let state = test_json!({});
13069 let mut writer = TestWriter::new();
13070 handle(
13071 &protocol,
13072 &state,
13073 &RenderOptions::new("index.html", "/"),
13074 &mut writer,
13075 )
13076 .unwrap();
13077 let html = writer.get_content();
13078 assert!(!html.contains("<link rel=preload>"));
13079 assert!(!html.contains("<script>lr</script>"));
13080 }
13081
13082 #[test]
13083 fn empty_inject_string_treated_as_unset() {
13084 let protocol = build_head_body_protocol();
13085 let state = test_json!({});
13086 let mut writer = TestWriter::new();
13087 let opts = RenderOptions::new("index.html", "/")
13088 .with_head_inject("")
13089 .with_body_inject("");
13090 handle(&protocol, &state, &opts, &mut writer).unwrap();
13091 let html = writer.get_content();
13094 assert!(html.contains("</head>"));
13095 assert!(html.contains("</body>"));
13096 }
13097
13098 #[test]
13099 fn inject_html_is_passed_through_verbatim() {
13100 let protocol = build_head_body_protocol();
13104 let state = test_json!({});
13105 let mut writer = TestWriter::new();
13106 let opts =
13107 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13108 handle(&protocol, &state, &opts, &mut writer).unwrap();
13109 assert!(writer.get_content().contains("<script>var x=1;</script>"));
13110 }
13111
13112 #[test]
13119 fn injects_robust_against_marker_literals_in_content() {
13120 let mut fragments = HashMap::new();
13121 fragments.insert(
13125 "index.html".to_string(),
13126 FragmentList {
13127 fragments: vec![
13128 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13129 structural_fragment("head_end"),
13130 WebUIFragment::raw(
13131 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13132 ),
13133 structural_fragment("body_end"),
13134 WebUIFragment::raw("</body></html>".to_string()),
13135 ],
13136 contains_boundary: false,
13137 },
13138 );
13139 let protocol = WebUIProtocol::new(fragments);
13140 let state = test_json!({});
13141 let mut writer = TestWriter::new();
13142 let opts = RenderOptions::new("index.html", "/")
13143 .with_head_inject("<HEAD-INJ>")
13144 .with_body_inject("<BODY-INJ>");
13145 handle(&protocol, &state, &opts, &mut writer).unwrap();
13146 let html = writer.get_content();
13147 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13150 let head_close_idx = html.find("</head>").expect("</head> missing");
13151 assert!(head_inj_idx < head_close_idx);
13152 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13156 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13158 assert!(
13159 body_inj_idx < body_close_idx,
13160 "body_inject must precede the structural </body>: {html}"
13161 );
13162 assert!(html.contains("<!-- </body> </head> -->"));
13164 }
13165
13166 #[test]
13170 fn both_injects_fire_at_correct_boundaries() {
13171 let protocol = build_head_body_protocol();
13172 let state = test_json!({});
13173 let mut writer = TestWriter::new();
13174 let opts = RenderOptions::new("index.html", "/")
13175 .with_head_inject("<META-HEAD>")
13176 .with_body_inject("<SCRIPT-BODY>");
13177 handle(&protocol, &state, &opts, &mut writer).unwrap();
13178 let html = writer.get_content();
13179 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13180 let head_close = html.find("</head>").expect("</head> missing");
13181 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13182 let body_close = html.find("</body>").expect("</body> missing");
13183 assert!(head_idx < head_close, "head_inject before </head>");
13184 assert!(head_close < body_idx, "body_inject after </head>");
13185 assert!(body_idx < body_close, "body_inject before </body>");
13186 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13187 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13188 }
13189
13190 #[test]
13196 fn injects_dedupe_against_duplicate_signals() {
13197 let mut fragments = HashMap::new();
13198 fragments.insert(
13199 "index.html".to_string(),
13200 FragmentList {
13201 fragments: vec![
13202 WebUIFragment::raw("<html><head>".to_string()),
13203 structural_fragment("head_end"),
13204 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13207 structural_fragment("body_end"),
13208 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13210 ],
13211 contains_boundary: false,
13212 },
13213 );
13214 let protocol = WebUIProtocol::new(fragments);
13215 let state = test_json!({});
13216 let mut writer = TestWriter::new();
13217 let opts = RenderOptions::new("index.html", "/")
13218 .with_head_inject("<HINJ>")
13219 .with_body_inject("<BINJ>");
13220 handle(&protocol, &state, &opts, &mut writer).unwrap();
13221 let html = writer.get_content();
13222 assert_eq!(
13223 html.matches("<HINJ>").count(),
13224 1,
13225 "head_inject must emit exactly once even with duplicate head_end signals"
13226 );
13227 assert_eq!(
13228 html.matches("<BINJ>").count(),
13229 1,
13230 "body_inject must emit exactly once even with duplicate body_end signals"
13231 );
13232 }
13233
13234 #[test]
13238 fn injects_no_op_when_no_head_or_body_signals() {
13239 let mut fragments = HashMap::new();
13240 fragments.insert(
13241 "index.html".to_string(),
13242 FragmentList {
13243 fragments: vec![WebUIFragment::raw(
13244 "<my-component>hi</my-component>".to_string(),
13245 )],
13246 contains_boundary: false,
13247 },
13248 );
13249 let protocol = WebUIProtocol::new(fragments);
13250 let state = test_json!({});
13251 let mut writer = TestWriter::new();
13252 let opts = RenderOptions::new("index.html", "/")
13253 .with_head_inject("<HINJ>")
13254 .with_body_inject("<BINJ>");
13255 handle(&protocol, &state, &opts, &mut writer).unwrap();
13256 let html = writer.get_content();
13257 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13258 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13259 assert!(html.contains("<my-component>"));
13260 }
13261
13262 #[test]
13268 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13269 let protocol = std::sync::Arc::new(build_head_body_protocol());
13270 let state = std::sync::Arc::new(test_json!({}));
13271 let handler = std::sync::Arc::new(WebUIHandler::new());
13272
13273 const N_THREADS: usize = 16;
13274 let mut handles = Vec::with_capacity(N_THREADS);
13275 for tid in 0..N_THREADS {
13276 let h = std::sync::Arc::clone(&handler);
13277 let p = std::sync::Arc::clone(&protocol);
13278 let s = std::sync::Arc::clone(&state);
13279 handles.push(std::thread::spawn(move || {
13280 let head = format!("<HEAD-T{tid}>");
13281 let body = format!("<BODY-T{tid}>");
13282 let mut writer = TestWriter::new();
13283 let opts = RenderOptions::new("index.html", "/")
13284 .with_head_inject(&head)
13285 .with_body_inject(&body);
13286 h.handle(&p, &s, &opts, &mut writer).unwrap();
13287 let html = writer.get_content();
13288 assert_eq!(html.matches(&head).count(), 1);
13290 assert_eq!(html.matches(&body).count(), 1);
13291 for other in 0..N_THREADS {
13293 if other == tid {
13294 continue;
13295 }
13296 let other_head = format!("<HEAD-T{other}>");
13297 let other_body = format!("<BODY-T{other}>");
13298 assert!(
13299 !html.contains(&other_head),
13300 "tid {tid} saw {other}'s head_inject"
13301 );
13302 assert!(
13303 !html.contains(&other_body),
13304 "tid {tid} saw {other}'s body_inject"
13305 );
13306 }
13307 }));
13308 }
13309 for h in handles {
13310 h.join().expect("worker panicked");
13311 }
13312 }
13313
13314 #[test]
13319 fn large_inject_roundtrips_without_truncation() {
13320 let protocol = build_head_body_protocol();
13321 let state = test_json!({});
13322 let mut writer = TestWriter::new();
13323 let big = "x".repeat(1024 * 1024);
13324 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13325 handle(&protocol, &state, &opts, &mut writer).unwrap();
13326 let html = writer.get_content();
13327 assert!(
13328 html.contains(&big),
13329 "large head_inject must be present verbatim ({} bytes)",
13330 big.len()
13331 );
13332 assert_eq!(html.matches(&big).count(), 1);
13334 }
13335
13336 #[test]
13341 fn empty_nonce_treated_as_unset() {
13342 let protocol = build_head_body_protocol();
13343 let state = test_json!({});
13344 let mut writer = TestWriter::new();
13345 let opts = RenderOptions::new("index.html", "/").with_nonce("");
13346 handle(&protocol, &state, &opts, &mut writer).unwrap();
13347 assert!(
13348 !writer.get_content().contains("webui-nonce"),
13349 "empty nonce must not emit <meta name=\"webui-nonce\">"
13350 );
13351 }
13352
13353 #[test]
13371 fn empty_field_bypass_is_normalised_at_handler_init() {
13372 let protocol = build_head_body_protocol();
13373 let state = test_json!({});
13374
13375 let opts_with_empty_nonce = RenderOptions {
13377 nonce: Some(""),
13378 ..RenderOptions::new("index.html", "/")
13379 };
13380 let mut writer = TestWriter::new();
13381 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13382 let html = writer.get_content();
13383 assert!(
13384 !html.contains("webui-nonce"),
13385 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13386 );
13387 assert!(
13388 !html.contains("nonce=\"\""),
13389 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13390 );
13391
13392 let opts_with_empty_injects = RenderOptions {
13394 head_inject: Some(""),
13395 body_inject: Some(""),
13396 ..RenderOptions::new("index.html", "/")
13397 };
13398 let mut writer = TestWriter::new();
13399 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13400 }
13404
13405 #[test]
13438 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13439 let mut fragments = HashMap::new();
13440 fragments.insert(
13441 "index.html".to_string(),
13442 FragmentList {
13443 fragments: vec![
13444 WebUIFragment::raw("["),
13445 WebUIFragment::for_loop("item", "outer", "outer_body"),
13446 WebUIFragment::raw("]"),
13447 ],
13448 contains_boundary: false,
13449 },
13450 );
13451 fragments.insert(
13452 "outer_body".to_string(),
13453 FragmentList {
13454 fragments: vec![
13455 WebUIFragment::raw("(O="),
13456 WebUIFragment::signal("item.tag", false),
13457 WebUIFragment::for_loop("item", "inner", "inner_body"),
13458 WebUIFragment::raw(",O="),
13459 WebUIFragment::signal("item.tag", false),
13460 WebUIFragment::raw(")"),
13461 ],
13462 contains_boundary: false,
13463 },
13464 );
13465 fragments.insert(
13466 "inner_body".to_string(),
13467 FragmentList {
13468 fragments: vec![
13469 WebUIFragment::raw("[I="),
13470 WebUIFragment::signal("item.tag", false),
13471 WebUIFragment::raw("]"),
13472 ],
13473 contains_boundary: false,
13474 },
13475 );
13476 let protocol = WebUIProtocol::new(fragments);
13477 let state = test_json!({
13478 "outer": [{"tag": "A"}, {"tag": "B"}],
13479 "inner": [{"tag": "X"}, {"tag": "Y"}],
13480 });
13481 let mut writer = TestWriter::new();
13482 handle(
13483 &protocol,
13484 &state,
13485 &RenderOptions::new("index.html", "/"),
13486 &mut writer,
13487 )
13488 .unwrap();
13489 assert_eq!(
13501 writer.get_content(),
13502 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13503 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13504 );
13505 }
13506 #[derive(Default)]
13507 struct FlushTestWriter {
13508 output: String,
13509 flushes: Vec<usize>,
13510 fail_flush: bool,
13511 fail_flush_at: Option<usize>,
13512 flush_attempts: usize,
13513 ended: bool,
13514 }
13515
13516 impl ResponseWriter for FlushTestWriter {
13517 fn write(&mut self, content: &str) -> Result<()> {
13518 self.output.push_str(content);
13519 Ok(())
13520 }
13521
13522 fn end(&mut self) -> Result<()> {
13523 self.ended = true;
13524 Ok(())
13525 }
13526 }
13527
13528 impl FlushWriter for FlushTestWriter {
13529 fn flush(&mut self) -> Result<()> {
13530 let attempt = self.flush_attempts;
13531 self.flush_attempts += 1;
13532 if self.fail_flush || self.fail_flush_at == Some(attempt) {
13533 return Err(HandlerError::ClientDisconnected);
13534 }
13535 self.flushes.push(self.output.len());
13536 Ok(())
13537 }
13538 }
13539
13540 fn streaming_protocol(with_boundaries: bool) -> Protocol {
13541 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13542 }
13543
13544 fn streaming_protocol_with_state_strategy(
13545 with_boundaries: bool,
13546 state_strategy: InitialStateStrategy,
13547 ) -> Protocol {
13548 let mut fragments = HashMap::new();
13549 let mut entry = vec![
13550 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13551 structural_fragment("head_start"),
13552 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13553 structural_fragment("head_end"),
13554 WebUIFragment::raw("</HEAD><body>"),
13555 structural_fragment("body_start"),
13556 ];
13557 if with_boundaries {
13558 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13559 }
13560 entry.extend([
13561 WebUIFragment::raw("<my-counter"),
13562 structural_fragment("streaming_root:my-counter"),
13563 WebUIFragment::raw(">"),
13564 WebUIFragment::component("my-counter"),
13565 WebUIFragment::raw("</my-counter>"),
13566 ]);
13567 if with_boundaries {
13568 entry.push(WebUIFragment::boundary_end(0));
13569 }
13570 entry.extend([
13571 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13572 structural_fragment("body_end"),
13573 WebUIFragment::raw("</body></html>"),
13574 ]);
13575 fragments.insert(
13576 "index.html".to_string(),
13577 FragmentList {
13578 fragments: entry,
13579 contains_boundary: with_boundaries,
13580 },
13581 );
13582 fragments.insert(
13583 "my-counter".to_string(),
13584 FragmentList {
13585 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13586 contains_boundary: false,
13587 },
13588 );
13589
13590 let mut document = WebUIProtocol::new(fragments);
13591 document.initial_state_strategy = state_strategy as i32;
13592 document.components.insert(
13593 "my-counter".to_string(),
13594 webui_protocol::ComponentData {
13595 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13596 template_functions: "[function(){return true}]".to_string(),
13597 hydration_mode: StateProjectionMode::Keys as i32,
13598 hydration_keys: vec!["count".to_string()],
13599 ..Default::default()
13600 },
13601 );
13602 Protocol::new(document)
13603 }
13604
13605 #[test]
13606 fn handler_error_stays_small() {
13607 assert!(
13614 std::mem::size_of::<HandlerError>()
13615 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13616 "HandlerError grew to {} bytes",
13617 std::mem::size_of::<HandlerError>()
13618 );
13619 }
13620
13621 #[test]
13622 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13623 let protocol = streaming_protocol(true);
13624 let handler = WebUIHandler::with_plugin(|| {
13625 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13626 });
13627 let state = test_json!({ "count": 1, "serverOnly": "secret" });
13628 let mut writer = FlushTestWriter::default();
13629
13630 handler
13631 .render_streaming(
13632 &protocol,
13633 &state,
13634 &RenderOptions::new("index.html", "/"),
13635 &mut writer,
13636 )
13637 .unwrap();
13638
13639 assert!(writer.ended);
13640 assert_eq!(
13641 writer.flushes.len(),
13642 3,
13643 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13644 );
13645 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13646 assert!(boundary_flush.contains("<!--wb:0-->"));
13647 assert!(boundary_flush.contains("<!--/wb:0-->"));
13648 assert!(
13649 boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
13650 "boundary flush: {boundary_flush}"
13651 );
13652 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13653 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13654 assert!(!boundary_flush.contains("slow tail"));
13655 assert!(!writer.output.contains("id=\"webui-data\""));
13656 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13659 assert!(terminal_flush.contains("slow tail"));
13660 assert!(writer.output.contains("[1,4,0,{}]"));
13661 assert!(!writer.output.contains("[1,0,1,"));
13662 assert!(!writer.output.contains("[2,4,0,{}]"));
13663
13664 let marker = writer
13665 .output
13666 .find(STREAMING_MARKER)
13667 .expect("streaming marker");
13668 let attributed_head = writer
13669 .output
13670 .find("<HEAD data-shell=\"main\">")
13671 .expect("attributed mixed-case head");
13672 let authored_script = writer
13673 .output
13674 .find("src=\"/index.js\"")
13675 .expect("entry script");
13676 assert!(attributed_head < marker && marker < authored_script);
13677 }
13678
13679 #[test]
13680 fn streaming_terminal_tail_never_resends_full_state() {
13681 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13682 let handler = WebUIHandler::with_plugin(|| {
13683 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13684 });
13685 let mut writer = FlushTestWriter::default();
13686
13687 handler
13688 .render_streaming(
13689 &protocol,
13690 &test_json!({ "count": 1, "serverOnly": "secret" }),
13691 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13692 &mut writer,
13693 )
13694 .unwrap();
13695
13696 assert_eq!(
13697 writer.output.matches("serverOnly").count(),
13698 1,
13699 "full state belongs only to the interactive boundary"
13700 );
13701 assert!(writer.output.contains("[1,4,0,{}]"));
13702 }
13703
13704 #[test]
13708 fn state_inject_placement_matches_between_render_modes() {
13709 let entry = vec![
13710 WebUIFragment::raw("<html><head>"),
13711 structural_fragment("head_start"),
13712 structural_fragment("head_end"),
13713 WebUIFragment::raw("</head><body>"),
13714 structural_fragment("body_start"),
13715 WebUIFragment::raw("<main>static</main>"),
13716 structural_fragment("body_end"),
13717 WebUIFragment::raw("</body></html>"),
13718 ];
13719 let fragments = HashMap::from([(
13720 "index.html".to_string(),
13721 FragmentList {
13722 fragments: entry,
13723 contains_boundary: false,
13724 },
13725 )]);
13726 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13727 let state = test_json!({
13728 "$webui": {
13729 "headEnd": "<meta name=he>",
13730 "bodyStart": "<span id=bs></span>",
13731 "bodyEnd": "<script>be</script>",
13732 }
13733 });
13734 let options = RenderOptions::new("index.html", "/");
13735
13736 let mut ordinary = TestWriter::new();
13737 WebUIHandler::new()
13738 .render(&protocol, &state, &options, &mut ordinary)
13739 .unwrap();
13740 let ordinary_html = ordinary.get_content().to_string();
13741
13742 let mut streamed = FlushTestWriter::default();
13743 WebUIHandler::new()
13744 .render_streaming(&protocol, &state, &options, &mut streamed)
13745 .unwrap();
13746 let streamed_html = &streamed.output;
13747
13748 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13749 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13750 let head_close = html.find("</head>").expect("</head> missing");
13751 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13752 let main = html.find("<main>static</main>").expect("content missing");
13753 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13754 let body_close = html.find("</body>").expect("</body> missing");
13755 assert!(head_end < head_close, "headEnd misplaced: {html}");
13756 assert!(body_start < main, "bodyStart misplaced: {html}");
13757 assert!(
13758 main < body_end && body_end < body_close,
13759 "bodyEnd misplaced: {html}"
13760 );
13761 }
13762
13763 assert!(
13766 streamed_html.contains(",4,0,{}]"),
13767 "streaming must still end in one empty terminal record: {streamed_html}"
13768 );
13769 }
13770
13771 #[test]
13772 fn streaming_state_inject_emits_and_strips_reserved_key() {
13773 let entry = vec![
13774 WebUIFragment::raw("<html><head>"),
13775 structural_fragment("head_start"),
13776 structural_fragment("head_end"),
13777 WebUIFragment::raw("</head><body>"),
13778 structural_fragment("body_start"),
13779 structural_fragment("body_end"),
13780 WebUIFragment::raw("</body></html>"),
13781 ];
13782 let fragments = HashMap::from([(
13783 "index.html".to_string(),
13784 FragmentList {
13785 fragments: entry,
13786 contains_boundary: false,
13787 },
13788 )]);
13789 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13790 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13791
13792 let mut writer = FlushTestWriter::default();
13793 WebUIHandler::new()
13794 .render_streaming(
13795 &protocol,
13796 &state,
13797 &RenderOptions::new("index.html", "/"),
13798 &mut writer,
13799 )
13800 .unwrap();
13801 assert!(writer.output.contains("<script>be</script>"));
13802 assert!(!writer.output.contains("$webui"));
13803 }
13804
13805 #[test]
13806 fn static_streaming_document_uses_one_empty_terminal_record() {
13807 let fragments = HashMap::from([(
13808 "index.html".to_string(),
13809 FragmentList {
13810 fragments: vec![
13811 WebUIFragment::raw("<html><head>"),
13812 structural_fragment("head_start"),
13813 structural_fragment("head_end"),
13814 WebUIFragment::raw("</head><body>"),
13815 structural_fragment("body_start"),
13816 WebUIFragment::raw("<main>static</main>"),
13817 structural_fragment("body_end"),
13818 WebUIFragment::raw("</body></html>"),
13819 ],
13820 contains_boundary: false,
13821 },
13822 )]);
13823 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13824 let mut writer = FlushTestWriter::default();
13825
13826 WebUIHandler::new()
13827 .render_streaming(
13828 &protocol,
13829 &test_json!({ "serverOnly": "secret" }),
13830 &RenderOptions::new("index.html", "/"),
13831 &mut writer,
13832 )
13833 .unwrap();
13834
13835 assert_eq!(writer.flushes.len(), 1);
13836 assert!(writer.output.contains(STREAMING_MARKER));
13837 assert!(writer.output.contains("[0,4,0,{}]"));
13838 assert!(!writer.output.contains("serverOnly"));
13839 assert!(!writer.output.contains("id=\"webui-data\""));
13840 assert!(!writer.output.contains("<!--wb:"));
13841 }
13842
13843 #[test]
13844 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13845 let protocol = streaming_protocol(true);
13846 let handler = WebUIHandler::with_plugin(|| {
13847 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13848 });
13849 let mut writer = FlushTestWriter::default();
13850 handler
13851 .render_streaming(
13852 &protocol,
13853 &test_json!({ "count": 1 }),
13854 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13855 &mut writer,
13856 )
13857 .unwrap();
13858
13859 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13860 let envelope = writer.output[boundary_end..]
13861 .find("data-webui-boundary nonce=\"test-nonce-123\"")
13862 .map(|index| index + boundary_end)
13863 .expect("nonce-bearing envelope");
13864 let functions = writer
13865 .output
13866 .find("templateFns")
13867 .expect("function side channel");
13868 let sentinel = writer
13869 .output
13870 .find("<webui-hydrate>")
13871 .expect("hydration sentinel");
13872 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13873 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13874 }
13875
13876 #[test]
13877 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13878 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13879 let mut fragments = vec![structural_fragment("head_start")];
13880 fragments.extend(boundaries);
13881 let fragments = HashMap::from([(
13882 "index.html".to_string(),
13883 FragmentList {
13884 fragments,
13885 contains_boundary: true,
13886 },
13887 )]);
13888 Protocol::new(WebUIProtocol::new(fragments))
13889 }
13890
13891 let handler = WebUIHandler::new();
13892 let mut writer = FlushTestWriter::default();
13893 let result = handler.render_streaming(
13894 &protocol(vec![WebUIFragment::boundary_end(0)]),
13895 &test_json!({}),
13896 &RenderOptions::new("index.html", "/"),
13897 &mut writer,
13898 );
13899 match result {
13900 Err(HandlerError::StreamingBoundary(err)) => {
13901 assert_eq!(err.signal, "boundary");
13902 assert!(err.reason.contains("no active occurrence"));
13903 }
13904 other => panic!("expected ordered-boundary error, got {other:?}"),
13905 }
13906
13907 let mut writer = FlushTestWriter::default();
13908 let result = handler.render_streaming(
13909 &protocol(vec![
13910 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13911 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13912 ]),
13913 &test_json!({}),
13914 &RenderOptions::new("index.html", "/"),
13915 &mut writer,
13916 );
13917 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13918 }
13919
13920 #[test]
13921 fn streaming_render_propagates_boundary_flush_disconnect() {
13922 let protocol = streaming_protocol(true);
13923 let handler = WebUIHandler::new();
13924 let mut writer = FlushTestWriter {
13925 fail_flush: true,
13926 ..FlushTestWriter::default()
13927 };
13928 let result = handler.render_streaming(
13929 &protocol,
13930 &test_json!({}),
13931 &RenderOptions::new("index.html", "/"),
13932 &mut writer,
13933 );
13934 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13935 assert!(!writer.ended);
13936 }
13937
13938 #[test]
13939 fn streaming_render_requires_structural_head_start_before_writing() {
13940 let fragments = HashMap::from([(
13941 "index.html".to_string(),
13942 FragmentList {
13943 fragments: vec![
13944 WebUIFragment::raw(
13945 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13946 ),
13947 structural_fragment("head_end"),
13948 WebUIFragment::raw("</HEAD><body>"),
13949 structural_fragment("body_start"),
13950 structural_fragment("body_end"),
13951 ],
13952 contains_boundary: false,
13953 },
13954 )]);
13955 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13956 let mut writer = FlushTestWriter::default();
13957
13958 let result = WebUIHandler::new().render_streaming(
13959 &protocol,
13960 &test_json!({}),
13961 &RenderOptions::new("index.html", "/"),
13962 &mut writer,
13963 );
13964
13965 assert!(matches!(
13966 result,
13967 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13968 ));
13969 assert_eq!(
13970 writer.output,
13971 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13972 );
13973 assert!(writer.flushes.is_empty());
13974 assert!(!writer.ended);
13975 }
13976
13977 #[test]
13978 fn streaming_render_rejects_duplicate_head_start() {
13979 let fragments = HashMap::from([(
13980 "index.html".to_string(),
13981 FragmentList {
13982 fragments: vec![
13983 WebUIFragment::raw("<html><head>"),
13984 structural_fragment("head_start"),
13985 structural_fragment("head_start"),
13986 structural_fragment("head_end"),
13987 WebUIFragment::raw("</head><body>"),
13988 structural_fragment("body_start"),
13989 structural_fragment("body_end"),
13990 ],
13991 contains_boundary: false,
13992 },
13993 )]);
13994 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13995 let mut writer = FlushTestWriter::default();
13996
13997 let result = WebUIHandler::new().render_streaming(
13998 &protocol,
13999 &test_json!({}),
14000 &RenderOptions::new("index.html", "/"),
14001 &mut writer,
14002 );
14003
14004 assert!(matches!(
14005 result,
14006 Err(HandlerError::DuplicateStreamingHeadStart)
14007 ));
14008 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14009 assert!(writer.flushes.is_empty());
14010 }
14011
14012 #[test]
14013 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14014 let handler = WebUIHandler::with_plugin(|| {
14015 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14016 });
14017 let state = test_json!({
14018 "count": 1,
14019 "head_start": "must not render",
14020 "boundary_start:0": "must not render",
14021 "boundary_end:0": "must not render",
14022 });
14023 let mut with_boundaries = TestWriter::new();
14024 let mut without_boundaries = TestWriter::new();
14025 handler
14026 .render(
14027 &streaming_protocol(true),
14028 &state,
14029 &RenderOptions::new("index.html", "/"),
14030 &mut with_boundaries,
14031 )
14032 .unwrap();
14033 handler
14034 .render(
14035 &streaming_protocol(false),
14036 &state,
14037 &RenderOptions::new("index.html", "/"),
14038 &mut without_boundaries,
14039 )
14040 .unwrap();
14041 assert_eq!(
14042 with_boundaries.get_content(),
14043 without_boundaries.get_content()
14044 );
14045 }
14046
14047 #[test]
14048 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14049 for source in [
14050 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14051 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14052 ] {
14053 let mut parser = HtmlParser::new();
14054 parser
14055 .parse("index.html", source)
14056 .expect("parse head fixture");
14057 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14058 let mut writer = TestWriter::new();
14059 WebUIHandler::new()
14060 .render(
14061 &protocol,
14062 &test_json!({ "theme": "light" }),
14063 &RenderOptions::new("index.html", "/"),
14064 &mut writer,
14065 )
14066 .unwrap();
14067
14068 assert_eq!(
14069 writer.get_content(),
14070 "<html><head><title>T</title></head><body>x</body></html>"
14071 );
14072 }
14073 }
14074
14075 #[test]
14076 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14077 let source =
14078 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14079 let mut parser = HtmlParser::new();
14080 parser.parse("index.html", source).expect("parse fixture");
14081 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14082
14083 let mut ordinary = TestWriter::new();
14084 WebUIHandler::new()
14085 .render(
14086 &protocol,
14087 &test_json!({}),
14088 &RenderOptions::new("index.html", "/"),
14089 &mut ordinary,
14090 )
14091 .unwrap();
14092 assert_eq!(ordinary.get_content(), source);
14093
14094 let mut streaming = FlushTestWriter::default();
14095 WebUIHandler::new()
14096 .render_streaming(
14097 &protocol,
14098 &test_json!({}),
14099 &RenderOptions::new("index.html", "/"),
14100 &mut streaming,
14101 )
14102 .unwrap();
14103 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14104 assert!(streaming.output.contains("</HEAD><BODY>x"));
14105 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14106 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14107 let title = streaming.output.find("<title>").expect("title");
14108 assert!(opening < marker && marker < title);
14109 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14110 }
14111
14112 #[test]
14115 fn unnamespaced_signal_is_ordinary_content() {
14116 let fragments = HashMap::from([(
14117 "index.html".to_string(),
14118 FragmentList {
14119 fragments: vec![
14120 WebUIFragment::raw("<html><head>"),
14121 structural_fragment("head_end"),
14122 WebUIFragment::raw("</head><body>"),
14123 WebUIFragment::signal("body_end".to_string(), false),
14124 structural_fragment("body_end"),
14125 WebUIFragment::raw("</body></html>"),
14126 ],
14127 contains_boundary: false,
14128 },
14129 )]);
14130 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14131 let mut writer = TestWriter::new();
14132 WebUIHandler::new()
14133 .render(
14134 &protocol,
14135 &test_json!({ "body_end": "content" }),
14136 &RenderOptions::new("index.html", "/"),
14137 &mut writer,
14138 )
14139 .expect("current protocol must render");
14140 assert!(writer.get_content().contains("content"));
14141 }
14142
14143 #[test]
14144 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14145 let source = concat!(
14146 "<html><head></head><body>",
14147 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14148 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14149 "</body></html>",
14150 );
14151 let state = test_json!({
14152 "head_start": "hs",
14153 "head_end": "he",
14154 "body_start": "bs",
14155 "body_end": "be",
14156 "boundary_start:0": "b0s",
14157 "boundary_end:0": "b0e",
14158 "streaming_root:forged": "root",
14159 });
14160 let mut parser = HtmlParser::new();
14161 parser.parse("index.html", source).expect("parse fixture");
14162 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14163 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14164
14165 let mut ordinary = TestWriter::new();
14166 WebUIHandler::new()
14167 .render(
14168 &protocol,
14169 &state,
14170 &RenderOptions::new("index.html", "/"),
14171 &mut ordinary,
14172 )
14173 .unwrap();
14174 assert_eq!(ordinary.get_content(), expected);
14175
14176 let mut streaming = FlushTestWriter::default();
14177 WebUIHandler::new()
14178 .render_streaming(
14179 &protocol,
14180 &state,
14181 &RenderOptions::new("index.html", "/"),
14182 &mut streaming,
14183 )
14184 .unwrap();
14185 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14186 assert!(!streaming.output.contains("<!--wb:"));
14187 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14188 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14189 }
14190
14191 #[test]
14192 fn streaming_rejects_structural_signals_after_terminal() {
14193 let fragments = HashMap::from([(
14194 "index.html".to_string(),
14195 FragmentList {
14196 fragments: vec![
14197 WebUIFragment::raw("<html><head>"),
14198 structural_fragment("head_start"),
14199 structural_fragment("head_end"),
14200 WebUIFragment::raw("</head><body>"),
14201 structural_fragment("body_start"),
14202 structural_fragment("body_end"),
14203 WebUIFragment::raw("</body>"),
14204 structural_fragment("boundary_start:0"),
14205 WebUIFragment::raw("</html>"),
14206 ],
14207 contains_boundary: false,
14208 },
14209 )]);
14210 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14211 let mut streaming = FlushTestWriter::default();
14212 let result = WebUIHandler::new().render_streaming(
14213 &protocol,
14214 &test_json!({}),
14215 &RenderOptions::new("index.html", "/"),
14216 &mut streaming,
14217 );
14218
14219 match result {
14220 Err(HandlerError::StreamingBoundary(error)) => {
14221 assert_eq!(error.signal, "boundary_start:0");
14222 assert!(
14223 error
14224 .reason
14225 .contains("structural signal arrived after body_end"),
14226 "reason: {}",
14227 error.reason
14228 );
14229 }
14230 other => panic!("expected post-terminal rejection, got {other:?}"),
14231 }
14232 assert_eq!(
14233 streaming.output,
14234 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14235 );
14236 assert!(!streaming.output.contains("<!--wb:0-->"));
14237
14238 let mut ordinary = TestWriter::new();
14239 WebUIHandler::new()
14240 .render(
14241 &protocol,
14242 &test_json!({}),
14243 &RenderOptions::new("index.html", "/"),
14244 &mut ordinary,
14245 )
14246 .unwrap();
14247 assert_eq!(
14248 ordinary.get_content(),
14249 "<html><head></head><body></body></html>"
14250 );
14251 }
14252
14253 fn disjoint_streaming_protocol_ext(
14254 hosts: &[&str],
14255 emit_root_signal: bool,
14256 include_styles: bool,
14257 ) -> Protocol {
14258 let mut fragments = HashMap::new();
14259 let mut entry = vec![
14260 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14261 structural_fragment("head_start"),
14262 structural_fragment("head_end"),
14263 WebUIFragment::raw("</head><body>"),
14264 structural_fragment("body_start"),
14265 ];
14266 for (sequence, host) in hosts.iter().enumerate() {
14267 entry.push(WebUIFragment::boundary(
14268 sequence as u32,
14269 "index.html",
14270 format!("boundary-{sequence}"),
14271 None,
14272 ));
14273 entry.push(WebUIFragment::raw(format!("<{host}")));
14274 if emit_root_signal {
14275 entry.push(structural_fragment(format!("streaming_root:{host}")));
14276 }
14277 entry.push(WebUIFragment::raw(">"));
14278 entry.push(WebUIFragment::component(*host));
14279 entry.push(WebUIFragment::raw(format!("</{host}>")));
14280 entry.push(WebUIFragment::boundary_end(sequence as u32));
14281 }
14282 entry.push(structural_fragment("body_end"));
14283 entry.push(WebUIFragment::raw("</body></html>"));
14284 fragments.insert(
14285 "index.html".to_string(),
14286 FragmentList {
14287 fragments: entry,
14288 contains_boundary: true,
14289 },
14290 );
14291 fragments.insert(
14292 "comp-a".to_string(),
14293 FragmentList {
14294 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14295 contains_boundary: false,
14296 },
14297 );
14298 fragments.insert(
14299 "comp-b".to_string(),
14300 FragmentList {
14301 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14302 contains_boundary: false,
14303 },
14304 );
14305
14306 let mut document = WebUIProtocol::new(fragments);
14307 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14308 document.components.insert(
14309 "comp-a".to_string(),
14310 webui_protocol::ComponentData {
14311 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14312 hydration_mode: StateProjectionMode::Keys as i32,
14313 hydration_keys: vec!["a_count".to_string()],
14314 ..Default::default()
14315 },
14316 );
14317 document.components.insert(
14318 "comp-b".to_string(),
14319 webui_protocol::ComponentData {
14320 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14321 hydration_mode: StateProjectionMode::Keys as i32,
14322 hydration_keys: vec!["b_count".to_string()],
14323 ..Default::default()
14324 },
14325 );
14326 if include_styles {
14327 document.set_css_strategy(webui_protocol::CssStrategy::Style);
14328 document
14329 .components
14330 .get_mut("comp-a")
14331 .expect("comp-a component")
14332 .css = ".comp-a{color:red}".to_string();
14333 document
14334 .components
14335 .get_mut("comp-b")
14336 .expect("comp-b component")
14337 .css = ".comp-b{color:blue}".to_string();
14338 document.populate_style_closures(&["index.html"]);
14339 }
14340 Protocol::new(document)
14341 }
14342
14343 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14344 disjoint_streaming_protocol_ext(hosts, true, false)
14345 }
14346
14347 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14348 disjoint_streaming_protocol_ext(hosts, true, true)
14349 }
14350
14351 fn streaming_root_validation_protocol(
14352 mut host_fragments: Vec<WebUIFragment>,
14353 tail: Vec<WebUIFragment>,
14354 ) -> Protocol {
14355 let mut entry = vec![
14356 WebUIFragment::raw("<html><head>"),
14357 structural_fragment("head_start"),
14358 structural_fragment("head_end"),
14359 WebUIFragment::raw("</head><body>"),
14360 structural_fragment("body_start"),
14361 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14362 ];
14363 entry.append(&mut host_fragments);
14364 entry.extend(tail);
14365 let fragments = HashMap::from([
14366 (
14367 "index.html".to_string(),
14368 FragmentList {
14369 fragments: entry,
14370 contains_boundary: true,
14371 },
14372 ),
14373 (
14374 "comp-a".to_string(),
14375 FragmentList {
14376 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14377 contains_boundary: false,
14378 },
14379 ),
14380 (
14381 "comp-b".to_string(),
14382 FragmentList {
14383 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14384 contains_boundary: false,
14385 },
14386 ),
14387 ]);
14388 Protocol::new(WebUIProtocol::new(fragments))
14389 }
14390
14391 fn completed_streaming_tail() -> Vec<WebUIFragment> {
14392 vec![
14393 WebUIFragment::raw("</comp-a>"),
14394 WebUIFragment::boundary_end(0),
14395 structural_fragment("body_end"),
14396 WebUIFragment::raw("</body></html>"),
14397 ]
14398 }
14399
14400 fn assert_streaming_root_error(
14401 protocol: &Protocol,
14402 expected_signal: &str,
14403 expected_reason: &str,
14404 ) {
14405 let mut writer = FlushTestWriter::default();
14406 let result = WebUIHandler::new().render_streaming(
14407 protocol,
14408 &test_json!({}),
14409 &RenderOptions::new("index.html", "/"),
14410 &mut writer,
14411 );
14412 match result {
14413 Err(HandlerError::StreamingBoundary(error)) => {
14414 assert_eq!(error.signal, expected_signal);
14415 assert!(
14416 error.reason.contains(expected_reason),
14417 "reason: {}",
14418 error.reason
14419 );
14420 }
14421 other => panic!("expected streaming-root rejection, got {other:?}"),
14422 }
14423 }
14424
14425 #[test]
14426 fn streaming_root_signal_injects_data_ws_inside_boundary() {
14427 let protocol = disjoint_streaming_protocol(&["comp-a"]);
14428 let handler = WebUIHandler::with_plugin(|| {
14429 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14430 });
14431 let mut writer = FlushTestWriter::default();
14432 handler
14433 .render_streaming(
14434 &protocol,
14435 &test_json!({ "a_count": 1 }),
14436 &RenderOptions::new("index.html", "/"),
14437 &mut writer,
14438 )
14439 .unwrap();
14440 assert!(
14443 writer.output.contains("<comp-a data-ws>"),
14444 "streamed host must carry data-ws: {}",
14445 writer.output
14446 );
14447 }
14448
14449 #[test]
14450 fn streaming_root_signal_preserves_ordinary_output_bytes() {
14451 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14454 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14455 let state = test_json!({ "a_count": 1, "b_count": 2 });
14456 let plugin = || {
14457 WebUIHandler::with_plugin(
14458 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14459 )
14460 };
14461 let mut with_writer = TestWriter::new();
14462 let mut without_writer = TestWriter::new();
14463 plugin()
14464 .render(
14465 &with_signal,
14466 &state,
14467 &RenderOptions::new("index.html", "/"),
14468 &mut with_writer,
14469 )
14470 .unwrap();
14471 plugin()
14472 .render(
14473 &without_signal,
14474 &state,
14475 &RenderOptions::new("index.html", "/"),
14476 &mut without_writer,
14477 )
14478 .unwrap();
14479 let dom_prefix = |content: &str| -> String {
14484 content
14485 .split_once(r#"<script type="application/json" id="webui-data""#)
14486 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14487 };
14488 assert_eq!(
14489 dom_prefix(&with_writer.get_content()),
14490 dom_prefix(&without_writer.get_content())
14491 );
14492 assert!(!with_writer.get_content().contains("data-ws"));
14493 assert!(with_writer.get_content().contains("<comp-a>"));
14494 }
14495
14496 #[test]
14497 fn streaming_component_without_root_signal_is_rejected() {
14498 let protocol = streaming_root_validation_protocol(
14499 vec![
14500 WebUIFragment::raw("<comp-a>"),
14501 WebUIFragment::component("comp-a"),
14502 ],
14503 completed_streaming_tail(),
14504 );
14505
14506 assert_streaming_root_error(
14507 &protocol,
14508 "comp-a",
14509 "has no compiler-owned streaming_root signal",
14510 );
14511 }
14512
14513 #[test]
14514 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14515 let duplicate = streaming_root_validation_protocol(
14516 vec![
14517 WebUIFragment::raw("<comp-a"),
14518 structural_fragment("streaming_root:comp-a"),
14519 structural_fragment("streaming_root:comp-a"),
14520 WebUIFragment::raw(">"),
14521 WebUIFragment::component("comp-a"),
14522 ],
14523 completed_streaming_tail(),
14524 );
14525 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14526
14527 let after_tag_close = streaming_root_validation_protocol(
14528 vec![
14529 WebUIFragment::raw("<comp-a>"),
14530 structural_fragment("streaming_root:comp-a"),
14531 WebUIFragment::component("comp-a"),
14532 ],
14533 completed_streaming_tail(),
14534 );
14535 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14536
14537 let detached_from_opening = streaming_root_validation_protocol(
14538 vec![
14539 WebUIFragment::raw("not an opening tag"),
14540 structural_fragment("streaming_root:comp-a"),
14541 WebUIFragment::raw(">"),
14542 WebUIFragment::component("comp-a"),
14543 ],
14544 completed_streaming_tail(),
14545 );
14546 assert_streaming_root_error(
14547 &detached_from_opening,
14548 "comp-a",
14549 "unclosed component opening-tag close",
14550 );
14551 }
14552
14553 #[test]
14554 fn mismatched_streaming_root_signal_is_rejected() {
14555 let protocol = streaming_root_validation_protocol(
14556 vec![
14557 WebUIFragment::raw("<comp-a"),
14558 structural_fragment("streaming_root:comp-b"),
14559 WebUIFragment::raw(">"),
14560 WebUIFragment::component("comp-a"),
14561 ],
14562 completed_streaming_tail(),
14563 );
14564
14565 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14566 }
14567
14568 #[test]
14569 fn pending_streaming_root_is_rejected_at_structural_ends() {
14570 let protocol = streaming_root_validation_protocol(
14571 vec![
14572 WebUIFragment::raw("<comp-a"),
14573 structural_fragment("streaming_root:comp-a"),
14574 WebUIFragment::raw(">"),
14575 ],
14576 vec![structural_fragment("boundary_end:0")],
14577 );
14578 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14579 }
14580
14581 #[test]
14582 fn streaming_root_outside_boundary_is_rejected() {
14583 let fragments = HashMap::from([
14584 (
14585 "index.html".to_string(),
14586 FragmentList {
14587 fragments: vec![
14588 WebUIFragment::raw("<html><head>"),
14589 structural_fragment("head_start"),
14590 structural_fragment("head_end"),
14591 WebUIFragment::raw("</head><body>"),
14592 structural_fragment("body_start"),
14593 WebUIFragment::raw("<comp-a"),
14594 structural_fragment("streaming_root:comp-a"),
14595 WebUIFragment::raw(">"),
14596 WebUIFragment::component("comp-a"),
14597 WebUIFragment::raw("</comp-a>"),
14598 structural_fragment("body_end"),
14599 WebUIFragment::raw("</body></html>"),
14600 ],
14601 contains_boundary: false,
14602 },
14603 ),
14604 (
14605 "comp-a".to_string(),
14606 FragmentList {
14607 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14608 contains_boundary: false,
14609 },
14610 ),
14611 ]);
14612 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14613 let mut writer = FlushTestWriter::default();
14614 let result = WebUIHandler::new().render_streaming(
14615 &protocol,
14616 &test_json!({}),
14617 &RenderOptions::new("index.html", "/"),
14618 &mut writer,
14619 );
14620 match result {
14621 Err(HandlerError::StreamingBoundary(err)) => {
14622 assert_eq!(err.signal, "comp-a");
14623 assert!(
14624 err.reason
14625 .contains("no boundary or generated component span"),
14626 "reason: {}",
14627 err.reason
14628 );
14629 }
14630 other => panic!("expected outside-boundary rejection, got {other:?}"),
14631 }
14632 }
14633
14634 fn parser_route_protocol(with_boundary: bool) -> Protocol {
14635 let mut parser = HtmlParser::with_options(DomStrategy::Light);
14636 parser
14637 .component_registry_mut()
14638 .register_component(ComponentRegistration::new(
14639 "route-page",
14640 "<p>route content</p>",
14641 None,
14642 true,
14643 ))
14644 .expect("register route component");
14645 let route = r#"<route path="/" component="route-page" exact />"#;
14646 let html = if with_boundary {
14647 format!(
14648 "<html><head></head><body>\
14649 <boundary name=\"route\">{route}</boundary>\
14650 </body></html>"
14651 )
14652 } else {
14653 format!("<html><head></head><body>{route}</body></html>")
14654 };
14655 parser
14656 .parse("index.html", &html)
14657 .expect("parse route streaming fixture");
14658 let mut document = WebUIProtocol::new(parser.into_fragment_records());
14659 document.components.insert(
14660 "route-page".to_string(),
14661 webui_protocol::ComponentData::default(),
14662 );
14663 Protocol::new(document)
14664 }
14665
14666 #[test]
14667 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14668 let protocol = parser_route_protocol(true);
14669 let mut writer = FlushTestWriter::default();
14670 WebUIHandler::new()
14671 .render_streaming(
14672 &protocol,
14673 &test_json!({}),
14674 &RenderOptions::new("index.html", "/"),
14675 &mut writer,
14676 )
14677 .unwrap();
14678
14679 assert!(
14680 writer
14681 .output
14682 .contains("<route-page data-ws><p>route content</p></route-page>"),
14683 "matched route host must be deferred before upgrade: {}",
14684 writer.output
14685 );
14686 }
14687
14688 #[test]
14689 fn streaming_parser_route_host_outside_boundary_is_rejected() {
14690 let protocol = parser_route_protocol(false);
14691 let mut writer = FlushTestWriter::default();
14692 let result = WebUIHandler::new().render_streaming(
14693 &protocol,
14694 &test_json!({}),
14695 &RenderOptions::new("index.html", "/"),
14696 &mut writer,
14697 );
14698
14699 match result {
14700 Err(HandlerError::StreamingBoundary(error)) => {
14701 assert_eq!(error.signal, "route-page");
14702 assert!(
14703 error
14704 .reason
14705 .contains("no boundary or generated component span"),
14706 "reason: {}",
14707 error.reason
14708 );
14709 }
14710 other => panic!("expected route boundary rejection, got {other:?}"),
14711 }
14712 assert!(
14713 !writer.output.contains("<route-page>"),
14714 "an unmarked custom-element opening must never be completed"
14715 );
14716 }
14717
14718 #[test]
14719 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14720 let entry = vec![
14721 WebUIFragment::raw("<html><head>"),
14722 structural_fragment("head_start"),
14723 structural_fragment("head_end"),
14724 WebUIFragment::raw("</head><body>"),
14725 structural_fragment("body_start"),
14726 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14727 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14728 path: "/account".into(),
14729 fragment_id: "account-shell".into(),
14730 exact: false,
14731 ..Default::default()
14732 }),
14733 WebUIFragment::boundary_end(0),
14734 structural_fragment("body_end"),
14735 WebUIFragment::raw("</body></html>"),
14736 ];
14737 let fragments = HashMap::from([
14738 (
14739 "index.html".to_string(),
14740 FragmentList {
14741 fragments: entry,
14742 contains_boundary: true,
14743 },
14744 ),
14745 (
14746 "account-shell".to_string(),
14747 FragmentList {
14748 fragments: vec![WebUIFragment::route_from(
14749 webui_protocol::WebUiFragmentRoute {
14750 path: "./details".into(),
14751 fragment_id: "details-page".into(),
14752 exact: true,
14753 pending_component: "details-loading".into(),
14754 error_component: "details-error".into(),
14755 ..Default::default()
14756 },
14757 )],
14758 contains_boundary: false,
14759 },
14760 ),
14761 (
14762 "details-page".to_string(),
14763 FragmentList {
14764 fragments: vec![WebUIFragment::raw("<p>details</p>")],
14765 contains_boundary: false,
14766 },
14767 ),
14768 (
14769 "details-loading".to_string(),
14770 FragmentList {
14771 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14772 contains_boundary: false,
14773 },
14774 ),
14775 (
14776 "details-error".to_string(),
14777 FragmentList {
14778 fragments: vec![WebUIFragment::raw("<p>error</p>")],
14779 contains_boundary: false,
14780 },
14781 ),
14782 ]);
14783 let mut document = WebUIProtocol::new(fragments);
14784 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14785 for name in [
14786 "account-shell",
14787 "details-page",
14788 "details-loading",
14789 "details-error",
14790 ] {
14791 document.components.insert(
14792 name.to_string(),
14793 webui_protocol::ComponentData {
14794 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14795 ..Default::default()
14796 },
14797 );
14798 }
14799 let protocol = Protocol::new(document);
14800 let handler = WebUIHandler::with_plugin(|| {
14801 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14802 });
14803 let mut writer = FlushTestWriter::default();
14804
14805 handler
14806 .render_streaming(
14807 &protocol,
14808 &test_json!({}),
14809 &RenderOptions::new("index.html", "/account/details"),
14810 &mut writer,
14811 )
14812 .unwrap();
14813
14814 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14815 assert!(
14816 first.contains(r#""details-loading":"#),
14817 "relative-route pending metadata missing: {first}"
14818 );
14819 assert!(
14820 first.contains(r#""details-error":"#),
14821 "relative-route error metadata missing: {first}"
14822 );
14823 }
14824
14825 #[test]
14826 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14827 let entry = vec![
14828 WebUIFragment::raw("<html><head>"),
14829 structural_fragment("head_start"),
14830 structural_fragment("head_end"),
14831 WebUIFragment::raw("</head><body>"),
14832 structural_fragment("body_start"),
14833 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14834 WebUIFragment::raw("<static-shell"),
14835 structural_fragment("streaming_root:static-shell"),
14836 WebUIFragment::raw(">"),
14837 WebUIFragment::component("static-shell"),
14838 WebUIFragment::raw("</static-shell>"),
14839 WebUIFragment::raw("<route-shell"),
14840 structural_fragment("streaming_root:route-shell"),
14841 WebUIFragment::raw(">"),
14842 WebUIFragment::component("route-shell"),
14843 WebUIFragment::raw("</route-shell>"),
14844 WebUIFragment::boundary_end(0),
14845 structural_fragment("body_end"),
14846 WebUIFragment::raw("</body></html>"),
14847 ];
14848 let fragments = HashMap::from([
14849 (
14850 "index.html".to_string(),
14851 FragmentList {
14852 fragments: entry,
14853 contains_boundary: true,
14854 },
14855 ),
14856 (
14857 "static-shell".to_string(),
14858 FragmentList {
14859 fragments: vec![WebUIFragment::if_cond(
14860 ConditionExpr::identifier("show_hidden"),
14861 "static-hidden-if",
14862 )],
14863 contains_boundary: false,
14864 },
14865 ),
14866 (
14867 "static-hidden-if".to_string(),
14868 FragmentList {
14869 fragments: vec![WebUIFragment::component("static-hidden")],
14870 contains_boundary: false,
14871 },
14872 ),
14873 (
14874 "static-hidden".to_string(),
14875 FragmentList {
14876 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14877 contains_boundary: false,
14878 },
14879 ),
14880 (
14881 "route-shell".to_string(),
14882 FragmentList {
14883 fragments: vec![WebUIFragment::route_from(
14884 webui_protocol::WebUiFragmentRoute {
14885 path: "/account".into(),
14886 fragment_id: "route-page".into(),
14887 exact: true,
14888 ..Default::default()
14889 },
14890 )],
14891 contains_boundary: false,
14892 },
14893 ),
14894 (
14895 "route-page".to_string(),
14896 FragmentList {
14897 fragments: vec![WebUIFragment::raw("<p>route</p>")],
14898 contains_boundary: false,
14899 },
14900 ),
14901 ]);
14902 let mut document = WebUIProtocol::new(fragments);
14903 document.set_css_strategy(webui_protocol::CssStrategy::Link);
14904 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14905 for (name, keys) in [
14906 ("static-shell", vec!["show_hidden", "static_count"]),
14907 ("static-hidden", vec!["hidden_count"]),
14908 ("route-shell", vec!["route_shell_count"]),
14909 ("route-page", vec!["route_count"]),
14910 ] {
14911 document.components.insert(
14912 name.to_string(),
14913 webui_protocol::ComponentData {
14914 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14915 hydration_mode: StateProjectionMode::Keys as i32,
14916 hydration_keys: keys.into_iter().map(str::to_string).collect(),
14917 css_href: format!("/{name}.css"),
14918 ..Default::default()
14919 },
14920 );
14921 }
14922 document.populate_style_closures(&["index.html"]);
14923 let protocol = Protocol::new(document);
14924 assert_eq!(
14925 protocol
14926 .component_reachability()
14927 .is_route_dependent(protocol.component_index()["route-shell"]),
14928 Some(true),
14929 "fixture must exercise request-aware checkpoint reachability"
14930 );
14931 let handler = WebUIHandler::with_plugin(|| {
14932 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14933 });
14934 let mut writer = FlushTestWriter::default();
14935
14936 handler
14937 .render_streaming(
14938 &protocol,
14939 &test_json!({
14940 "show_hidden": false,
14941 "static_count": 1,
14942 "hidden_count": 2,
14943 "route_shell_count": 3,
14944 "route_count": 4,
14945 "unrelated": "private",
14946 }),
14947 &RenderOptions::new("index.html", "/account"),
14948 &mut writer,
14949 )
14950 .unwrap();
14951
14952 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14953 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14954 assert!(
14955 checkpoint.contains(&format!(r#""{template}":"#)),
14956 "{template} metadata missing: {checkpoint}"
14957 );
14958 }
14959 for state in [
14960 r#""show_hidden":false"#,
14961 r#""static_count":1"#,
14962 r#""hidden_count":2"#,
14963 r#""route_shell_count":3"#,
14964 r#""route_count":4"#,
14965 ] {
14966 assert!(
14967 checkpoint.contains(state),
14968 "{state} state missing: {checkpoint}"
14969 );
14970 }
14971 assert!(
14972 !checkpoint.contains("unrelated"),
14973 "checkpoint leaked unrelated state: {checkpoint}"
14974 );
14975 assert!(
14976 checkpoint.contains(
14977 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14978 ),
14979 "checkpoint metadata lost source order: {checkpoint}"
14980 );
14981 assert!(
14982 checkpoint.contains(r#""componentStyles":"#)
14983 && checkpoint.contains(r#""strategy":"link""#)
14984 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14985 "checkpoint is missing tree-local style metadata: {checkpoint}"
14986 );
14987 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14988 assert_eq!(
14989 writer
14990 .output
14991 .matches(&format!(r#"data-webui-resource="{resource}""#))
14992 .count(),
14993 1,
14994 "Document delivery state must persist across streamed checkpoints"
14995 );
14996 }
14997 }
14998
14999 #[test]
15000 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15001 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15006 let handler = WebUIHandler::with_plugin(|| {
15007 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15008 });
15009 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15010 let mut writer = FlushTestWriter::default();
15011 handler
15012 .render_streaming(
15013 &protocol,
15014 &state,
15015 &RenderOptions::new("index.html", "/"),
15016 &mut writer,
15017 )
15018 .unwrap();
15019
15020 assert_eq!(
15021 writer.flushes.len(),
15022 5,
15023 "bootstrap, three boundary commits, and one terminal"
15024 );
15025 let segment = |index: usize| -> &str {
15026 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15027 };
15028 let b0 = segment(0);
15029 let b1 = segment(1);
15030 let b2 = segment(2);
15031
15032 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15034 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15035 assert!(
15036 b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15037 "b0: {b0}"
15038 );
15039 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15040 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15041 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15042
15043 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15045 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15046 assert!(
15047 b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15048 "b1: {b1}"
15049 );
15050 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15051 assert!(
15052 !b1.contains(r#""templates":{"comp-a"#),
15053 "b1 re-sent comp-a: {b1}"
15054 );
15055 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15056
15057 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15059 assert!(
15060 b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15061 "b2: {b2}"
15062 );
15063 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15064 assert!(
15065 !b2.contains(r#""templates""#),
15066 "b2 re-sent a template: {b2}"
15067 );
15068
15069 assert!(!writer.output.contains("serverOnly"));
15071 }
15072
15073 #[test]
15074 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15075 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15076 let handler = WebUIHandler::with_plugin(|| {
15077 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15078 });
15079 let mut writer = FlushTestWriter::default();
15080 handler
15081 .render_streaming(
15082 &protocol,
15083 &test_json!({ "a_count": 1, "b_count": 2 }),
15084 &RenderOptions::new("index.html", "/"),
15085 &mut writer,
15086 )
15087 .unwrap();
15088
15089 let segment = |index: usize| -> &str {
15090 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15091 };
15092 let checkpoint_styles = |checkpoint: &str| -> Value {
15093 let script = checkpoint
15094 .rfind(r#"<script type="application/json" data-webui-boundary"#)
15095 .unwrap();
15096 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15097 let payload_end =
15098 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15099 let record: Value =
15100 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15101 record[3]["componentStyles"].clone()
15102 };
15103
15104 let first = checkpoint_styles(segment(0));
15105 assert!(first["resources"]["comp-a"].is_object());
15106 assert!(first["resources"]["comp-b"].is_object());
15107 assert!(first["closures"]["index.html"].is_array());
15108 assert!(first["closures"]["comp-a"].is_array());
15109
15110 let second = checkpoint_styles(segment(1));
15111 assert_eq!(second["resources"], test_json!({}));
15112 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15113
15114 let repeated = checkpoint_styles(segment(2));
15115 assert_eq!(repeated["resources"], test_json!({}));
15116 assert_eq!(repeated["closures"], test_json!({}));
15117 for resource in ["comp-a", "comp-b"] {
15118 assert_eq!(
15119 [&first, &second, &repeated]
15120 .iter()
15121 .filter(|styles| {
15122 styles["resources"]
15123 .as_object()
15124 .is_some_and(|resources| resources.contains_key(resource))
15125 })
15126 .count(),
15127 1,
15128 "{resource} style metadata was serialized more than once"
15129 );
15130 }
15131 }
15132
15133 #[test]
15134 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15135 let entry = vec![
15136 WebUIFragment::raw("<html><head>"),
15137 structural_fragment("head_start"),
15138 structural_fragment("head_end"),
15139 WebUIFragment::raw("</head><body>"),
15140 structural_fragment("body_start"),
15141 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15142 WebUIFragment::raw("<comp-a"),
15143 structural_fragment("streaming_root:comp-a"),
15144 WebUIFragment::raw(">"),
15145 WebUIFragment::component("comp-a"),
15146 WebUIFragment::raw("</comp-a>"),
15147 WebUIFragment::boundary_end(0),
15148 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15149 WebUIFragment::raw("<comp-hidden"),
15150 structural_fragment("streaming_root:comp-hidden"),
15151 WebUIFragment::raw(">"),
15152 WebUIFragment::component("comp-hidden"),
15153 WebUIFragment::raw("</comp-hidden>"),
15154 WebUIFragment::boundary_end(1),
15155 structural_fragment("body_end"),
15156 WebUIFragment::raw("</body></html>"),
15157 ];
15158 let fragments = HashMap::from([
15159 (
15160 "index.html".to_string(),
15161 FragmentList {
15162 fragments: entry,
15163 contains_boundary: true,
15164 },
15165 ),
15166 (
15167 "comp-a".to_string(),
15168 FragmentList {
15169 fragments: vec![WebUIFragment::if_cond(
15170 ConditionExpr::identifier("show_hidden"),
15171 "hidden-if",
15172 )],
15173 contains_boundary: false,
15174 },
15175 ),
15176 (
15177 "hidden-if".to_string(),
15178 FragmentList {
15179 fragments: vec![WebUIFragment::component("comp-hidden")],
15180 contains_boundary: false,
15181 },
15182 ),
15183 (
15184 "comp-hidden".to_string(),
15185 FragmentList {
15186 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15187 contains_boundary: false,
15188 },
15189 ),
15190 ]);
15191 let mut document = WebUIProtocol::new(fragments);
15192 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15193 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15194 document.components.insert(
15195 "comp-a".to_string(),
15196 webui_protocol::ComponentData {
15197 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15198 hydration_mode: StateProjectionMode::Keys as i32,
15199 hydration_keys: vec!["show_hidden".to_string()],
15200 ..Default::default()
15201 },
15202 );
15203 document.components.insert(
15204 "comp-hidden".to_string(),
15205 webui_protocol::ComponentData {
15206 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15207 css: ".hidden{display:block}".to_string(),
15208 hydration_mode: StateProjectionMode::Keys as i32,
15209 hydration_keys: vec!["hidden_count".to_string()],
15210 ..Default::default()
15211 },
15212 );
15213 document.populate_style_closures(&["index.html"]);
15214 let protocol = Protocol::new(document);
15215 let handler = WebUIHandler::with_plugin(|| {
15216 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15217 });
15218 let mut writer = FlushTestWriter::default();
15219
15220 handler
15221 .render_streaming(
15222 &protocol,
15223 &test_json!({
15224 "show_hidden": false,
15225 "hidden_count": 7,
15226 "unrelated": "private",
15227 }),
15228 &RenderOptions::new("index.html", "/"),
15229 &mut writer,
15230 )
15231 .unwrap();
15232
15233 let boundary = |index: usize| -> &str {
15234 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15235 };
15236 let first = boundary(0);
15237 let second = boundary(1);
15238
15239 assert!(first.contains(r#""comp-a":"#), "first: {first}");
15240 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15241 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15242 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15243 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15244 assert!(
15245 first.contains(r#"<script type="importmap""#),
15246 "first: {first}"
15247 );
15248 assert!(
15249 !first.contains("unrelated"),
15250 "first leaked unrelated state: {first}"
15251 );
15252
15253 assert!(
15254 !second.contains(r#""templates""#),
15255 "second re-sent metadata: {second}"
15256 );
15257 assert!(
15258 !second.contains(r#"<script type="importmap""#),
15259 "second re-sent CSS: {second}"
15260 );
15261 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15262 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15263 assert_eq!(
15264 writer
15265 .output
15266 .matches(r#"<script type="importmap">"#)
15267 .count(),
15268 1,
15269 "import-map output: {}",
15270 writer.output
15271 );
15272 }
15273
15274 #[test]
15275 fn streaming_flush_count_is_boundaries_plus_one() {
15276 for &count in &[1usize, 3, 10, 100] {
15277 let hosts: Vec<&str> = (0..count)
15278 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15279 .collect();
15280 let protocol = disjoint_streaming_protocol(&hosts);
15281 let handler = WebUIHandler::with_plugin(|| {
15282 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15283 });
15284 let mut writer = FlushTestWriter::default();
15285 handler
15286 .render_streaming(
15287 &protocol,
15288 &test_json!({ "a_count": 1, "b_count": 2 }),
15289 &RenderOptions::new("index.html", "/"),
15290 &mut writer,
15291 )
15292 .unwrap();
15293 assert_eq!(
15294 writer.flushes.len(),
15295 count + 2,
15296 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15297 );
15298 }
15299 }
15300
15301 #[test]
15302 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15303 let mut fragments = HashMap::new();
15304 fragments.insert("comp-a".to_string(), FragmentList::default());
15305 fragments.insert("comp-b".to_string(), FragmentList::default());
15306 let mut protocol = WebUIProtocol::new(fragments);
15307 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15308 protocol.components.insert(
15309 "comp-a".to_string(),
15310 webui_protocol::ComponentData {
15311 hydration_mode: StateProjectionMode::Keys as i32,
15312 hydration_keys: vec!["a".to_string(), "shared".to_string()],
15313 ..Default::default()
15314 },
15315 );
15316 protocol.components.insert(
15317 "comp-b".to_string(),
15318 webui_protocol::ComponentData {
15319 hydration_mode: StateProjectionMode::Keys as i32,
15320 hydration_keys: vec!["b".to_string(), "shared".to_string()],
15321 ..Default::default()
15322 },
15323 );
15324
15325 let protocol = Protocol::new(protocol);
15326 let component_index = protocol.component_index();
15327 let reachability = protocol.component_reachability();
15328 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15329 assert!(!collect_hydration_key_ids_into(
15330 protocol.protocol(),
15331 reachability,
15332 [component_index["comp-a"]],
15333 &mut scratch,
15334 ));
15335 let keys = scratch
15336 .iter()
15337 .filter_map(|id| reachability.hydration_key(*id))
15338 .collect::<Vec<_>>();
15339 assert_eq!(keys, ["a", "shared"]);
15340 let pointer = scratch.as_ptr();
15341 let capacity = scratch.capacity();
15342
15343 assert!(!collect_hydration_key_ids_into(
15344 protocol.protocol(),
15345 reachability,
15346 [component_index["comp-b"]],
15347 &mut scratch,
15348 ));
15349 let keys = scratch
15350 .iter()
15351 .filter_map(|id| reachability.hydration_key(*id))
15352 .collect::<Vec<_>>();
15353 assert_eq!(keys, ["b", "shared"]);
15354 assert_eq!(scratch.as_ptr(), pointer);
15355 assert_eq!(scratch.capacity(), capacity);
15356 }
15357}