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,
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
263pub trait FlushWriter: ResponseWriter {
268 fn flush(&mut self) -> Result<()>;
270}
271
272pub struct RenderOptions<'a> {
277 pub entry_id: &'a str,
279 pub request_path: &'a str,
281 pub nonce: Option<&'a str>,
285 pub head_inject: Option<&'a str>,
293 pub body_inject: Option<&'a str>,
298}
299
300pub const STATE_INJECT_KEY: &str = "$webui";
316
317#[derive(Clone, Copy, Default)]
324pub(crate) struct StateInject<'state> {
325 pub(crate) head_end: Option<&'state str>,
326 pub(crate) body_start: Option<&'state str>,
327 pub(crate) body_end: Option<&'state str>,
328}
329
330impl<'state> StateInject<'state> {
331 pub(crate) fn resolve(state: &'state Value) -> Self {
338 let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
339 return Self::default();
340 };
341 let field = |name: &str| {
342 map.get(name)
343 .and_then(Value::as_str)
344 .filter(|html| !html.is_empty())
345 };
346 Self {
347 head_end: field("headEnd"),
348 body_start: field("bodyStart"),
349 body_end: field("bodyEnd"),
350 }
351 }
352}
353
354impl<'a> RenderOptions<'a> {
355 #[must_use]
357 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
358 Self {
359 entry_id,
360 request_path,
361 nonce: None,
362 head_inject: None,
363 body_inject: None,
364 }
365 }
366
367 #[must_use]
371 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
372 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
373 self
374 }
375
376 #[must_use]
391 pub fn with_head_inject(mut self, html: &'a str) -> Self {
392 self.head_inject = if html.is_empty() { None } else { Some(html) };
393 self
394 }
395
396 #[must_use]
406 pub fn with_body_inject(mut self, html: &'a str) -> Self {
407 self.body_inject = if html.is_empty() { None } else { Some(html) };
408 self
409 }
410}
411
412pub struct WebUIHandler {
417 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
418}
419
420#[derive(Clone, Copy, Eq, PartialEq)]
421enum StyleClosureInstall {
422 Static,
423 Routed,
424}
425
426pub(crate) struct ShadowStyleRoot {
427 component_index: u32,
428 static_closure_emitted: bool,
429 routed_resources: Vec<u32>,
430}
431
432struct LoopBinding<'protocol, 'state> {
433 name: &'protocol str,
434 value: &'state Value,
435}
436
437#[derive(Clone, Copy)]
438struct VisibleLoopScope {
439 start: usize,
440 end: usize,
441}
442
443impl VisibleLoopScope {
444 const EMPTY: Self = Self { start: 0, end: 0 };
445}
446
447#[derive(Clone, Copy)]
448struct LocalValueSources<'ctx, 'protocol, 'state> {
449 owned: &'ctx HashMap<String, Value>,
450 borrowed: &'ctx BorrowedScope<'protocol, 'state>,
451}
452
453#[derive(Default)]
454struct BorrowedScope<'protocol, 'state> {
455 inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
456 inline_len: usize,
457 overflow: Vec<(&'protocol str, &'state Value)>,
458}
459
460impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
461 fn get(&self, name: &str) -> Option<&'state Value> {
462 if self.inline_len == 0 {
463 return None;
464 }
465 if let Some((entry_name, value)) = self.inline[0].as_ref() {
466 if *entry_name == name {
467 return Some(*value);
468 }
469 }
470 for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
471 if *entry_name == name {
472 return Some(*value);
473 }
474 }
475 self.overflow
476 .iter()
477 .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
478 }
479
480 fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
481 for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
482 if *entry_name == name {
483 return Some(std::mem::replace(current, value));
484 }
485 }
486 for (entry_name, current) in &mut self.overflow {
487 if *entry_name == name {
488 return Some(std::mem::replace(current, value));
489 }
490 }
491 if self.inline_len < INLINE_SCOPE_SLOTS {
492 self.inline[self.inline_len] = Some((name, value));
493 self.inline_len += 1;
494 } else {
495 self.overflow.push((name, value));
496 }
497 None
498 }
499
500 fn remove(&mut self, name: &str) -> Option<&'state Value> {
501 if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
502 entry
503 .as_ref()
504 .is_some_and(|(entry_name, _)| *entry_name == name)
505 }) {
506 let removed = self.inline[index].take().map(|(_, value)| value);
507 if let Some(entry) = self.overflow.pop() {
508 self.inline[index] = Some(entry);
509 } else {
510 self.inline_len -= 1;
511 self.inline[index] = self.inline[self.inline_len].take();
512 }
513 return removed;
514 }
515 let index = self
516 .overflow
517 .iter()
518 .position(|(entry_name, _)| *entry_name == name)?;
519 Some(self.overflow.swap_remove(index).1)
520 }
521
522 fn clear(&mut self) {
523 for entry in &mut self.inline[..self.inline_len] {
524 *entry = None;
525 }
526 self.inline_len = 0;
527 self.overflow.clear();
528 }
529
530 fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
531 for (name, value) in self.inline[..self.inline_len].iter().flatten() {
532 target.insert((*name).to_owned(), (*value).clone());
533 }
534 for (name, value) in &self.overflow {
535 target.insert((*name).to_owned(), (*value).clone());
536 }
537 }
538}
539
540#[derive(Clone, Copy)]
546pub(crate) struct RenderFragmentList<'protocol> {
547 fragments: &'protocol [WebUIFragment],
548 metadata: &'protocol [RenderFragmentMetadata],
549 attr_names: &'protocol str,
550 has_routes: bool,
553}
554
555impl<'protocol> RenderFragmentList<'protocol> {
556 fn target(self, index: usize) -> Option<usize> {
558 let target = self.metadata.get(index)?.target;
559 (target != NO_RENDER_SLOT).then_some(target as usize)
560 }
561
562 fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
564 let prepared = self.metadata.get(index)?;
565 if prepared.attr_start == NO_ATTR_NAME {
566 return None;
567 }
568 let start = prepared.attr_start as usize;
569 self.attr_names
570 .get(start..start + prepared.attr_len as usize)
571 }
572}
573
574const NO_RENDER_SLOT: u32 = u32::MAX;
576const NO_ATTR_NAME: u32 = u32::MAX;
578
579#[derive(Clone, Copy)]
584struct RenderFragmentMetadata {
585 target: u32,
589 attr_start: u32,
591 attr_len: u32,
592}
593
594pub(crate) struct RenderFragmentIndex {
601 ids: Box<[Arc<str>]>,
602 metadata: Box<[RenderFragmentMetadata]>,
603 ranges: Box<[u32]>,
605 attr_names: Box<str>,
607 route_presence: Box<[u64]>,
609}
610
611pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
616 cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
617 spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
621 index: &'protocol RenderFragmentIndex,
622 protocol: &'protocol WebUIProtocol,
623}
624
625impl RenderFragmentIndex {
626 pub(crate) fn new(
633 protocol: &WebUIProtocol,
634 ids: &[Arc<str>],
635 slots: &HashMap<Arc<str>, u32>,
636 ) -> Self {
637 let total_fragments = protocol
638 .fragments
639 .values()
640 .map(|list| list.fragments.len())
641 .sum();
642 let mut metadata = Vec::with_capacity(total_fragments);
643 let mut ranges = Vec::with_capacity(ids.len() + 1);
644 let mut attr_names = String::with_capacity(total_fragments * 8);
647 let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
648
649 for (slot, id) in ids.iter().enumerate() {
650 #[allow(clippy::cast_possible_truncation)]
652 ranges.push(metadata.len() as u32);
653 let Some(list) = protocol.fragments.get(id.as_ref()) else {
654 continue;
655 };
656 let mut has_routes = false;
657 for fragment in &list.fragments {
658 let inner = fragment.fragment.as_ref();
659 if matches!(inner, Some(Fragment::Route(_))) {
660 has_routes = true;
661 }
662 let target = fragment_target_id(fragment)
663 .and_then(|target| slots.get(target).copied())
664 .unwrap_or(NO_RENDER_SLOT);
665 let (attr_start, attr_len) = match inner {
666 Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
670 #[allow(clippy::cast_possible_truncation)]
671 let start = attr_names.len() as u32;
672 let before = attr_names.len();
673 push_component_attr_name(&mut attr_names, component_attr_source(attribute));
674 #[allow(clippy::cast_possible_truncation)]
675 let len = (attr_names.len() - before) as u32;
676 (start, len)
677 }
678 _ => (NO_ATTR_NAME, 0),
679 };
680 metadata.push(RenderFragmentMetadata {
681 target,
682 attr_start,
683 attr_len,
684 });
685 }
686 if has_routes {
687 route_presence[slot / 64] |= 1u64 << (slot % 64);
688 }
689 }
690 #[allow(clippy::cast_possible_truncation)]
691 ranges.push(metadata.len() as u32);
692
693 Self {
694 ids: ids.to_vec().into_boxed_slice(),
695 metadata: metadata.into_boxed_slice(),
696 ranges: ranges.into_boxed_slice(),
697 attr_names: attr_names.into_boxed_str(),
698 route_presence: route_presence.into_boxed_slice(),
699 }
700 }
701
702 pub(crate) fn index(&self, id: &str) -> Option<usize> {
704 self.ids
705 .binary_search_by(|candidate| candidate.as_ref().cmp(id))
706 .ok()
707 }
708
709 pub(crate) fn resolve<'protocol>(
710 &'protocol self,
711 protocol: &'protocol WebUIProtocol,
712 ) -> ResolvedRenderFragmentIndex<'protocol> {
713 ResolvedRenderFragmentIndex {
714 cache: std::array::from_fn(|_| Cell::new(None)),
715 spilled: OnceCell::new(),
716 index: self,
717 protocol,
718 }
719 }
720}
721
722impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
723 pub(crate) fn index(&self, id: &str) -> Option<usize> {
724 self.index.index(id)
725 }
726
727 fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
729 if index < INLINE_RENDER_FRAGMENT_LISTS {
730 return self.cache.get(index);
731 }
732 let spilled = self.spilled.get_or_init(|| {
733 let overflow = self
734 .index
735 .ids
736 .len()
737 .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
738 (0..overflow).map(|_| Cell::new(None)).collect()
739 });
740 spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
741 }
742
743 pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
744 let id = self.index.ids.get(index)?;
745 let fragment_list = match self.slot(index) {
746 Some(slot) => match slot.get() {
747 Some(cached) => cached,
748 None => {
749 let resolved = self.protocol.fragments.get(id.as_ref())?;
750 slot.set(Some(resolved));
751 resolved
752 }
753 },
754 None => self.protocol.fragments.get(id.as_ref())?,
755 };
756 let start = *self.index.ranges.get(index)? as usize;
757 let end = *self.index.ranges.get(index + 1)? as usize;
758 let metadata = self.index.metadata.get(start..end)?;
759 let has_routes = self
760 .index
761 .route_presence
762 .get(index / 64)
763 .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
764 Some(RenderFragmentList {
765 fragments: &fragment_list.fragments,
766 metadata,
767 attr_names: &self.index.attr_names,
768 has_routes,
769 })
770 }
771
772 #[cfg(test)]
773 pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
774 self.index(id).and_then(|index| self.list(index))
775 }
776}
777
778fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
785 let takes_complex_branch = attribute.condition_tree.is_none()
786 && attribute.template.is_empty()
787 && !attribute.value.is_empty()
788 && !attribute.raw_value
789 && attribute.complex;
790 if takes_complex_branch {
791 attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
792 } else {
793 &attribute.name
794 }
795}
796
797fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
799 match fragment.fragment.as_ref()? {
800 Fragment::Component(component) => Some(&component.fragment_id),
801 Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
802 Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
803 Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
804 Some(&attribute.template)
805 }
806 _ => None,
807 }
808}
809
810pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
812 pub(crate) protocol: &'protocol WebUIProtocol,
813 pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
817 pub(crate) component_asset_style_manifest: &'protocol str,
820 pub(crate) component_asset_style_links: &'protocol str,
822 pub(crate) state: &'state Value,
823 pub(crate) writer: &'output mut dyn ResponseWriter,
824 pub(crate) local_vars: HashMap<String, Value>,
825 local_borrowed_vars: BorrowedScope<'protocol, 'state>,
827 loop_vars: Vec<LoopBinding<'protocol, 'state>>,
829 visible_loop_scope: VisibleLoopScope,
832 pub(crate) component_attrs: HashMap<String, Value>,
834 component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
836 collecting_component_attrs: bool,
840 pub(crate) request_path: &'protocol str,
843 pub(crate) route_base: Cow<'protocol, str>,
848 pub(crate) rendered_components: HashSet<String>,
852 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
854 pub(crate) route_children: Vec<webui_protocol::WebUiFragmentRoute>,
857 pub(crate) entry_id: &'protocol str,
860 pub(crate) nonce: Option<&'protocol str>,
863 pub(crate) component_index: &'protocol HashMap<String, u32>,
866 pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
868 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
874 pub(crate) css_strategy: webui_protocol::CssStrategy,
876 pub(crate) head_inject: Option<&'protocol str>,
882 pub(crate) body_inject: Option<&'protocol str>,
886 pub(crate) state_inject: StateInject<'state>,
891 pub(crate) head_end_emitted: bool,
898 pub(crate) body_start_emitted: bool,
903 pub(crate) component_asset_styles_emitted: bool,
906 pub(crate) body_end_emitted: bool,
911 pub(crate) route_index: &'protocol CompiledRouteIndex,
913 pub(crate) route_chain_index: usize,
917 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
920 pub(crate) route_document_style_targets: Vec<bool>,
924 pub(crate) reachable_components: Option<Vec<String>>,
930 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
932 pub(crate) json_scratch: Vec<u8>,
939 pub(crate) scope_pool: Vec<HashMap<String, Value>>,
945 pub(crate) document_style_resources: HashSet<String>,
948 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
951 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
953}
954
955pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
958
959pub(crate) fn structural_signal_value(
960 signal: &webui_protocol::WebUIFragmentSignal,
961) -> Option<&str> {
962 if !signal.raw {
963 return None;
964 }
965 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
966}
967
968pub(crate) fn write_interaction_marker(
969 tag: &str,
970 context: &mut WebUIProcessContext<'_, '_, '_>,
971) -> Result<()> {
972 if matches!(
973 context
974 .protocol
975 .components
976 .get(tag)
977 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
978 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
979 ) {
980 context.writer.write(" data-webui-interaction")?;
981 }
982 Ok(())
983}
984
985fn doctype_prefix_end(raw: &str) -> Option<usize> {
988 const PREFIX: &[u8] = b"<!doctype";
989
990 let bytes = raw.as_bytes();
991 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
992 while bytes
993 .get(cursor)
994 .is_some_and(|byte| byte.is_ascii_whitespace())
995 {
996 cursor += 1;
997 }
998
999 let prefix_end = cursor.checked_add(PREFIX.len())?;
1000 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1001 return None;
1002 }
1003 let separator = *bytes.get(prefix_end)?;
1004 if separator != b'>' && !separator.is_ascii_whitespace() {
1005 return None;
1006 }
1007
1008 let mut quote = None;
1009 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1010 match quote {
1011 Some(active) if byte == active => quote = None,
1012 Some(_) => {}
1013 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1014 None if byte == b'>' => return Some(prefix_end + offset + 1),
1015 None => {}
1016 }
1017 }
1018 None
1019}
1020
1021const SCOPE_POOL_CAP: usize = 8;
1024const INLINE_SCOPE_SLOTS: usize = 4;
1026const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1037const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1038
1039struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1040
1041impl Serialize for ComponentAssetStyleManifest<'_> {
1042 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1043 where
1044 S: serde::Serializer,
1045 {
1046 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1047 for entry in self.0 {
1048 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1049 }
1050 map.end()
1051 }
1052}
1053
1054fn serialize_component_asset_style_manifest(
1055 preloads: &[ComponentAssetStylePreload],
1056) -> std::result::Result<String, serde_json::Error> {
1057 if preloads.is_empty() {
1058 return Ok(String::new());
1059 }
1060 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1061 if json.contains("</") {
1062 Ok(json.replace("</", "<\\/"))
1063 } else {
1064 Ok(json)
1065 }
1066}
1067
1068fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1069 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1070 let mut seen = HashSet::with_capacity(href_count);
1071 let mut links = String::with_capacity(href_count.saturating_mul(48));
1072 for entry in preloads {
1073 for href in &entry.style_hrefs {
1074 if !seen.insert(href.as_str()) {
1075 continue;
1076 }
1077 links.push_str("<link rel=\"stylesheet\" href=\"");
1078 push_escaped_html_attribute(&mut links, href);
1079 links.push_str("\">");
1080 }
1081 }
1082 links
1083}
1084
1085fn push_escaped_html_attribute(output: &mut String, value: &str) {
1086 let mut start = 0;
1087 for (index, ch) in value.char_indices() {
1088 let escaped = match ch {
1089 '&' => Some("&"),
1090 '"' => Some("""),
1091 '<' => Some("<"),
1092 '>' => Some(">"),
1093 _ => None,
1094 };
1095 let Some(entity) = escaped else {
1096 continue;
1097 };
1098 if start < index {
1099 output.push_str(&value[start..index]);
1100 }
1101 output.push_str(entity);
1102 start = index + ch.len_utf8();
1103 }
1104 if start < value.len() {
1105 output.push_str(&value[start..]);
1106 }
1107}
1108
1109fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1112 pool.pop().unwrap_or_default()
1113}
1114
1115fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1119 if pool.len() < SCOPE_POOL_CAP {
1120 map.clear();
1121 pool.push(map);
1122 }
1123}
1124
1125fn take_borrowed_scope<'protocol, 'state>(
1126 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1127) -> BorrowedScope<'protocol, 'state> {
1128 pool.pop().unwrap_or_default()
1129}
1130
1131fn recycle_borrowed_scope<'protocol, 'state>(
1132 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1133 mut scope: BorrowedScope<'protocol, 'state>,
1134) {
1135 if pool.len() < SCOPE_POOL_CAP {
1136 scope.clear();
1137 pool.push(scope);
1138 }
1139}
1140
1141pub(crate) struct WebUiBootstrap<'a> {
1142 pub(crate) declaration_id: Option<u32>,
1143 pub(crate) enclosing_span_instance_id: Option<u32>,
1144 pub(crate) state: &'a Value,
1145 pub(crate) state_selection: StateSelection<'a>,
1146 pub(crate) chain: &'a [Value],
1147 pub(crate) inventory: &'a str,
1148 pub(crate) nonce: Option<&'a str>,
1149 pub(crate) css_hrefs: &'a [&'a str],
1150 pub(crate) style_specs: &'a [&'a str],
1151 pub(crate) component_styles: &'a Value,
1152 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1153}
1154
1155#[cfg(test)]
1165fn component_attr_name(name: &str) -> String {
1166 let mut buffer = String::new();
1167 push_component_attr_name(&mut buffer, name);
1168 buffer
1169}
1170
1171fn push_component_attr_name(buffer: &mut String, name: &str) {
1180 let stripped = name.strip_prefix(':').unwrap_or(name);
1181 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1186 buffer.push_str(property);
1187 return;
1188 }
1189 buffer.reserve(stripped.len());
1190 let mut capitalize_next = false;
1191 for ch in stripped.chars() {
1192 if ch == '-' {
1193 capitalize_next = true;
1194 } else if capitalize_next {
1195 buffer.extend(ch.to_uppercase());
1196 capitalize_next = false;
1197 } else {
1198 buffer.push(ch);
1199 }
1200 }
1201}
1202
1203#[cold]
1204#[inline(never)]
1205fn missing_component_attr_name_error() -> HandlerError {
1206 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1207}
1208
1209pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1211 if n == 0 {
1212 return writer.write("0");
1213 }
1214 let mut buf = [0u8; 20];
1216 let mut pos = buf.len();
1217 while n > 0 {
1218 pos -= 1;
1219 #[allow(clippy::cast_possible_truncation)]
1221 let digit = (n % 10) as u8;
1222 buf[pos] = b'0' + digit;
1223 n /= 10;
1224 }
1225 match std::str::from_utf8(&buf[pos..]) {
1227 Ok(s) => writer.write(s),
1228 Err(_) => writer.write("0"),
1229 }
1230}
1231
1232pub(crate) fn write_script_safe_json<T>(
1233 writer: &mut dyn ResponseWriter,
1234 scratch: &mut Vec<u8>,
1235 value: &T,
1236) -> Result<()>
1237where
1238 T: Serialize + ?Sized,
1239{
1240 scratch.clear();
1248 serde_json::to_writer(&mut *scratch, value)
1249 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1250 let json = std::str::from_utf8(scratch)
1251 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1252 write_script_safe_json_str(writer, json)
1253}
1254
1255fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1256 let mut start = 0;
1257 while start < json.len() {
1258 let rest = &json[start..];
1259 let Some(offset) = rest.find("</") else {
1260 writer.write(rest)?;
1261 return Ok(());
1262 };
1263
1264 if offset > 0 {
1265 writer.write(&rest[..offset])?;
1266 }
1267 writer.write("<\\/")?;
1268 start += offset + 2;
1269 }
1270 Ok(())
1271}
1272
1273fn write_json_field_name(
1274 writer: &mut dyn ResponseWriter,
1275 wrote_field: &mut bool,
1276 name: &str,
1277) -> Result<()> {
1278 if *wrote_field {
1279 writer.write(",")?;
1280 }
1281 *wrote_field = true;
1282 writer.write("\"")?;
1283 writer.write(name)?;
1284 writer.write("\":")
1285}
1286
1287fn write_json_field<T>(
1288 writer: &mut dyn ResponseWriter,
1289 scratch: &mut Vec<u8>,
1290 wrote_field: &mut bool,
1291 name: &str,
1292 value: &T,
1293) -> Result<()>
1294where
1295 T: Serialize + ?Sized,
1296{
1297 write_json_field_name(writer, wrote_field, name)?;
1298 write_script_safe_json(writer, scratch, value)
1299}
1300
1301struct ProjectedState<'a> {
1319 value: &'a Value,
1320 keys: KeyView<'a>,
1321}
1322
1323impl Serialize for ProjectedState<'_> {
1324 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1325 where
1326 S: serde::Serializer,
1327 {
1328 let Value::Object(map) = self.value else {
1329 return serializer.serialize_map(Some(0))?.end();
1330 };
1331
1332 let mut out = serializer.serialize_map(None)?;
1333 if self.keys.len() < map.len() {
1334 let mut previous = None;
1335 for key in self.keys.iter() {
1336 if key == STATE_INJECT_KEY {
1337 continue;
1338 }
1339 if previous == Some(key) {
1340 continue;
1341 }
1342 previous = Some(key);
1343 if let Some(value) = map.get(key) {
1344 out.serialize_entry(key, value)?;
1345 }
1346 }
1347 } else {
1348 for (key, value) in map {
1349 if key == STATE_INJECT_KEY {
1350 continue;
1351 }
1352 if self.keys.contains(key.as_str()) {
1353 out.serialize_entry(key, value)?;
1354 }
1355 }
1356 }
1357 out.end()
1358 }
1359}
1360
1361struct StateWithoutReservedKey<'a> {
1382 value: &'a Value,
1383}
1384
1385impl Serialize for StateWithoutReservedKey<'_> {
1386 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1387 where
1388 S: serde::Serializer,
1389 {
1390 let Value::Object(map) = self.value else {
1391 return self.value.serialize(serializer);
1392 };
1393 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1394 for (key, value) in map {
1395 if key == STATE_INJECT_KEY {
1396 continue;
1397 }
1398 out.serialize_entry(key, value)?;
1399 }
1400 out.end()
1401 }
1402}
1403
1404fn write_full_state(
1409 writer: &mut dyn ResponseWriter,
1410 scratch: &mut Vec<u8>,
1411 state: &Value,
1412) -> Result<()> {
1413 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1414 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1415 }
1416 write_script_safe_json(writer, scratch, state)
1417}
1418
1419pub(crate) fn write_selected_state(
1420 writer: &mut dyn ResponseWriter,
1421 scratch: &mut Vec<u8>,
1422 state: &Value,
1423 selection: &StateSelection<'_>,
1424) -> Result<()> {
1425 let keys = match selection {
1426 StateSelection::Full => return write_full_state(writer, scratch, state),
1427 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1428 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1429 };
1430 if keys.is_empty() {
1431 return writer.write("{}");
1432 }
1433
1434 debug_assert!(
1439 keys.iter()
1440 .zip(keys.iter().skip(1))
1441 .all(|(left, right)| left <= right),
1442 "hydration keys must be sorted for binary-search projection"
1443 );
1444 if let Value::Object(map) = state {
1445 let selects_entire_map =
1446 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1447 if selects_entire_map {
1448 return write_full_state(writer, scratch, state);
1449 }
1450 }
1451 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1452}
1453
1454pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1457
1458pub(crate) enum StateSelection<'a> {
1460 Full,
1462 Keys(Vec<&'a str>),
1464 KeyIds(HydrationKeySelection<'a>),
1471}
1472
1473#[derive(Clone, Copy)]
1475pub(crate) struct HydrationKeySelection<'a> {
1476 pub(crate) ids: &'a [u32],
1477 pub(crate) index: &'a ComponentReachabilityIndex,
1478}
1479
1480impl<'a> HydrationKeySelection<'a> {
1481 fn key(self, position: usize) -> Option<&'a str> {
1482 let id = self.ids.get(position).copied()?;
1483 self.index.hydration_key(id)
1484 }
1485}
1486
1487#[derive(Clone, Copy)]
1489enum KeyView<'a> {
1490 Borrowed(&'a [&'a str]),
1491 Ids(HydrationKeySelection<'a>),
1492}
1493
1494impl<'a> KeyView<'a> {
1495 fn len(self) -> usize {
1496 match self {
1497 Self::Borrowed(keys) => keys.len(),
1498 Self::Ids(selection) => selection.ids.len(),
1499 }
1500 }
1501
1502 fn is_empty(self) -> bool {
1503 self.len() == 0
1504 }
1505
1506 fn get(self, position: usize) -> Option<&'a str> {
1507 match self {
1508 Self::Borrowed(keys) => keys.get(position).copied(),
1509 Self::Ids(selection) => selection.key(position),
1510 }
1511 }
1512
1513 fn iter(self) -> impl Iterator<Item = &'a str> {
1514 (0..self.len()).map_while(move |position| self.get(position))
1515 }
1516
1517 fn contains(self, key: &str) -> bool {
1520 match self {
1521 Self::Borrowed(keys) => keys
1522 .binary_search_by(|candidate| str::cmp(candidate, key))
1523 .is_ok(),
1524 Self::Ids(selection) => selection
1525 .ids
1526 .binary_search_by(|id| {
1527 selection
1528 .index
1529 .hydration_key(*id)
1530 .map_or(std::cmp::Ordering::Less, |candidate| {
1531 str::cmp(candidate, key)
1532 })
1533 })
1534 .is_ok(),
1535 }
1536 }
1537}
1538
1539#[derive(Clone, Copy)]
1540enum ComponentStateSurface {
1541 Hydration,
1542 Navigation,
1543}
1544
1545pub(crate) fn collect_hydration_state<'a, 'b>(
1551 protocol: &'a WebUIProtocol,
1552 components: impl IntoIterator<Item = &'b str>,
1553) -> StateSelection<'a> {
1554 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1555 return StateSelection::Full;
1556 }
1557 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1558}
1559
1560pub(crate) fn collect_hydration_key_ids_into(
1568 protocol: &WebUIProtocol,
1569 index: &ComponentReachabilityIndex,
1570 components: impl IntoIterator<Item = u32>,
1571 ids: &mut Vec<u32>,
1572) -> bool {
1573 ids.clear();
1574 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1575 return true;
1576 }
1577 for component in components {
1578 if index.extend_hydration_keys(component, ids) {
1579 ids.clear();
1580 return true;
1581 }
1582 }
1583 ids.sort_unstable();
1584 ids.dedup();
1585 false
1586}
1587
1588pub(crate) fn collect_navigation_state<'a, 'b>(
1590 protocol: &'a WebUIProtocol,
1591 components: impl IntoIterator<Item = &'b str>,
1592) -> StateSelection<'a> {
1593 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1594}
1595
1596fn collect_component_state<'a, 'b>(
1597 protocol: &'a WebUIProtocol,
1598 components: impl IntoIterator<Item = &'b str>,
1599 surface: ComponentStateSurface,
1600) -> StateSelection<'a> {
1601 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1602 if collect_component_state_into(protocol, components, surface, &mut keys) {
1603 StateSelection::Full
1604 } else {
1605 StateSelection::Keys(keys)
1606 }
1607}
1608
1609fn collect_component_state_into<'a, 'b>(
1612 protocol: &'a WebUIProtocol,
1613 components: impl IntoIterator<Item = &'b str>,
1614 surface: ComponentStateSurface,
1615 keys: &mut Vec<&'a str>,
1616) -> bool {
1617 keys.clear();
1618 for name in components {
1619 let Some(component) = protocol.components.get(name) else {
1620 return true;
1621 };
1622 let (mode, component_keys) = match surface {
1623 ComponentStateSurface::Hydration => {
1624 (component.hydration_mode, &component.hydration_keys)
1625 }
1626 ComponentStateSurface::Navigation => (
1627 match component.navigation_mode {
1628 Some(mode) => mode,
1629 None if !component.navigation_keys.is_empty() => {
1630 StateProjectionMode::Keys as i32
1631 }
1632 None => return true,
1633 },
1634 &component.navigation_keys,
1635 ),
1636 };
1637 if mode == StateProjectionMode::All as i32 {
1638 return true;
1639 }
1640 if mode == StateProjectionMode::Keys as i32
1641 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1642 {
1643 keys.extend(component_keys.iter().map(String::as_str));
1644 } else if mode != StateProjectionMode::None as i32 {
1645 return true;
1646 }
1647 }
1648 keys.sort_unstable();
1649 keys.dedup();
1650 false
1651}
1652
1653pub(crate) fn write_webui_bootstrap(
1654 writer: &mut dyn ResponseWriter,
1655 scratch: &mut Vec<u8>,
1656 bootstrap: WebUiBootstrap<'_>,
1657) -> Result<()> {
1658 let mut wrote_field = false;
1659
1660 writer.write("{")?;
1661 if let Some(declaration_id) = bootstrap.declaration_id {
1662 write_json_field(
1663 writer,
1664 scratch,
1665 &mut wrote_field,
1666 "declarationId",
1667 &declaration_id,
1668 )?;
1669 }
1670 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1671 write_json_field(
1672 writer,
1673 scratch,
1674 &mut wrote_field,
1675 "enclosingSpanInstanceId",
1676 &span_id,
1677 )?;
1678 }
1679 if !bootstrap.chain.is_empty() {
1680 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1681 }
1682 write_json_field(
1685 writer,
1686 scratch,
1687 &mut wrote_field,
1688 "componentStyles",
1689 bootstrap.component_styles,
1690 )?;
1691 if !bootstrap.css_hrefs.is_empty() {
1692 write_json_field(
1693 writer,
1694 scratch,
1695 &mut wrote_field,
1696 "css",
1697 bootstrap.css_hrefs,
1698 )?;
1699 }
1700 write_json_field(
1701 writer,
1702 scratch,
1703 &mut wrote_field,
1704 "inventory",
1705 bootstrap.inventory,
1706 )?;
1707 if let Some(nonce) = bootstrap.nonce {
1708 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1709 }
1710 write_json_field_name(writer, &mut wrote_field, "state")?;
1711 write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
1712 if !bootstrap.style_specs.is_empty() {
1713 write_json_field(
1714 writer,
1715 scratch,
1716 &mut wrote_field,
1717 "styles",
1718 bootstrap.style_specs,
1719 )?;
1720 }
1721 if bootstrap
1722 .templates
1723 .iter()
1724 .any(|template| !template.template_json.is_empty())
1725 {
1726 write_json_field_name(writer, &mut wrote_field, "templates")?;
1727 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1728 }
1729 writer.write("}")
1730}
1731
1732fn write_webui_data_block(
1733 writer: &mut dyn ResponseWriter,
1734 scratch: &mut Vec<u8>,
1735 bootstrap: WebUiBootstrap<'_>,
1736) -> Result<()> {
1737 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1738 if let Some(nonce) = bootstrap.nonce {
1739 writer.write(" nonce=\"")?;
1740 writer.write(nonce)?;
1741 writer.write("\"")?;
1742 }
1743
1744 writer.write(">")?;
1745 write_webui_bootstrap(writer, scratch, bootstrap)?;
1746 writer.write("</script>\n")
1747}
1748
1749fn write_component_asset_style_manifest(
1750 writer: &mut dyn ResponseWriter,
1751 manifest: &str,
1752 nonce: Option<&str>,
1753) -> Result<()> {
1754 if manifest.is_empty() {
1755 return Ok(());
1756 }
1757 writer.write("<script type=\"application/json\" id=\"")?;
1758 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1759 writer.write("\"")?;
1760 if let Some(nonce) = nonce {
1761 writer.write(" nonce=\"")?;
1762 writer.write(&crate::html_encode::encode_safe(nonce))?;
1763 writer.write("\"")?;
1764 }
1765 writer.write(">")?;
1766 writer.write(manifest)?;
1767 writer.write("</script>")
1768}
1769
1770fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1771 if context.component_asset_styles_emitted {
1772 return Ok(());
1773 }
1774 context.component_asset_styles_emitted = true;
1775 context.writer.write(context.component_asset_style_links)?;
1776 write_component_asset_style_manifest(
1777 context.writer,
1778 context.component_asset_style_manifest,
1779 context.nonce,
1780 )
1781}
1782
1783fn write_webui_template_json_map(
1784 writer: &mut dyn ResponseWriter,
1785 scratch: &mut Vec<u8>,
1786 templates: &[WebUiTemplatePayload<'_>],
1787) -> Result<()> {
1788 writer.write("{")?;
1789 let mut wrote = false;
1790 for template in templates {
1791 if template.template_json.is_empty() {
1792 continue;
1793 }
1794 if wrote {
1795 writer.write(",")?;
1796 }
1797 wrote = true;
1798 write_script_safe_json(writer, scratch, template.tag_name)?;
1799 writer.write(":")?;
1800 write_script_safe_json_str(writer, template.template_json)?;
1801 }
1802 writer.write("}")
1803}
1804
1805fn resolve_value_from_sources<'ctx, 'state>(
1806 path: &str,
1807 loop_vars: &'ctx [LoopBinding<'_, 'state>],
1808 visible_loop_scope: VisibleLoopScope,
1809 local_values: LocalValueSources<'ctx, '_, 'state>,
1810 state: &'state Value,
1811) -> Option<Cow<'ctx, Value>>
1812where
1813 'state: 'ctx,
1814{
1815 if let Some(binding) = loop_vars
1816 .get(visible_loop_scope.start..visible_loop_scope.end)
1817 .and_then(|bindings| bindings.last())
1818 {
1819 let name = binding.name;
1820 if path.len() == name.len() && path == name {
1821 return Some(Cow::Borrowed(binding.value));
1822 }
1823 if path.len() > name.len()
1824 && path.as_bytes().get(name.len()) == Some(&b'.')
1825 && path.starts_with(name)
1826 {
1827 if let Some(value) =
1828 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1829 {
1830 return Some(value);
1831 }
1832 return find_value_by_dotted_path_ref(path, state);
1833 }
1834 }
1835
1836 if let Some(first_part) = path.split('.').next() {
1837 if let Some(local_value) = local_values.borrowed.get(first_part) {
1838 if first_part.len() == path.len() {
1839 return Some(Cow::Borrowed(local_value));
1840 }
1841 let remaining = &path[first_part.len() + 1..];
1842 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1843 return Some(value);
1844 }
1845 return find_value_by_dotted_path_ref(path, state);
1846 }
1847 if let Some(local_value) = local_values.owned.get(first_part) {
1848 if first_part.len() == path.len() {
1849 return Some(Cow::Borrowed(local_value));
1850 }
1851 let remaining = &path[first_part.len() + 1..];
1852 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1853 return Some(value);
1854 }
1855 return find_value_by_dotted_path_ref(path, state);
1856 }
1857
1858 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1859 .iter()
1860 .rev()
1861 {
1862 if binding.name != first_part {
1863 continue;
1864 }
1865 if first_part.len() == path.len() {
1866 return Some(Cow::Borrowed(binding.value));
1867 }
1868 let remaining = &path[first_part.len() + 1..];
1869 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1870 return Some(value);
1871 }
1872 return find_value_by_dotted_path_ref(path, state);
1873 }
1874 }
1875
1876 find_value_by_dotted_path_ref(path, state)
1877}
1878
1879fn resolve_borrowed_collection<'state>(
1880 path: &str,
1881 loop_vars: &[LoopBinding<'_, 'state>],
1882 visible_loop_scope: VisibleLoopScope,
1883 local_values: LocalValueSources<'_, '_, 'state>,
1884 state: &'state Value,
1885) -> Option<&'state [Value]> {
1886 let first_part = path.split('.').next()?;
1887 if let Some(binding) = loop_vars
1888 .get(visible_loop_scope.start..visible_loop_scope.end)
1889 .and_then(|bindings| bindings.last())
1890 .filter(|binding| binding.name == first_part)
1891 {
1892 if first_part.len() == path.len() {
1893 return binding.value.as_array().map(Vec::as_slice);
1894 }
1895 let remaining = &path[first_part.len() + 1..];
1896 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1897 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1898 Some(Cow::Owned(_)) => None,
1899 None => borrowed_state_array(path, state),
1900 };
1901 }
1902 if let Some(local_value) = local_values.borrowed.get(first_part) {
1903 let local_match = if first_part.len() == path.len() {
1904 Some(Cow::Borrowed(local_value))
1905 } else {
1906 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1907 };
1908 return match local_match {
1909 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1910 Some(Cow::Owned(_)) => None,
1911 None => borrowed_state_array(path, state),
1912 };
1913 }
1914 if let Some(local_value) = local_values.owned.get(first_part) {
1915 let local_match = if first_part.len() == path.len() {
1916 Some(Cow::Borrowed(local_value))
1917 } else {
1918 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1919 };
1920 if local_match.is_some() {
1921 return None;
1922 }
1923 return borrowed_state_array(path, state);
1924 }
1925
1926 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1927 .iter()
1928 .rev()
1929 {
1930 if binding.name != first_part {
1931 continue;
1932 }
1933 if first_part.len() == path.len() {
1934 return binding.value.as_array().map(Vec::as_slice);
1935 }
1936 let remaining = &path[first_part.len() + 1..];
1937 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1938 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1939 Some(Cow::Owned(_)) => None,
1940 None => borrowed_state_array(path, state),
1941 };
1942 }
1943
1944 borrowed_state_array(path, state)
1945}
1946
1947fn resolve_state_backed_value<'state>(
1948 path: &str,
1949 loop_vars: &[LoopBinding<'_, 'state>],
1950 visible_loop_scope: VisibleLoopScope,
1951 local_values: LocalValueSources<'_, '_, 'state>,
1952 state: &'state Value,
1953) -> Option<&'state Value> {
1954 let first_part = path.split('.').next()?;
1955 if let Some(binding) = loop_vars
1956 .get(visible_loop_scope.start..visible_loop_scope.end)
1957 .and_then(|bindings| bindings.last())
1958 .filter(|binding| binding.name == first_part)
1959 {
1960 if first_part.len() == path.len() {
1961 return Some(binding.value);
1962 }
1963 let remaining = &path[first_part.len() + 1..];
1964 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1965 Some(Cow::Borrowed(value)) => Some(value),
1966 Some(Cow::Owned(_)) => None,
1967 None => borrowed_state_value(path, state),
1968 };
1969 }
1970 if let Some(local_value) = local_values.borrowed.get(first_part) {
1971 if first_part.len() == path.len() {
1972 return Some(local_value);
1973 }
1974 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1975 Some(Cow::Borrowed(value)) => Some(value),
1976 Some(Cow::Owned(_)) => None,
1977 None => borrowed_state_value(path, state),
1978 };
1979 }
1980 if let Some(local_value) = local_values.owned.get(first_part) {
1981 if first_part.len() == path.len() {
1982 return None;
1983 }
1984 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1985 Some(_) => None,
1986 None => borrowed_state_value(path, state),
1987 };
1988 }
1989
1990 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1991 .iter()
1992 .rev()
1993 {
1994 if binding.name != first_part {
1995 continue;
1996 }
1997 if first_part.len() == path.len() {
1998 return Some(binding.value);
1999 }
2000 let remaining = &path[first_part.len() + 1..];
2001 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2002 Some(Cow::Borrowed(value)) => Some(value),
2003 Some(Cow::Owned(_)) => None,
2004 None => borrowed_state_value(path, state),
2005 };
2006 }
2007
2008 borrowed_state_value(path, state)
2009}
2010
2011fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2012 match find_value_by_dotted_path_ref(path, state) {
2013 Some(Cow::Borrowed(value)) => Some(value),
2014 Some(Cow::Owned(_)) | None => None,
2015 }
2016}
2017
2018fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2019 match find_value_by_dotted_path_ref(path, state)? {
2020 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2021 Cow::Owned(_) => None,
2022 }
2023}
2024
2025impl WebUIHandler {
2026 pub fn new() -> Self {
2028 Self {
2029 plugin_factory: None,
2030 }
2031 }
2032
2033 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2038 Self {
2039 plugin_factory: Some(factory),
2040 }
2041 }
2042
2043 #[cfg(test)]
2044 fn handle(
2045 &self,
2046 document: &WebUIProtocol,
2047 state: &Value,
2048 options: &RenderOptions<'_>,
2049 writer: &mut dyn ResponseWriter,
2050 ) -> Result<()> {
2051 let protocol = Protocol::new(document.clone());
2052 self.render(&protocol, state, options, writer)
2053 }
2054
2055 fn process_fragment_target<'data>(
2061 &self,
2062 target: Option<usize>,
2063 fragment_id: &str,
2064 context: &mut WebUIProcessContext<'data, '_, '_>,
2065 ) -> Result<()> {
2066 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2067 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2068 };
2069 let Some(fragment_list) = context.render_fragments.list(index) else {
2070 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2071 };
2072 self.process_fragment(fragment_list, context)
2073 }
2074
2075 fn process_fragment<'data>(
2080 &self,
2081 fragment_list: RenderFragmentList<'data>,
2082 context: &mut WebUIProcessContext<'data, '_, '_>,
2083 ) -> Result<()> {
2084 self.process_fragment_from(fragment_list, 0, context)
2085 }
2086
2087 fn process_fragment_from<'data>(
2088 &self,
2089 fragment_list: RenderFragmentList<'data>,
2090 start: usize,
2091 context: &mut WebUIProcessContext<'data, '_, '_>,
2092 ) -> Result<()> {
2093 let fragments = fragment_list.fragments;
2094 let best_route = if fragment_list.has_routes {
2100 route_renderer::find_best_route_match(
2101 fragments,
2102 context.request_path,
2103 &context.route_base,
2104 context.route_index,
2105 )
2106 } else {
2107 None
2108 };
2109 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2110 }
2111
2112 fn process_fragment_range<'data>(
2113 &self,
2114 fragment_list: RenderFragmentList<'data>,
2115 range: std::ops::Range<usize>,
2116 best_route: &Option<(String, route_matcher::RouteMatch)>,
2117 context: &mut WebUIProcessContext<'data, '_, '_>,
2118 ) -> Result<()> {
2119 let fragments = fragment_list.fragments;
2120 let Some(selected) = fragments.get(range.clone()) else {
2121 return Err(invalid_fragment_range_error(&range, fragments.len()));
2122 };
2123 for (offset, item) in selected.iter().enumerate() {
2124 let index = range.start + offset;
2125 if context.streaming.is_some() {
2126 validate_pending_streaming_root(item, context)?;
2127 validate_streaming_root_opening(&fragments[..index], item)?;
2128 }
2129 match item.fragment.as_ref() {
2133 Some(Fragment::Raw(raw)) => {
2134 context.writer.write(&raw.value)?;
2135 }
2136 Some(Fragment::Component(component)) => {
2137 self.process_component(
2138 component,
2139 fragment_list.target(index),
2140 ComponentHostOrigin::ParserProduced,
2141 context,
2142 )?;
2143 }
2144 Some(Fragment::ForLoop(for_loop)) => {
2145 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2146 }
2147 Some(Fragment::Signal(signal)) => {
2148 self.process_signal(signal, context)?;
2149 }
2150 Some(Fragment::IfCond(if_cond)) => {
2151 self.process_if(if_cond, fragment_list.target(index), context)?;
2152 }
2153 Some(Fragment::Attribute(attr)) => {
2154 self.process_attribute(
2155 attr,
2156 fragment_list.target(index),
2157 fragment_list.component_attr_name(index),
2158 context,
2159 )?;
2160 }
2161 Some(Fragment::Plugin(plugin_frag)) => {
2162 if let Some(p) = &mut context.plugin {
2163 p.on_element_data(&plugin_frag.data, context.writer)?;
2164 }
2165 }
2166 Some(Fragment::Route(route_frag)) => {
2167 self.process_route(route_frag, best_route, context)?;
2168 }
2169 Some(Fragment::Outlet(_)) => {
2170 self.process_outlet(context)?;
2171 }
2172 Some(Fragment::Boundary(_)) => {}
2173 None => {}
2174 }
2175 }
2176 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2177 }
2178
2179 fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2185 let mut children = std::mem::take(&mut context.route_children);
2186 if children.is_empty() {
2187 return Ok(());
2188 }
2189
2190 let request_segments = route_matcher::split_request_path(context.request_path);
2192 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2193 for (idx, child) in children.iter().enumerate() {
2194 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2195 context.route_index,
2196 &child.path,
2197 &context.route_base,
2198 &request_segments,
2199 child.exact,
2200 ) {
2201 let is_better = best
2202 .as_ref()
2203 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2204 if is_better {
2205 best = Some((idx, m));
2206 }
2207 }
2208 }
2209
2210 let grandchildren = if let Some((idx, _)) = &best {
2214 std::mem::take(&mut children[*idx].children)
2215 } else {
2216 Vec::new()
2217 };
2218
2219 if let Some((idx, ref rm)) = best {
2220 let matched_child = &children[idx];
2221 let comp = &matched_child.fragment_id;
2222
2223 if !comp.is_empty() {
2224 let saved_route_base = context.route_base.clone();
2225 let saved_route_children = std::mem::take(&mut context.route_children);
2226
2227 if rm.consumed_segments > 0 {
2228 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2229 context.request_path,
2230 rm.consumed_segments,
2231 ));
2232 }
2233
2234 context.route_children = grandchildren;
2235
2236 context.writer.write("<webui-route")?;
2238 context.writer.write(" path=\"")?;
2239 context.writer.write(&matched_child.path)?;
2240 context.writer.write("\"")?;
2241 context.writer.write(" component=\"")?;
2242 context.writer.write(comp)?;
2243 context.writer.write("\"")?;
2244 if matched_child.exact {
2245 context.writer.write(" exact")?;
2246 }
2247 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2248 let ri = context.route_chain_index;
2250 context.route_chain_index += 1;
2251 context.writer.write(" data-ri=\"")?;
2252 write_usize(context.writer, ri)?;
2253 context.writer.write("\" active>")?;
2254
2255 if !Self::component_owns_css_tree(comp, context.protocol) {
2256 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2257 }
2258 if !matched_child.content_fragment_id.is_empty() {
2259 self.process_fragment_target(
2260 None,
2261 &matched_child.content_fragment_id,
2262 context,
2263 )?;
2264 }
2265
2266 context.writer.write("<")?;
2267 context.writer.write(comp)?;
2268 if let Some(p) = &context.plugin {
2269 p.write_route_component_state(context.state, context.writer)?;
2270 }
2271 write_interaction_marker(comp, context)?;
2272 prepare_generated_streaming_root(comp, context)?;
2273 context.writer.write(">")?;
2274
2275 self.process_component(
2276 &webui_protocol::WebUIFragmentComponent {
2277 fragment_id: comp.clone(),
2278 },
2279 None,
2280 ComponentHostOrigin::HandlerGenerated,
2281 context,
2282 )?;
2283
2284 context.writer.write("</")?;
2285 context.writer.write(comp)?;
2286 context.writer.write(">")?;
2287 context.writer.write("</webui-route>")?;
2288
2289 context.route_base = saved_route_base;
2290 context.route_children = saved_route_children;
2291 }
2292 }
2293
2294 for (idx, child) in children.iter().enumerate() {
2296 let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2297 if !is_matched && !child.fragment_id.is_empty() {
2298 context.writer.write("<webui-route")?;
2299 context.writer.write(" path=\"")?;
2300 context.writer.write(&child.path)?;
2301 context.writer.write("\"")?;
2302 context.writer.write(" component=\"")?;
2303 context.writer.write(&child.fragment_id)?;
2304 context.writer.write("\"")?;
2305 if child.exact {
2306 context.writer.write(" exact")?;
2307 }
2308 route_renderer::write_route_navigation_attrs(context.writer, child)?;
2309 context
2310 .writer
2311 .write(" style=\"display:none\"></webui-route>")?;
2312 }
2313 }
2314
2315 Ok(())
2316 }
2317
2318 fn emit_css_module_importmap(
2329 &self,
2330 specifier: &str,
2331 css: &str,
2332 context: &mut WebUIProcessContext,
2333 ) -> Result<()> {
2334 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2335 context.writer.write(&tag)?;
2336 Ok(())
2337 }
2338
2339 fn emit_component_style_closure(
2341 &self,
2342 root: &str,
2343 install: StyleClosureInstall,
2344 context: &mut WebUIProcessContext,
2345 ) -> Result<()> {
2346 if context.protocol.style_closures.is_empty() {
2347 return Ok(());
2348 }
2349 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2350 match context.shadow_style_roots.last() {
2351 Some(shadow_root) => {
2352 if !shadow_root.static_closure_emitted {
2353 return Err(HandlerError::Invariant(
2354 "routed styles reached a ShadowRoot before its static style hook"
2355 .to_string(),
2356 ));
2357 }
2358 let root_name = context
2359 .component_index
2360 .iter()
2361 .find_map(|(name, index)| {
2362 (*index == shadow_root.component_index).then_some(name.as_str())
2363 })
2364 .ok_or_else(|| {
2365 HandlerError::Invariant(
2366 "active Shadow style root lost its protocol index".to_string(),
2367 )
2368 })?;
2369 Some(
2370 context
2371 .protocol
2372 .style_closures
2373 .get(root_name)
2374 .ok_or_else(|| {
2375 HandlerError::Invariant(format!(
2376 "component style closure metadata is missing Shadow root `{root_name}`"
2377 ))
2378 })?,
2379 )
2380 }
2381 None => None,
2382 }
2383 } else {
2384 None
2385 };
2386 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2387 HandlerError::Invariant(format!(
2388 "component style closure metadata is missing root `{root}`"
2389 ))
2390 })?;
2391 let is_document_tree = context.shadow_style_roots.is_empty();
2392 let strategy = context.css_strategy;
2393
2394 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2399 let mut emitted_resources = HashSet::with_capacity(unit_count);
2400
2401 for position in 0..unit_count {
2402 let unit = context
2403 .protocol
2404 .style_closure_unit(closure, &context.style_chunk_index, position)
2405 .ok_or_else(|| {
2406 HandlerError::Invariant(format!(
2407 "component style closure `{root}` references out-of-range unit {position}"
2408 ))
2409 })?;
2410 let (name, chunk) = (unit.name, unit.chunk);
2411 if !emitted_resources.insert(name) {
2412 continue;
2413 }
2414 let resource = unit.resource.ok_or_else(|| match chunk {
2415 Some(index) => HandlerError::Invariant(format!(
2416 "component style closure `{root}` references missing style chunk {index}"
2417 )),
2418 None => HandlerError::Invariant(format!(
2419 "component style closure `{root}` references missing resource `{name}`"
2420 )),
2421 })?;
2422 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2423 continue;
2424 }
2425 if let Some(static_closure) = shadow_static_closure {
2426 let resource_index = match chunk {
2427 Some(index) => {
2428 if static_closure.style_chunks.contains(&index) {
2429 continue;
2430 }
2431 index
2432 }
2433 None => {
2434 if static_closure.component_tags.iter().any(|tag| tag == name) {
2435 continue;
2436 }
2437 context.component_index.get(name).copied().ok_or_else(|| {
2438 HandlerError::Invariant(format!(
2439 "component style resource `{name}` is missing its protocol index"
2440 ))
2441 })?
2442 }
2443 };
2444 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2445 HandlerError::Invariant(
2446 "active Shadow style root disappeared during routed style delivery"
2447 .to_string(),
2448 )
2449 })?;
2450 if shadow_root.routed_resources.contains(&resource_index) {
2451 continue;
2452 }
2453 shadow_root.routed_resources.push(resource_index);
2454 }
2455
2456 match strategy {
2457 webui_protocol::CssStrategy::Link => {
2458 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2459 context
2460 .writer
2461 .write(&crate::html_encode::encode_safe(resource))?;
2462 context.writer.write("\" data-webui-resource=\"")?;
2463 context
2464 .writer
2465 .write(&crate::html_encode::encode_safe(name))?;
2466 context.writer.write("\" data-webui-strategy=\"link\">")?;
2467 }
2468 strategy => {
2469 context.writer.write("<style")?;
2470 if let Some(nonce) = context.nonce {
2471 context.writer.write(" nonce=\"")?;
2472 context
2473 .writer
2474 .write(&crate::html_encode::encode_safe(nonce))?;
2475 context.writer.write("\"")?;
2476 }
2477 context.writer.write(" data-webui-resource=\"")?;
2478 context
2479 .writer
2480 .write(&crate::html_encode::encode_safe(name))?;
2481 context.writer.write("\" data-webui-strategy=\"")?;
2482 context
2483 .writer
2484 .write(if strategy == webui_protocol::CssStrategy::Module {
2485 "module"
2486 } else {
2487 "style"
2488 })?;
2489 context.writer.write("\">")?;
2490 crate::html_encode::write_style_text(context.writer, resource)?;
2491 context.writer.write("</style>")?;
2492 }
2493 }
2494 }
2495 Ok(())
2496 }
2497
2498 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2499 if context.route_chain.is_none() {
2500 let plan = crate::route_handler::collect_route_chain_plan(
2501 context.protocol,
2502 context.entry_id,
2503 context.request_path,
2504 context.route_index,
2505 );
2506 context.route_document_style_targets = plan.document_style_targets;
2507 context.route_chain = Some(plan.entries);
2508 }
2509 }
2510
2511 fn emit_active_route_styles(
2512 &self,
2513 preloaded: &mut Vec<u32>,
2514 context: &mut WebUIProcessContext,
2515 ) -> Result<()> {
2516 Self::ensure_request_route_chain(context);
2517 let Some(chain) = context.route_chain.take() else {
2518 return Err(route_style_plan_missing_error());
2519 };
2520 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2521 if chain.len() != document_targets.len() {
2522 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2523 context.route_document_style_targets = document_targets;
2524 context.route_chain = Some(chain);
2525 return Err(error);
2526 }
2527
2528 let result = (|| {
2529 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2530 if *targets_document {
2531 self.emit_component_style_closure(
2532 &entry.component,
2533 StyleClosureInstall::Static,
2534 context,
2535 )?;
2536 continue;
2537 }
2538
2539 if context.css_strategy != webui_protocol::CssStrategy::Link {
2540 continue;
2541 }
2542 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2543 }
2544 Ok(())
2545 })();
2546
2547 context.route_document_style_targets = document_targets;
2548 context.route_chain = Some(chain);
2549 result
2550 }
2551
2552 fn emit_component_style_preloads(
2553 &self,
2554 root: &str,
2555 preloaded: &mut Vec<u32>,
2556 context: &mut WebUIProcessContext,
2557 ) -> Result<()> {
2558 let Some(closure) = context.protocol.style_closures.get(root) else {
2559 return Ok(());
2560 };
2561 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2563 for position in 0..unit_count {
2564 let Some(unit) =
2565 context
2566 .protocol
2567 .style_closure_unit(closure, &context.style_chunk_index, position)
2568 else {
2569 continue;
2570 };
2571 let Some(href) = unit.resource else {
2572 continue;
2573 };
2574 let name = unit.name;
2575 if context.document_style_resources.contains(name) {
2576 continue;
2577 }
2578 let Some(&resource_index) = context.style_resource_index.get(name) else {
2579 continue;
2580 };
2581 if preloaded.contains(&resource_index) {
2582 continue;
2583 }
2584 context
2585 .writer
2586 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2587 context
2588 .writer
2589 .write(&crate::html_encode::encode_safe(href))?;
2590 context.writer.write("\">")?;
2591 preloaded.push(resource_index);
2592 }
2593 Ok(())
2594 }
2595
2596 fn emit_reachable_shadow_preloads(
2597 &self,
2598 preloaded: &mut Vec<u32>,
2599 context: &mut WebUIProcessContext,
2600 ) -> Result<()> {
2601 if context.css_strategy != webui_protocol::CssStrategy::Link {
2602 return Ok(());
2603 }
2604 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2605 crate::route_handler::collect_reachable_component_order_for_request(
2606 context.protocol,
2607 context.entry_id,
2608 context.request_path,
2609 context.route_index,
2610 )
2611 });
2612 let result = (|| {
2613 for component in &reachable {
2614 if context.protocol.component_uses_shadow_dom(component) {
2615 self.emit_component_style_preloads(component, preloaded, context)?;
2616 }
2617 }
2618 Ok(())
2619 })();
2620 context.reachable_components = Some(reachable);
2621 result
2622 }
2623
2624 fn process_shadow_style_signal(
2625 &self,
2626 root: &str,
2627 context: &mut WebUIProcessContext,
2628 ) -> Result<()> {
2629 let Some(root_index) = context.component_index.get(root).copied() else {
2630 return Err(HandlerError::Invariant(format!(
2631 "Shadow style hook `{root}` references an unknown component"
2632 )));
2633 };
2634 if context
2635 .shadow_style_roots
2636 .last()
2637 .map(|shadow_root| shadow_root.component_index)
2638 != Some(root_index)
2639 {
2640 return Err(HandlerError::Invariant(format!(
2641 "Shadow style hook `{root}` does not match the active component root"
2642 )));
2643 }
2644 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2645 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2646 HandlerError::Invariant(
2647 "active Shadow style root disappeared after its static style hook".to_string(),
2648 )
2649 })?;
2650 shadow_root.static_closure_emitted = true;
2651 Ok(())
2652 }
2653
2654 fn emit_css_module(
2663 &self,
2664 component: &webui_protocol::WebUIFragmentComponent,
2665 context: &mut WebUIProcessContext,
2666 ) -> Result<()> {
2667 if context.css_strategy != webui_protocol::CssStrategy::Module {
2668 return Ok(());
2669 }
2670 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2671 streaming_template_already_sent(
2672 streaming,
2673 context.component_index,
2674 &component.fragment_id,
2675 )
2676 });
2677 if !metadata_already_streamed
2678 && !context.rendered_components.contains(&component.fragment_id)
2679 {
2680 if let Some(css) = context
2681 .protocol
2682 .components
2683 .get(&component.fragment_id)
2684 .map(|c| c.css.as_str())
2685 .filter(|s| !s.is_empty())
2686 {
2687 self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2688 }
2689 }
2690 Ok(())
2691 }
2692
2693 fn process_route(
2695 &self,
2696 route_frag: &webui_protocol::WebUiFragmentRoute,
2697 best_route: &Option<(String, route_matcher::RouteMatch)>,
2698 context: &mut WebUIProcessContext,
2699 ) -> Result<()> {
2700 let is_matched = best_route
2701 .as_ref()
2702 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2703
2704 context.writer.write("<webui-route")?;
2705 context.writer.write(" path=\"")?;
2706 context.writer.write(&route_frag.path)?;
2707 context.writer.write("\"")?;
2708 if !route_frag.fragment_id.is_empty() {
2709 context.writer.write(" component=\"")?;
2710 context.writer.write(&route_frag.fragment_id)?;
2711 context.writer.write("\"")?;
2712 }
2713 if route_frag.exact {
2714 context.writer.write(" exact")?;
2715 }
2716 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2717
2718 if is_matched {
2719 let ri = context.route_chain_index;
2721 context.route_chain_index += 1;
2722 context.writer.write(" data-ri=\"")?;
2723 write_usize(context.writer, ri)?;
2724 context.writer.write("\" active>")?;
2725
2726 let saved_route_base = context.route_base.clone();
2727 let saved_route_children = std::mem::take(&mut context.route_children);
2728 if let Some((_, ref rm)) = best_route {
2729 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2730 context.request_path,
2731 rm.consumed_segments,
2732 ));
2733 }
2734 context.route_children = route_frag.children.clone();
2735
2736 if !route_frag.content_fragment_id.is_empty() {
2737 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2738 }
2739
2740 if !route_frag.fragment_id.is_empty() {
2741 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2742 self.emit_component_style_closure(
2743 &route_frag.fragment_id,
2744 StyleClosureInstall::Routed,
2745 context,
2746 )?;
2747 }
2748 let comp = webui_protocol::WebUIFragmentComponent {
2749 fragment_id: route_frag.fragment_id.clone(),
2750 };
2751
2752 context.writer.write("<")?;
2753 context.writer.write(&route_frag.fragment_id)?;
2754 if let Some(p) = &context.plugin {
2755 p.write_route_component_state(context.state, context.writer)?;
2756 }
2757 write_interaction_marker(&route_frag.fragment_id, context)?;
2758 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2759 context.writer.write(">")?;
2760
2761 self.process_component(
2762 &comp,
2763 None,
2764 ComponentHostOrigin::HandlerGenerated,
2765 context,
2766 )?;
2767
2768 context.writer.write("</")?;
2769 context.writer.write(&route_frag.fragment_id)?;
2770 context.writer.write(">")?;
2771 }
2772 context.route_base = saved_route_base;
2773 context.route_children = saved_route_children;
2774 } else {
2775 context.writer.write(" style=\"display:none\">")?;
2776 }
2777
2778 context.writer.write("</webui-route>")?;
2779 Ok(())
2780 }
2781
2782 fn process_component(
2784 &self,
2785 component: &webui_protocol::WebUIFragmentComponent,
2786 target: Option<usize>,
2787 origin: ComponentHostOrigin,
2788 context: &mut WebUIProcessContext,
2789 ) -> Result<()> {
2790 if context.streaming.is_some() {
2791 consume_streaming_component_root(&component.fragment_id, origin, context)?;
2792 record_checkpoint_tag(context, &component.fragment_id);
2795 }
2796
2797 if !context.rendered_components.contains(&component.fragment_id) {
2803 self.emit_css_module(component, context)?;
2804 context
2805 .rendered_components
2806 .insert(component.fragment_id.clone());
2807 }
2808
2809 let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2810 if owns_css_tree {
2811 Self::push_shadow_style_root(&component.fragment_id, context)?;
2812 }
2813
2814 let saved_local_vars = std::mem::take(&mut context.local_vars);
2816 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2817 let saved_component_attrs = std::mem::replace(
2821 &mut context.component_attrs,
2822 take_scope_map(&mut context.scope_pool),
2823 );
2824 let saved_component_borrowed_attrs = std::mem::replace(
2825 &mut context.component_borrowed_attrs,
2826 take_borrowed_scope(&mut context.borrowed_scope_pool),
2827 );
2828 context.local_vars = saved_component_attrs;
2829 context.local_borrowed_vars = saved_component_borrowed_attrs;
2830 context.collecting_component_attrs = false;
2831 let saved_loop_scope = context.visible_loop_scope;
2832 context.visible_loop_scope = VisibleLoopScope {
2833 start: context.loop_vars.len(),
2834 end: context.loop_vars.len(),
2835 };
2836
2837 if let Some(p) = &mut context.plugin {
2838 p.push_scope();
2839 }
2840
2841 let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2842 context.visible_loop_scope = saved_loop_scope;
2843
2844 if owns_css_tree {
2845 Self::pop_shadow_style_root(&component.fragment_id, context)?;
2846 }
2847 render_result?;
2848
2849 if let Some(p) = &mut context.plugin {
2850 p.pop_scope();
2851 }
2852
2853 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2856 recycle_scope_map(&mut context.scope_pool, used_locals);
2857 let used_borrowed_locals =
2858 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2859 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2860 context.component_attrs.clear();
2863 context.component_borrowed_attrs.clear();
2864
2865 Ok(())
2866 }
2867
2868 #[inline]
2869 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2870 !protocol.style_closures.is_empty()
2871 && protocol
2872 .components
2873 .get(component)
2874 .is_some_and(|data| data.uses_shadow_dom)
2875 }
2876
2877 pub(crate) fn push_shadow_style_root(
2878 component: &str,
2879 context: &mut WebUIProcessContext,
2880 ) -> Result<()> {
2881 if !context.protocol.style_closures.contains_key(component) {
2882 return Err(HandlerError::Invariant(format!(
2883 "component style closure metadata is missing Shadow root `{component}`"
2884 )));
2885 }
2886 let root_index = context
2887 .component_index
2888 .get(component)
2889 .copied()
2890 .ok_or_else(|| {
2891 HandlerError::Invariant(format!(
2892 "Shadow component `{component}` is missing its protocol index"
2893 ))
2894 })?;
2895 context.shadow_style_roots.push(ShadowStyleRoot {
2896 component_index: root_index,
2897 static_closure_emitted: false,
2898 routed_resources: Vec::new(),
2899 });
2900 Ok(())
2901 }
2902
2903 pub(crate) fn pop_shadow_style_root(
2904 component: &str,
2905 context: &mut WebUIProcessContext,
2906 ) -> Result<()> {
2907 context.shadow_style_roots.pop().ok_or_else(|| {
2908 HandlerError::Invariant(format!(
2909 "Shadow component `{component}` lost its active style root"
2910 ))
2911 })?;
2912 Ok(())
2913 }
2914
2915 fn resolve_value_owned(
2917 &self,
2918 path: &str,
2919 context: &WebUIProcessContext<'_, '_, '_>,
2920 ) -> Option<Value> {
2921 resolve_value_from_sources(
2922 path,
2923 &context.loop_vars,
2924 context.visible_loop_scope,
2925 LocalValueSources {
2926 owned: &context.local_vars,
2927 borrowed: &context.local_borrowed_vars,
2928 },
2929 context.state,
2930 )
2931 .map(Cow::into_owned)
2932 }
2933
2934 fn evaluate_condition(
2941 &self,
2942 condition: &webui_protocol::ConditionExpr,
2943 context: &WebUIProcessContext,
2944 ) -> Result<bool> {
2945 let loop_vars = &context.loop_vars;
2946 let visible_loop_scope = context.visible_loop_scope;
2947 let local_values = LocalValueSources {
2948 owned: &context.local_vars,
2949 borrowed: &context.local_borrowed_vars,
2950 };
2951 let state = context.state;
2952 match evaluate_with_resolver(condition, |path| {
2953 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
2954 }) {
2955 Ok(result) => Ok(result),
2956 Err(ExpressionError::MissingValue(_)) => Ok(false),
2957 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
2958 }
2959 }
2960
2961 fn process_for_loop<'protocol, 'state>(
2967 &self,
2968 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2969 target: Option<usize>,
2970 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2971 ) -> Result<()> {
2972 if let Some(items) = resolve_borrowed_collection(
2973 &for_loop.collection,
2974 &context.loop_vars,
2975 context.visible_loop_scope,
2976 LocalValueSources {
2977 owned: &context.local_vars,
2978 borrowed: &context.local_borrowed_vars,
2979 },
2980 context.state,
2981 ) {
2982 return self.process_borrowed_for_loop(for_loop, target, items, context);
2983 }
2984 self.process_owned_for_loop(for_loop, target, context)
2985 }
2986
2987 fn process_borrowed_for_loop<'protocol, 'state>(
2988 &self,
2989 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2990 target: Option<usize>,
2991 items: &'state [Value],
2992 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2993 ) -> Result<()> {
2994 if let Some(plugin) = &mut context.plugin {
2995 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
2996 }
2997
2998 let item_name = for_loop.item.as_str();
2999 let saved_value = context.local_vars.remove(item_name);
3000 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3001 let saved_scope = context.visible_loop_scope;
3002 for (index, item) in items.iter().enumerate() {
3003 if let Some(plugin) = &mut context.plugin {
3004 plugin.on_repeat_item_start(index, context.writer)?;
3005 plugin.push_scope();
3006 }
3007
3008 context.loop_vars.push(LoopBinding {
3009 name: item_name,
3010 value: item,
3011 });
3012 context.visible_loop_scope.end = context.loop_vars.len();
3013 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3014 context.loop_vars.pop();
3015 context.visible_loop_scope = saved_scope;
3016
3017 if let Some(plugin) = &mut context.plugin {
3018 plugin.pop_scope();
3019 plugin.on_repeat_item_end(index, context.writer)?;
3020 }
3021 }
3022 if let Some(value) = saved_value {
3023 context.local_vars.insert(item_name.to_string(), value);
3024 }
3025 if let Some(value) = saved_borrowed_value {
3026 context.local_borrowed_vars.insert(item_name, value);
3027 }
3028
3029 if let Some(plugin) = &mut context.plugin {
3030 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3031 }
3032 Ok(())
3033 }
3034
3035 fn process_owned_for_loop<'protocol, 'state>(
3036 &self,
3037 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3038 target: Option<usize>,
3039 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3040 ) -> Result<()> {
3041 let collection_name = &for_loop.collection;
3042
3043 let items = match self.resolve_value_owned(collection_name, context) {
3046 Some(Value::Array(arr)) => arr,
3047 Some(_) => {
3048 return Err(HandlerError::TypeError(format!(
3049 "Collection '{}' is not an array",
3050 collection_name
3051 )))
3052 }
3053 None => Vec::new(),
3054 };
3055
3056 if let Some(p) = &mut context.plugin {
3057 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3058 }
3059
3060 let item_name = for_loop.item.as_str();
3068 let saved_value = context.local_vars.remove(item_name);
3069 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3070 if !items.is_empty() {
3074 context
3075 .local_vars
3076 .insert(item_name.to_string(), Value::Null);
3077 }
3078 for (i, item) in items.into_iter().enumerate() {
3079 if let Some(p) = &mut context.plugin {
3080 p.on_repeat_item_start(i, context.writer)?;
3081 p.push_scope();
3082 }
3083
3084 if let Some(slot) = context.local_vars.get_mut(item_name) {
3086 *slot = item;
3087 }
3088 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3089
3090 if let Some(p) = &mut context.plugin {
3091 p.pop_scope();
3092 p.on_repeat_item_end(i, context.writer)?;
3093 }
3094 }
3095 match saved_value {
3097 Some(value) => {
3098 context.local_vars.insert(item_name.to_string(), value);
3099 }
3100 None => {
3101 context.local_vars.remove(item_name);
3102 }
3103 }
3104 if let Some(value) = saved_borrowed_value {
3105 context.local_borrowed_vars.insert(item_name, value);
3106 }
3107
3108 if let Some(p) = &mut context.plugin {
3109 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3110 }
3111
3112 Ok(())
3113 }
3114
3115 #[inline]
3121 fn process_signal<'data>(
3122 &self,
3123 signal: &'data webui_protocol::WebUIFragmentSignal,
3124 context: &mut WebUIProcessContext<'data, '_, '_>,
3125 ) -> Result<()> {
3126 if signal.raw {
3127 self.process_raw_signal(signal, context)
3128 } else {
3129 self.process_state_signal(signal, false, context)
3130 }
3131 }
3132
3133 #[inline(never)]
3134 fn process_raw_signal<'data>(
3135 &self,
3136 signal: &'data webui_protocol::WebUIFragmentSignal,
3137 context: &mut WebUIProcessContext<'data, '_, '_>,
3138 ) -> Result<()> {
3139 let Some(structural_value) = structural_signal_value(signal) else {
3140 return self.process_state_signal(signal, !signal.raw_text_context, context);
3141 };
3142
3143 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3144 return self.process_shadow_style_signal(root, context);
3145 }
3146
3147 if context.streaming.is_some()
3148 && self.process_streaming_signal(structural_value, context)?
3149 {
3150 return Ok(());
3151 }
3152
3153 if structural_value == "head_end" && !context.head_end_emitted {
3157 context.head_end_emitted = true;
3158 if let Some(nonce) = context.nonce {
3159 context
3160 .writer
3161 .write("<meta name=\"webui-nonce\" content=\"")?;
3162 context
3163 .writer
3164 .write(&crate::html_encode::encode_safe(nonce))?;
3165 context.writer.write("\">")?;
3166 }
3167
3168 if !context.protocol.component_render_css.is_empty() {
3171 context.writer.write("<style data-webui-render-policy")?;
3172 if let Some(nonce) = context.nonce {
3173 context.writer.write(" nonce=\"")?;
3174 context
3175 .writer
3176 .write(&crate::html_encode::encode_safe(nonce))?;
3177 context.writer.write("\"")?;
3178 }
3179 context.writer.write(">")?;
3180 context
3181 .writer
3182 .write(&context.protocol.component_render_css)?;
3183 context.writer.write("</style>")?;
3184 }
3185
3186 if !context.protocol.style_closures.is_empty() {
3187 let entry_id = context.entry_id;
3188 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3189 let mut preloaded = Vec::new();
3190 self.emit_active_route_styles(&mut preloaded, context)?;
3191 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3192 }
3193
3194 if !context.protocol.module_preloads.is_empty() {
3206 for href in &context.protocol.module_preloads {
3207 context
3208 .writer
3209 .write("<link rel=\"modulepreload\" href=\"")?;
3210 context.writer.write(href)?;
3211 context.writer.write("\">")?;
3212 }
3213 }
3214
3215 write_component_asset_styles(context)?;
3216
3217 if let Some(html) = context.head_inject {
3223 context.writer.write(html)?;
3224 }
3225
3226 if let Some(html) = context.state_inject.head_end {
3230 context.writer.write(html)?;
3231 }
3232 }
3233
3234 if structural_value == "body_start" && !context.body_start_emitted {
3237 context.body_start_emitted = true;
3238 write_component_asset_styles(context)?;
3239 if let Some(html) = context.state_inject.body_start {
3240 context.writer.write(html)?;
3241 }
3242 }
3243
3244 if structural_value == "body_end" && !context.body_end_emitted {
3249 context.body_end_emitted = true;
3250 if context.plugin.is_some() {
3251 let reachable = context
3259 .reachable_components
3260 .take()
3261 .unwrap_or_else(|| {
3262 crate::route_handler::collect_reachable_component_order_for_request(
3263 context.protocol,
3264 context.entry_id,
3265 context.request_path,
3266 context.route_index,
3267 )
3268 })
3269 .into_iter()
3270 .collect::<HashSet<_>>();
3271 let state_selection =
3272 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3273
3274 if context.css_strategy == webui_protocol::CssStrategy::Module {
3278 for name in &reachable {
3279 if !context.rendered_components.contains(name) {
3280 if let Some(css) = context
3281 .protocol
3282 .components
3283 .get(name)
3284 .map(|c| c.css.as_str())
3285 .filter(|s| !s.is_empty())
3286 {
3287 self.emit_css_module_importmap(name, css, context)?;
3288 }
3289 }
3290 }
3291 }
3292
3293 let template_payloads = context
3297 .plugin
3298 .as_ref()
3299 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3300
3301 if template_payloads.is_none() {
3302 if let Some(ref p) = context.plugin {
3304 p.emit_templates(
3305 context.protocol,
3306 &reachable,
3307 context.nonce,
3308 context.writer,
3309 )?;
3310 }
3311 }
3312
3313 let inventory_hex = crate::route_handler::encode_component_inventory(
3315 &context.rendered_components,
3316 context.component_index,
3317 );
3318
3319 Self::ensure_request_route_chain(context);
3321 let chain = context.route_chain.as_deref().ok_or_else(|| {
3322 HandlerError::Invariant(
3323 "request route chain disappeared after collection".to_string(),
3324 )
3325 })?;
3326 let chain_json: Vec<Value> = chain
3327 .iter()
3328 .map(crate::route_handler::RouteChainEntry::to_json)
3329 .collect();
3330
3331 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3333 let mut css_hrefs: Vec<&str> = Vec::new();
3334 if is_link {
3335 for name in &reachable {
3336 if let Some(href) = context
3337 .protocol
3338 .components
3339 .get(name)
3340 .map(|c| c.css_href.as_str())
3341 .filter(|h| !h.is_empty())
3342 {
3343 css_hrefs.push(href);
3344 }
3345 }
3346 }
3347
3348 let mut style_specs: Vec<&str> = Vec::new();
3350 if context.css_strategy == webui_protocol::CssStrategy::Module {
3351 for name in &reachable {
3352 if context
3353 .protocol
3354 .components
3355 .get(name)
3356 .map(|c| !c.css.is_empty())
3357 .unwrap_or(false)
3358 {
3359 style_specs.push(name);
3360 }
3361 }
3362 }
3363
3364 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3365 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3366 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3367 style_roots.push(context.entry_id);
3368 for entry in chain {
3369 style_roots.push(entry.component.as_str());
3370 if !entry.pending_component.is_empty() {
3371 style_roots.push(entry.pending_component.as_str());
3372 }
3373 if !entry.error_component.is_empty() {
3374 style_roots.push(entry.error_component.as_str());
3375 }
3376 }
3377 style_roots.extend(reachable.iter().map(String::as_str));
3378 let component_styles =
3379 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3380 write_webui_data_block(
3381 context.writer,
3382 &mut context.json_scratch,
3383 WebUiBootstrap {
3384 declaration_id: None,
3385 enclosing_span_instance_id: None,
3386 state: context.state,
3387 state_selection,
3388 chain: &chain_json,
3389 inventory: &inventory_hex,
3390 nonce: context.nonce,
3391 css_hrefs: &css_hrefs,
3392 style_specs: &style_specs,
3393 component_styles: &component_styles,
3394 templates: payloads,
3395 },
3396 )?;
3397
3398 if let Some(ref plugin) = context.plugin {
3402 plugin.emit_bootstrap_extension(
3403 BootstrapExtensionContext {
3404 protocol: context.protocol,
3405 components: &reachable,
3406 payloads,
3407 nonce: context.nonce,
3408 },
3409 context.writer,
3410 )?;
3411 }
3412 }
3413
3414 if let Some(html) = context.body_inject {
3421 context.writer.write(html)?;
3422 }
3423
3424 if let Some(html) = context.state_inject.body_end {
3427 context.writer.write(html)?;
3428 }
3429 }
3430
3431 Ok(())
3435 }
3436
3437 #[inline]
3449 fn process_state_signal(
3450 &self,
3451 signal: &webui_protocol::WebUIFragmentSignal,
3452 owns_html_range: bool,
3453 context: &mut WebUIProcessContext,
3454 ) -> Result<()> {
3455 if let Some(p) = &mut context.plugin {
3456 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3457 }
3458
3459 if let Some(value) = resolve_value_from_sources(
3460 &signal.value,
3461 &context.loop_vars,
3462 context.visible_loop_scope,
3463 LocalValueSources {
3464 owned: &context.local_vars,
3465 borrowed: &context.local_borrowed_vars,
3466 },
3467 context.state,
3468 ) {
3469 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3470 }
3471
3472 if let Some(p) = &mut context.plugin {
3473 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3474 }
3475 Ok(())
3476 }
3477
3478 fn write_signal_value(
3487 &self,
3488 value: &Value,
3489 raw: bool,
3490 writer: &mut dyn ResponseWriter,
3491 ) -> Result<()> {
3492 if raw {
3493 match value {
3494 Value::String(s) => writer.write(s),
3495 _ => writer.write(&value.to_string()),
3496 }
3497 } else {
3498 match value {
3499 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3500 _ => {
3501 let s = value.to_string();
3502 writer.write(&crate::html_encode::encode_safe(&s))
3503 }
3504 }
3505 }
3506 }
3507
3508 fn process_if(
3510 &self,
3511 if_cond: &webui_protocol::WebUIFragmentIf,
3512 target: Option<usize>,
3513 context: &mut WebUIProcessContext,
3514 ) -> Result<()> {
3515 let condition = if_cond
3516 .condition
3517 .as_ref()
3518 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3519 let condition_met = self.evaluate_condition(condition, context)?;
3520
3521 if let Some(p) = &mut context.plugin {
3522 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3523 }
3524
3525 if condition_met {
3526 if let Some(p) = &mut context.plugin {
3527 p.push_scope();
3528 }
3529
3530 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3531
3532 if let Some(p) = &mut context.plugin {
3533 p.pop_scope();
3534 }
3535 }
3536
3537 if let Some(p) = &mut context.plugin {
3538 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3539 }
3540
3541 Ok(())
3542 }
3543
3544 fn process_attribute<'protocol, 'state>(
3550 &self,
3551 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3552 template_target: Option<usize>,
3553 component_name: Option<&'protocol str>,
3554 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3555 ) -> Result<()> {
3556 if attr.attr_start {
3559 context.component_attrs.clear();
3560 context.component_borrowed_attrs.clear();
3561 context.collecting_component_attrs = true;
3562 }
3563
3564 if let Some(condition) = &attr.condition_tree {
3566 let condition_met = self.evaluate_condition(condition, context)?;
3567
3568 if context.collecting_component_attrs && !attr.attr_skip {
3569 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3570 context.component_borrowed_attrs.remove(name);
3571 context
3572 .component_attrs
3573 .insert(name.to_owned(), Value::Bool(condition_met));
3574 }
3575
3576 if condition_met {
3577 context.writer.write(" ")?;
3578 context.writer.write(&attr.name)?;
3579 }
3580 return Ok(());
3581 }
3582
3583 if !attr.template.is_empty() {
3585 let raw_value =
3586 self.render_template_attr_value(&attr.template, template_target, context)?;
3587 let escaped = crate::html_encode::encode_safe(&raw_value);
3588 write_attr(context.writer, &attr.name, &escaped)?;
3589
3590 if context.collecting_component_attrs && !attr.attr_skip {
3591 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3592 context.component_borrowed_attrs.remove(name);
3593 context
3594 .component_attrs
3595 .insert(name.to_owned(), Value::String(raw_value));
3596 }
3597 return Ok(());
3598 }
3599
3600 if !attr.value.is_empty() {
3602 if attr.raw_value {
3603 write_attr(context.writer, &attr.name, &attr.value)?;
3605 if context.collecting_component_attrs && !attr.attr_skip {
3606 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3607 context.component_borrowed_attrs.remove(name);
3608 context
3609 .component_attrs
3610 .insert(name.to_owned(), Value::String(attr.value.clone()));
3611 }
3612 } else if attr.complex {
3613 if context.collecting_component_attrs && !attr.attr_skip {
3615 let state_backed_value = if context.streaming.is_none() {
3616 resolve_state_backed_value(
3617 &attr.value,
3618 &context.loop_vars,
3619 context.visible_loop_scope,
3620 LocalValueSources {
3621 owned: &context.local_vars,
3622 borrowed: &context.local_borrowed_vars,
3623 },
3624 context.state,
3625 )
3626 } else {
3627 None
3628 };
3629 if let Some(value) = state_backed_value {
3630 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3631 context.component_attrs.remove(name);
3632 context.component_borrowed_attrs.insert(name, value);
3633 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3634 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3635 context.component_borrowed_attrs.remove(name);
3636 context.component_attrs.insert(name.to_owned(), value);
3637 }
3638 }
3639 } else {
3640 let state_backed_value = if context.collecting_component_attrs
3645 && !attr.attr_skip
3646 && context.streaming.is_none()
3647 {
3648 resolve_state_backed_value(
3649 &attr.value,
3650 &context.loop_vars,
3651 context.visible_loop_scope,
3652 LocalValueSources {
3653 owned: &context.local_vars,
3654 borrowed: &context.local_borrowed_vars,
3655 },
3656 context.state,
3657 )
3658 } else {
3659 None
3660 };
3661 let value = resolve_value_from_sources(
3662 &attr.value,
3663 &context.loop_vars,
3664 context.visible_loop_scope,
3665 LocalValueSources {
3666 owned: &context.local_vars,
3667 borrowed: &context.local_borrowed_vars,
3668 },
3669 context.state,
3670 );
3671 match value.as_deref() {
3674 Some(Value::String(s)) => {
3675 write_attr(
3676 context.writer,
3677 &attr.name,
3678 &crate::html_encode::encode_safe(s),
3679 )?;
3680 }
3681 Some(Value::Null) | None => {
3682 write_attr(context.writer, &attr.name, "")?;
3683 }
3684 Some(other) => {
3685 let s = other.to_string();
3686 write_attr(
3687 context.writer,
3688 &attr.name,
3689 &crate::html_encode::encode_safe(&s),
3690 )?;
3691 }
3692 }
3693
3694 if context.collecting_component_attrs && !attr.attr_skip {
3695 if let Some(borrowed) = state_backed_value {
3696 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3697 context.component_attrs.remove(name);
3698 context.component_borrowed_attrs.insert(name, borrowed);
3699 } else {
3700 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3701 context.component_borrowed_attrs.remove(name);
3702 context.component_attrs.insert(
3703 name.to_owned(),
3704 value
3705 .map(Cow::into_owned)
3706 .unwrap_or(Value::String(String::new())),
3707 );
3708 }
3709 }
3710 }
3711 }
3712
3713 Ok(())
3714 }
3715
3716 fn render_template_attr_value(
3718 &self,
3719 template_id: &str,
3720 target: Option<usize>,
3721 context: &WebUIProcessContext,
3722 ) -> Result<String> {
3723 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3724 return Err(HandlerError::MissingFragment(template_id.to_string()));
3725 };
3726 let fragments = context
3727 .render_fragments
3728 .list(index)
3729 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3730 let mut raw_value = String::new();
3731 for frag in fragments.fragments {
3732 match frag.fragment.as_ref() {
3733 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3734 Some(Fragment::Signal(signal)) => {
3735 if let Some(value) = resolve_value_from_sources(
3736 &signal.value,
3737 &context.loop_vars,
3738 context.visible_loop_scope,
3739 LocalValueSources {
3740 owned: &context.local_vars,
3741 borrowed: &context.local_borrowed_vars,
3742 },
3743 context.state,
3744 ) {
3745 match value.as_ref() {
3746 Value::String(s) => raw_value.push_str(s),
3747 _ => raw_value.push_str(&value.to_string()),
3748 }
3749 }
3750 }
3751 _ => {}
3752 }
3753 }
3754 Ok(raw_value)
3755 }
3756
3757 pub fn render<'a>(
3759 &self,
3760 protocol: &'a Protocol,
3761 state: &'a Value,
3762 options: &RenderOptions<'a>,
3763 writer: &'a mut dyn ResponseWriter,
3764 ) -> Result<()> {
3765 protocol.ensure_style_metadata()?;
3766 let document = protocol.protocol();
3767 let render_fragments = protocol.render_fragments().resolve(document);
3768 let Some(entry_index) = render_fragments.index(options.entry_id) else {
3769 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3770 };
3771 let entry = render_fragments
3772 .list(entry_index)
3773 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3774 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3775 let has_document_head_boundary = !entry_owns_css_tree
3776 && !document.style_closures.is_empty()
3777 && entry.fragments.iter().any(|fragment| {
3778 matches!(
3779 fragment.fragment.as_ref(),
3780 Some(Fragment::Signal(signal))
3781 if structural_signal_value(signal) == Some("head_end")
3782 )
3783 });
3784 let doctype_split = (!entry_owns_css_tree
3785 && !has_document_head_boundary
3786 && !document.style_closures.is_empty())
3787 .then(|| {
3788 let Some(Fragment::Raw(raw)) = entry
3789 .fragments
3790 .first()
3791 .and_then(|fragment| fragment.fragment.as_ref())
3792 else {
3793 return None;
3794 };
3795 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3796 })
3797 .flatten();
3798 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3799 let mut context = WebUIProcessContext {
3800 protocol: document,
3801 render_fragments,
3802 component_asset_style_manifest,
3803 component_asset_style_links: protocol.component_asset_style_links(),
3804 state,
3805 writer,
3806 local_vars: HashMap::new(),
3807 local_borrowed_vars: BorrowedScope::default(),
3808 loop_vars: Vec::new(),
3809 visible_loop_scope: VisibleLoopScope::EMPTY,
3810 component_attrs: HashMap::new(),
3811 component_borrowed_attrs: BorrowedScope::default(),
3812 collecting_component_attrs: false,
3813 request_path: options.request_path,
3814 route_base: Cow::Borrowed("/"),
3815 rendered_components: HashSet::new(),
3816 plugin: self.plugin_factory.map(|f| f()),
3817 route_children: Vec::new(),
3818 entry_id: options.entry_id,
3819 nonce: options.nonce.filter(|s| !s.is_empty()),
3822 head_inject: options.head_inject.filter(|s| !s.is_empty()),
3823 body_inject: options.body_inject.filter(|s| !s.is_empty()),
3824 state_inject: StateInject::resolve(state),
3825 head_end_emitted: false,
3826 body_start_emitted: false,
3827 component_asset_styles_emitted: false,
3828 component_index: protocol.component_index(),
3829 style_resource_index: protocol.style_resource_index(),
3830 style_chunk_index: protocol.protocol().style_chunk_index(),
3831 css_strategy: protocol.css_strategy(),
3832 body_end_emitted: false,
3833 route_index: protocol.route_index(),
3834 route_chain_index: 0,
3835 route_chain: None,
3836 route_document_style_targets: Vec::new(),
3837 reachable_components: None,
3838 streaming: None,
3839 json_scratch: Vec::new(),
3840 scope_pool: Vec::new(),
3841 document_style_resources: HashSet::new(),
3842 shadow_style_roots: Vec::new(),
3843 borrowed_scope_pool: Vec::new(),
3844 };
3845
3846 if entry_owns_css_tree {
3847 Self::push_shadow_style_root(options.entry_id, &mut context)?;
3848 }
3849
3850 let render_result = if let Some((first_raw, split)) = doctype_split {
3851 context.writer.write(&first_raw[..split])?;
3852 self.emit_component_style_closure(
3853 options.entry_id,
3854 StyleClosureInstall::Static,
3855 &mut context,
3856 )?;
3857 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3858 context.writer.write(&first_raw[split..])?;
3859 self.process_fragment_from(entry, 1, &mut context)
3860 } else {
3861 if !entry_owns_css_tree
3862 && !has_document_head_boundary
3863 && !document.style_closures.is_empty()
3864 {
3865 self.emit_component_style_closure(
3866 options.entry_id,
3867 StyleClosureInstall::Static,
3868 &mut context,
3869 )?;
3870 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3871 }
3872 self.process_fragment(entry, &mut context)
3873 };
3874
3875 if entry_owns_css_tree {
3876 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3877 }
3878 render_result?;
3879 writer.end()?;
3880
3881 Ok(())
3882 }
3883}
3884
3885impl Default for WebUIHandler {
3886 fn default() -> Self {
3887 Self::new()
3888 }
3889}
3890
3891fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3893 writer.write_attribute(name, value)
3894}
3895
3896#[cfg(test)]
3897fn handle(
3898 protocol: &WebUIProtocol,
3899 state: &Value,
3900 options: &RenderOptions<'_>,
3901 writer: &mut dyn ResponseWriter,
3902) -> Result<()> {
3903 let handler = WebUIHandler::new();
3904 handler.handle(protocol, state, options, writer)
3905}
3906
3907#[cfg(test)]
3908mod tests {
3909 use super::*;
3910 use crate::streaming::STREAMING_MARKER;
3911 use std::cell::RefCell;
3912 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
3913 use webui_protocol::{
3914 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
3915 WebUIFragmentAttribute, WebUiFragmentRoute,
3916 };
3917 use webui_test_utils::test_json;
3918
3919 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
3920 WebUIFragment::signal(
3921 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
3922 true,
3923 )
3924 }
3925
3926 #[test]
3927 fn borrowed_collection_reuses_state_array() {
3928 let state = test_json!({
3929 "items": [
3930 {"name": "first"},
3931 {"name": "second"}
3932 ]
3933 });
3934 let local_vars = HashMap::new();
3935 let local_borrowed_vars = BorrowedScope::default();
3936 let loop_vars = Vec::new();
3937 let items = resolve_borrowed_collection(
3938 "items",
3939 &loop_vars,
3940 VisibleLoopScope::EMPTY,
3941 LocalValueSources {
3942 owned: &local_vars,
3943 borrowed: &local_borrowed_vars,
3944 },
3945 &state,
3946 )
3947 .unwrap_or_else(|| panic!("state array should be borrowed"));
3948 let state_items = state["items"]
3949 .as_array()
3950 .unwrap_or_else(|| panic!("fixture items should be an array"));
3951 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3952 }
3953
3954 #[test]
3955 fn owned_local_collection_keeps_precedence() {
3956 let state = test_json!({"items": [{"name": "global"}]});
3957 let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
3958 let local_borrowed_vars = BorrowedScope::default();
3959 assert!(resolve_borrowed_collection(
3960 "items",
3961 &[],
3962 VisibleLoopScope::EMPTY,
3963 LocalValueSources {
3964 owned: &local_vars,
3965 borrowed: &local_borrowed_vars,
3966 },
3967 &state,
3968 )
3969 .is_none());
3970 }
3971
3972 #[test]
3973 fn borrowed_component_collection_reuses_state_array() {
3974 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
3975 let contacts = &state["teams"][0]["contacts"];
3976 let mut local_borrowed_vars = BorrowedScope::default();
3977 local_borrowed_vars.insert("contacts", contacts);
3978 let local_vars = HashMap::new();
3979 let items = resolve_borrowed_collection(
3980 "contacts",
3981 &[],
3982 VisibleLoopScope::EMPTY,
3983 LocalValueSources {
3984 owned: &local_vars,
3985 borrowed: &local_borrowed_vars,
3986 },
3987 &state,
3988 )
3989 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
3990 let state_items = contacts
3991 .as_array()
3992 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
3993 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3994 }
3995
3996 #[test]
3997 fn nested_borrowed_collection_reuses_child_array() {
3998 let state = test_json!({
3999 "items": [{
4000 "children": [
4001 {"name": "first"},
4002 {"name": "second"}
4003 ]
4004 }]
4005 });
4006 let item = &state["items"][0];
4007 let loop_vars = vec![LoopBinding {
4008 name: "item",
4009 value: item,
4010 }];
4011 let local_vars = HashMap::new();
4012 let local_borrowed_vars = BorrowedScope::default();
4013 let children = resolve_borrowed_collection(
4014 "item.children",
4015 &loop_vars,
4016 VisibleLoopScope { start: 0, end: 1 },
4017 LocalValueSources {
4018 owned: &local_vars,
4019 borrowed: &local_borrowed_vars,
4020 },
4021 &state,
4022 )
4023 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4024 let state_children = item["children"]
4025 .as_array()
4026 .unwrap_or_else(|| panic!("fixture children should be an array"));
4027 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4028 }
4029
4030 #[test]
4031 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4032 let state = test_json!({
4033 "item": {"name": "global", "fallback": "global fallback"},
4034 "outer": {"name": "outer"},
4035 "inner": {"name": "inner"}
4036 });
4037 let loop_vars = vec![
4038 LoopBinding {
4039 name: "item",
4040 value: &state["outer"],
4041 },
4042 LoopBinding {
4043 name: "item",
4044 value: &state["inner"],
4045 },
4046 ];
4047 let local_vars = HashMap::new();
4048 let local_borrowed_vars = BorrowedScope::default();
4049 let sources = LocalValueSources {
4050 owned: &local_vars,
4051 borrowed: &local_borrowed_vars,
4052 };
4053
4054 assert_eq!(
4055 resolve_value_from_sources(
4056 "item.name",
4057 &loop_vars,
4058 VisibleLoopScope { start: 0, end: 2 },
4059 sources,
4060 &state,
4061 )
4062 .as_deref()
4063 .and_then(Value::as_str),
4064 Some("inner")
4065 );
4066 assert_eq!(
4067 resolve_value_from_sources(
4068 "item.fallback",
4069 &loop_vars,
4070 VisibleLoopScope { start: 0, end: 2 },
4071 sources,
4072 &state,
4073 )
4074 .as_deref()
4075 .and_then(Value::as_str),
4076 Some("global fallback")
4077 );
4078 assert_eq!(
4079 resolve_value_from_sources(
4080 "item.name",
4081 &loop_vars,
4082 VisibleLoopScope { start: 2, end: 2 },
4083 sources,
4084 &state,
4085 )
4086 .as_deref()
4087 .and_then(Value::as_str),
4088 Some("global")
4089 );
4090 }
4091
4092 #[test]
4093 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4094 let values = [
4095 Value::String("a".to_string()),
4096 Value::String("b".to_string()),
4097 Value::String("c".to_string()),
4098 Value::String("d".to_string()),
4099 Value::String("e".to_string()),
4100 Value::String("f".to_string()),
4101 Value::String("replacement".to_string()),
4102 ];
4103 let mut scope = BorrowedScope::default();
4104 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4105 assert!(scope.insert(name, &values[index]).is_none());
4106 }
4107 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4108 assert_eq!(scope.overflow.len(), 2);
4109 assert_eq!(scope.get("a"), Some(&values[0]));
4110 assert_eq!(scope.get("f"), Some(&values[5]));
4111 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4112 assert_eq!(scope.get("a"), Some(&values[6]));
4113 assert_eq!(scope.remove("b"), Some(&values[1]));
4114 assert!(scope.get("b").is_none());
4115 assert_eq!(scope.get("f"), Some(&values[5]));
4116 scope.clear();
4117 assert!(scope.get("a").is_none());
4118 assert!(scope.overflow.is_empty());
4119 }
4120
4121 struct TestWriter {
4123 content: RefCell<String>,
4124 ended: RefCell<bool>,
4125 }
4126
4127 impl TestWriter {
4128 fn new() -> Self {
4129 Self {
4130 content: RefCell::new(String::new()),
4131 ended: RefCell::new(false),
4132 }
4133 }
4134
4135 fn get_content(&self) -> String {
4136 self.content.borrow().clone()
4137 }
4138
4139 fn is_ended(&self) -> bool {
4140 *self.ended.borrow()
4141 }
4142 }
4143
4144 impl ResponseWriter for TestWriter {
4145 fn write(&mut self, content: &str) -> Result<()> {
4146 self.content.borrow_mut().push_str(content);
4147 Ok(())
4148 }
4149
4150 fn end(&mut self) -> Result<()> {
4151 *self.ended.borrow_mut() = true;
4152 Ok(())
4153 }
4154 }
4155
4156 #[test]
4157 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4158 struct AttributeWriter {
4159 output: String,
4160 }
4161
4162 impl ResponseWriter for AttributeWriter {
4163 fn write(&mut self, content: &str) -> Result<()> {
4164 self.output.push_str(content);
4165 Ok(())
4166 }
4167
4168 crate::string_response_writer_methods!(output);
4169
4170 fn end(&mut self) -> Result<()> {
4171 Ok(())
4172 }
4173 }
4174
4175 let mut writer = AttributeWriter {
4176 output: String::new(),
4177 };
4178 write_attr(&mut writer, "data-id", "42")
4179 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4180 writer
4181 .write_boolean_attribute("disabled")
4182 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4183 assert_eq!(writer.output, " data-id=\"42\" disabled");
4184 }
4185
4186 #[test]
4187 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4188 let mut fragments = HashMap::new();
4189 fragments.insert(
4190 "index.html".to_string(),
4191 FragmentList {
4192 fragments: vec![
4193 WebUIFragment {
4194 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4195 name: ":data-title".to_string(),
4196 value: "source".to_string(),
4197 complex: true,
4198 attr_start: true,
4199 ..Default::default()
4200 })),
4201 },
4202 WebUIFragment::component("prepared-child"),
4203 ],
4204 contains_boundary: false,
4205 },
4206 );
4207 fragments.insert(
4208 "prepared-child".to_string(),
4209 FragmentList {
4210 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4211 contains_boundary: false,
4212 },
4213 );
4214 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4215 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4216 let entry = resolved
4217 .list_by_id("index.html")
4218 .unwrap_or_else(|| panic!("entry render plan should exist"));
4219 let child_index = protocol
4220 .render_fragments()
4221 .index("prepared-child")
4222 .unwrap_or_else(|| panic!("component target should be indexed"));
4223 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4224 assert_eq!(entry.target(1), Some(child_index));
4225 assert!(!entry.has_routes);
4226 let source_entry = protocol
4227 .protocol()
4228 .fragments
4229 .get("index.html")
4230 .unwrap_or_else(|| panic!("source entry should exist"));
4231 assert!(std::ptr::eq(
4232 entry.fragments.as_ptr(),
4233 source_entry.fragments.as_ptr()
4234 ));
4235
4236 let state = test_json!({"source": "Prepared"});
4237 let options = RenderOptions::new("index.html", "/");
4238 for handler in [
4239 WebUIHandler::new(),
4240 WebUIHandler::with_plugin(|| {
4241 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4242 }),
4243 ] {
4244 let mut writer = TestWriter::new();
4245 assert!(handler
4246 .render(&protocol, &state, &options, &mut writer)
4247 .is_ok());
4248 assert!(writer.get_content().contains("Prepared"));
4249 }
4250 }
4251
4252 #[test]
4253 fn prepared_graph_records_route_presence_per_fragment_list() {
4254 let mut fragments = HashMap::new();
4255 fragments.insert(
4256 "index.html".to_string(),
4257 FragmentList {
4258 fragments: vec![WebUIFragment::route("/", "dash-page")],
4259 contains_boundary: false,
4260 },
4261 );
4262 fragments.insert(
4263 "dash-page".to_string(),
4264 FragmentList {
4265 fragments: vec![WebUIFragment::raw("dashboard")],
4266 contains_boundary: false,
4267 },
4268 );
4269 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4270 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4271 let entry = resolved
4272 .list_by_id("index.html")
4273 .unwrap_or_else(|| panic!("entry render plan should exist"));
4274 let page = resolved
4275 .list_by_id("dash-page")
4276 .unwrap_or_else(|| panic!("page render plan should exist"));
4277 assert!(entry.has_routes);
4279 assert!(!page.has_routes);
4280 }
4281
4282 #[test]
4283 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4284 let names = [
4288 "data-title",
4289 ":data-title",
4290 "aria-labelledby",
4291 "aria-valuemax",
4292 "readonly",
4293 "tabindex",
4294 "class",
4295 "for",
4296 "maxlength",
4297 "novalidate",
4298 "already-camel",
4299 "trailing-",
4300 "-leading",
4301 "multi-part-attribute-name",
4302 "single",
4303 "",
4304 ];
4305 for name in names {
4306 let stripped = name.strip_prefix(':').unwrap_or(name);
4307 assert_eq!(
4308 component_attr_name(name),
4309 webui_protocol::attrs::attribute_to_camel(stripped),
4310 "prepared name diverged for `{name}`"
4311 );
4312 }
4313 }
4314
4315 #[test]
4316 fn prepared_component_prop_names_match_per_branch_stripping() {
4317 let complex = WebUIFragmentAttribute {
4321 name: "::data-title".to_string(),
4322 value: "source".to_string(),
4323 complex: true,
4324 ..Default::default()
4325 };
4326 assert_eq!(component_attr_source(&complex), ":data-title");
4327 assert_eq!(
4328 component_attr_name(component_attr_source(&complex)),
4329 "dataTitle"
4330 );
4331
4332 let raw = WebUIFragmentAttribute {
4334 raw_value: true,
4335 ..complex.clone()
4336 };
4337 assert_eq!(component_attr_source(&raw), "::data-title");
4338
4339 let templated = WebUIFragmentAttribute {
4341 template: "tpl".to_string(),
4342 ..complex.clone()
4343 };
4344 assert_eq!(component_attr_source(&templated), "::data-title");
4345
4346 let empty_value = WebUIFragmentAttribute {
4348 value: String::new(),
4349 ..complex.clone()
4350 };
4351 assert_eq!(component_attr_source(&empty_value), "::data-title");
4352
4353 let ordinary = WebUIFragmentAttribute {
4355 name: ":data-title".to_string(),
4356 ..complex
4357 };
4358 assert_eq!(component_attr_source(&ordinary), "data-title");
4359 assert_eq!(
4360 component_attr_name(component_attr_source(&ordinary)),
4361 "dataTitle"
4362 );
4363 }
4364
4365 #[test]
4366 fn render_slots_match_continuation_slots() {
4367 let mut fragments = HashMap::new();
4372 for id in [
4373 "index.html",
4374 "zeta-last",
4375 "alpha-first",
4376 "middle-one",
4377 "middle-two",
4378 ] {
4379 fragments.insert(
4380 id.to_string(),
4381 FragmentList {
4382 fragments: vec![WebUIFragment::raw(id)],
4383 contains_boundary: false,
4384 },
4385 );
4386 }
4387 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4388 let index = protocol.render_fragments();
4389 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4390 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4391 {
4392 let id = protocol
4393 .fragment_id(slot)
4394 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4395 assert_eq!(
4396 index.index(id),
4397 Some(slot as usize),
4398 "render slot must equal continuation slot for `{id}`"
4399 );
4400 assert_eq!(protocol.fragment_slot(id), Some(slot));
4401 }
4402 }
4403
4404 #[test]
4405 fn prepared_graph_reports_missing_fragments_without_panicking() {
4406 let mut fragments = HashMap::new();
4407 fragments.insert(
4408 "index.html".to_string(),
4409 FragmentList {
4410 fragments: vec![WebUIFragment::component("absent-child")],
4411 contains_boundary: false,
4412 },
4413 );
4414 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4415 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4416 let entry = resolved
4417 .list_by_id("index.html")
4418 .unwrap_or_else(|| panic!("entry render plan should exist"));
4419 assert_eq!(entry.target(0), None);
4422 assert!(resolved.list_by_id("absent-child").is_none());
4423 assert!(resolved.list(usize::MAX).is_none());
4424
4425 let state = test_json!({});
4426 let options = RenderOptions::new("index.html", "/");
4427 let mut writer = TestWriter::new();
4428 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4429 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4430 other => panic!("expected a missing fragment error, got {other:?}"),
4431 }
4432
4433 let mut writer = TestWriter::new();
4434 match WebUIHandler::new().render(
4435 &protocol,
4436 &state,
4437 &RenderOptions::new("absent-entry", "/"),
4438 &mut writer,
4439 ) {
4440 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4441 other => panic!("expected a missing entry error, got {other:?}"),
4442 }
4443 }
4444
4445 #[test]
4446 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4447 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4448 let mut fragments = HashMap::with_capacity(list_count);
4449 for index in 0..list_count {
4450 fragments.insert(
4451 format!("fragment-{index}"),
4452 FragmentList {
4453 fragments: vec![WebUIFragment::raw("content")],
4454 contains_boundary: false,
4455 },
4456 );
4457 }
4458 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4459 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4460 assert!(resolved.cache[0].get().is_none());
4462 assert!(resolved.spilled.get().is_none());
4463 assert!(resolved.list(0).is_some());
4464 assert!(resolved.cache[0].get().is_some());
4465 assert!(resolved.spilled.get().is_none());
4467 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4470 let spilled = resolved
4471 .spilled
4472 .get()
4473 .expect("a slot past the inline cache should allocate the spill region");
4474 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4475 assert!(spilled[0].get().is_some());
4476 assert!(resolved.list(list_count - 1).is_some());
4477
4478 for (id, source) in &protocol.protocol().fragments {
4479 let prepared = resolved
4480 .list_by_id(id)
4481 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4482 assert!(std::ptr::eq(
4484 prepared.fragments.as_ptr(),
4485 source.fragments.as_ptr()
4486 ));
4487 }
4488 }
4489
4490 #[test]
4491 fn test_handle_raw() {
4492 let mut fragments = HashMap::new();
4494 fragments.insert(
4495 "index.html".to_string(),
4496 FragmentList {
4497 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4498 contains_boundary: false,
4499 },
4500 );
4501
4502 let protocol = WebUIProtocol::new(fragments);
4503 let state = test_json!({});
4504
4505 let mut writer = TestWriter::new();
4507
4508 assert!(
4510 handle(
4511 &protocol,
4512 &state,
4513 &RenderOptions::new("index.html", "/"),
4514 &mut writer
4515 )
4516 .is_ok(),
4517 "Failed to handle raw protocol"
4518 );
4519
4520 assert_eq!(writer.get_content(), "Hello, WebUI!");
4522 assert!(writer.is_ended());
4523 }
4524
4525 #[test]
4526 fn test_handle_signal() {
4527 let mut fragments = HashMap::new();
4529 fragments.insert(
4530 "index.html".to_string(),
4531 FragmentList {
4532 fragments: vec![
4533 WebUIFragment::raw("Hello, "),
4534 WebUIFragment::signal("name", false),
4535 WebUIFragment::raw("!"),
4536 ],
4537 contains_boundary: false,
4538 },
4539 );
4540
4541 let protocol = WebUIProtocol::new(fragments);
4542 let state = test_json!({"name": "WebUI"});
4543
4544 let mut writer = TestWriter::new();
4546
4547 assert!(
4549 handle(
4550 &protocol,
4551 &state,
4552 &RenderOptions::new("index.html", "/"),
4553 &mut writer
4554 )
4555 .is_ok(),
4556 "Failed to handle signal protocol"
4557 );
4558
4559 assert_eq!(writer.get_content(), "Hello, WebUI!");
4561 assert!(writer.is_ended());
4562 }
4563
4564 #[test]
4565 fn test_handle_for_loop() {
4566 let mut fragments = HashMap::new();
4568 fragments.insert(
4569 "index.html".to_string(),
4570 FragmentList {
4571 fragments: vec![
4572 WebUIFragment::raw("People: "),
4573 WebUIFragment::for_loop("person", "people", "person-item"),
4574 ],
4575 contains_boundary: false,
4576 },
4577 );
4578
4579 fragments.insert(
4580 "person-item".to_string(),
4581 FragmentList {
4582 fragments: vec![
4583 WebUIFragment::signal("person.name", false),
4584 WebUIFragment::raw(", "),
4585 ],
4586 contains_boundary: false,
4587 },
4588 );
4589
4590 let protocol = WebUIProtocol::new(fragments);
4591 let state = test_json!({
4592 "people": [
4593 {"name": "Alice"},
4594 {"name": "Bob"},
4595 {"name": "Charlie"}
4596 ]
4597 });
4598
4599 let mut writer = TestWriter::new();
4601
4602 assert!(
4604 handle(
4605 &protocol,
4606 &state,
4607 &RenderOptions::new("index.html", "/"),
4608 &mut writer
4609 )
4610 .is_ok(),
4611 "Failed to handle for loop protocol"
4612 );
4613
4614 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4616 assert!(writer.is_ended());
4617 }
4618
4619 #[test]
4620 fn test_handle_if_condition() {
4621 let mut fragments = HashMap::new();
4623 fragments.insert(
4624 "index.html".to_string(),
4625 FragmentList {
4626 fragments: vec![
4627 WebUIFragment::raw("Status: "),
4628 WebUIFragment::if_cond(
4629 webui_protocol::ConditionExpr::identifier("isActive"),
4630 "active-content",
4631 ),
4632 WebUIFragment::raw("End"),
4633 ],
4634 contains_boundary: false,
4635 },
4636 );
4637
4638 fragments.insert(
4639 "active-content".to_string(),
4640 FragmentList {
4641 fragments: vec![WebUIFragment::raw("Active")],
4642 contains_boundary: false,
4643 },
4644 );
4645
4646 let protocol = WebUIProtocol::new(fragments);
4647
4648 let state_true = test_json!({"isActive": true});
4650 let mut writer_true = TestWriter::new();
4651 assert!(
4652 handle(
4653 &protocol,
4654 &state_true,
4655 &RenderOptions::new("index.html", "/"),
4656 &mut writer_true
4657 )
4658 .is_ok(),
4659 "Failed to handle if condition (true case)"
4660 );
4661 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4662 assert!(writer_true.is_ended());
4663
4664 let state_false = test_json!({"isActive": false});
4666 let mut writer_false = TestWriter::new();
4667 assert!(
4668 handle(
4669 &protocol,
4670 &state_false,
4671 &RenderOptions::new("index.html", "/"),
4672 &mut writer_false
4673 )
4674 .is_ok(),
4675 "Failed to handle if condition (false case)"
4676 );
4677 assert_eq!(writer_false.get_content(), "Status: End");
4678 assert!(writer_false.is_ended());
4679 }
4680
4681 #[test]
4682 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4683 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>"#;
4684 let mut parser = HtmlParser::new();
4685 parser
4686 .parse("index.html", source)
4687 .expect("parse missing-path condition fixture");
4688 let protocol = WebUIProtocol::new(parser.into_fragment_records());
4689 let state = test_json!({
4690 "items": [
4691 {"label": "Normal"},
4692 {"label": "Search", "searchPresentation": true}
4693 ]
4694 });
4695 let mut writer = TestWriter::new();
4696
4697 handle(
4698 &protocol,
4699 &state,
4700 &RenderOptions::new("index.html", "/"),
4701 &mut writer,
4702 )
4703 .expect("render missing-path condition fixture");
4704
4705 assert_eq!(
4706 writer.get_content(),
4707 "top-negated<span>Normal</span><mark>Search</mark>"
4708 );
4709 }
4710
4711 #[test]
4712 fn native_dynamic_attribute_does_not_leak_into_next_component() {
4713 let mut fragments = HashMap::new();
4714 fragments.insert(
4715 "index.html".to_string(),
4716 FragmentList {
4717 fragments: vec![
4718 WebUIFragment::raw("<div"),
4719 WebUIFragment {
4720 fragment: Some(web_ui_fragment::Fragment::Attribute(
4721 WebUIFragmentAttribute {
4722 name: "title".into(),
4723 value: "nativeTitle".into(),
4724 ..Default::default()
4725 },
4726 )),
4727 },
4728 WebUIFragment::raw("></div><my-comp>"),
4729 WebUIFragment::component("my-comp"),
4730 WebUIFragment::raw("</my-comp>"),
4731 ],
4732 contains_boundary: false,
4733 },
4734 );
4735 fragments.insert(
4736 "my-comp".to_string(),
4737 FragmentList {
4738 fragments: vec![WebUIFragment::signal("title", false)],
4739 contains_boundary: false,
4740 },
4741 );
4742 let protocol = WebUIProtocol::new(fragments);
4743 let state = test_json!({
4744 "nativeTitle": "native",
4745 "title": "global"
4746 });
4747 let mut writer = TestWriter::new();
4748 handle(
4749 &protocol,
4750 &state,
4751 &RenderOptions::new("index.html", "/"),
4752 &mut writer,
4753 )
4754 .unwrap_or_else(|error| panic!("render failed: {error}"));
4755 assert_eq!(
4756 writer.get_content(),
4757 "<div title=\"native\"></div><my-comp>global</my-comp>"
4758 );
4759 }
4760
4761 #[test]
4762 fn test_handle_component() {
4763 let mut fragments = HashMap::new();
4765 fragments.insert(
4766 "index.html".to_string(),
4767 FragmentList {
4768 fragments: vec![
4769 WebUIFragment::raw("Component: "),
4770 WebUIFragment::component("my-component"),
4771 ],
4772 contains_boundary: false,
4773 },
4774 );
4775
4776 fragments.insert(
4777 "my-component".to_string(),
4778 FragmentList {
4779 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
4780 contains_boundary: false,
4781 },
4782 );
4783
4784 let protocol = WebUIProtocol::new(fragments);
4785 let state = test_json!({});
4786
4787 let mut writer = TestWriter::new();
4789
4790 assert!(
4792 handle(
4793 &protocol,
4794 &state,
4795 &RenderOptions::new("index.html", "/"),
4796 &mut writer
4797 )
4798 .is_ok(),
4799 "Failed to handle component protocol"
4800 );
4801
4802 assert_eq!(
4804 writer.get_content(),
4805 "Component: <div>Component Content</div>"
4806 );
4807 assert!(writer.is_ended());
4808 }
4809
4810 #[test]
4811 fn test_missing_fragment() {
4812 let mut fragments = HashMap::new();
4814 fragments.insert(
4815 "index.html".to_string(),
4816 FragmentList {
4817 fragments: vec![WebUIFragment::component("missing-component")],
4818 contains_boundary: false,
4819 },
4820 );
4821
4822 let protocol = WebUIProtocol::new(fragments);
4823 let state = test_json!({});
4824
4825 let mut writer = TestWriter::new();
4827
4828 let result = handle(
4830 &protocol,
4831 &state,
4832 &RenderOptions::new("index.html", "/"),
4833 &mut writer,
4834 );
4835
4836 assert!(result.is_err());
4838 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4839 assert_eq!(fragment_id, "missing-component");
4840 } else {
4841 panic!("Expected MissingFragment error");
4842 }
4843 }
4844
4845 #[test]
4846 fn test_missing_signal_renders_empty() {
4847 let mut fragments = HashMap::new();
4849 fragments.insert(
4850 "index.html".to_string(),
4851 FragmentList {
4852 fragments: vec![
4853 WebUIFragment::raw("Hello, "),
4854 WebUIFragment::signal("missing_field", false),
4855 WebUIFragment::raw("!"),
4856 ],
4857 contains_boundary: false,
4858 },
4859 );
4860
4861 let protocol = WebUIProtocol::new(fragments);
4862 let state = test_json!({});
4863
4864 let mut writer = TestWriter::new();
4865
4866 assert!(
4867 handle(
4868 &protocol,
4869 &state,
4870 &RenderOptions::new("index.html", "/"),
4871 &mut writer
4872 )
4873 .is_ok(),
4874 "Missing signal should not produce an error"
4875 );
4876
4877 assert_eq!(writer.get_content(), "Hello, !");
4878 assert!(writer.is_ended());
4879 }
4880
4881 #[test]
4884 fn test_boolean_attr_true() {
4885 let mut fragments = HashMap::new();
4886 fragments.insert(
4887 "index.html".to_string(),
4888 FragmentList {
4889 fragments: vec![
4890 WebUIFragment::raw("<button"),
4891 WebUIFragment::attribute_boolean(
4892 "disabled",
4893 ConditionExpr::identifier("isDisabled"),
4894 ),
4895 WebUIFragment::raw(">Click</button>"),
4896 ],
4897 contains_boundary: false,
4898 },
4899 );
4900 let protocol = WebUIProtocol::new(fragments);
4901 let state = test_json!({"isDisabled": true});
4902 let mut writer = TestWriter::new();
4903 handle(
4904 &protocol,
4905 &state,
4906 &RenderOptions::new("index.html", "/"),
4907 &mut writer,
4908 )
4909 .unwrap();
4910 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
4911 }
4912
4913 #[test]
4914 fn test_boolean_attr_false() {
4915 let mut fragments = HashMap::new();
4916 fragments.insert(
4917 "index.html".to_string(),
4918 FragmentList {
4919 fragments: vec![
4920 WebUIFragment::raw("<button"),
4921 WebUIFragment::attribute_boolean(
4922 "disabled",
4923 ConditionExpr::identifier("isDisabled"),
4924 ),
4925 WebUIFragment::raw(">Click</button>"),
4926 ],
4927 contains_boundary: false,
4928 },
4929 );
4930 let protocol = WebUIProtocol::new(fragments);
4931 let state = test_json!({"isDisabled": false});
4932 let mut writer = TestWriter::new();
4933 handle(
4934 &protocol,
4935 &state,
4936 &RenderOptions::new("index.html", "/"),
4937 &mut writer,
4938 )
4939 .unwrap();
4940 assert_eq!(writer.get_content(), "<button>Click</button>");
4941 }
4942
4943 #[test]
4944 fn test_boolean_attr_missing() {
4945 let mut fragments = HashMap::new();
4946 fragments.insert(
4947 "index.html".to_string(),
4948 FragmentList {
4949 fragments: vec![
4950 WebUIFragment::raw("<input type=\"checkbox\""),
4951 WebUIFragment::attribute_boolean(
4952 "checked",
4953 ConditionExpr::identifier("checked"),
4954 ),
4955 WebUIFragment::raw(">"),
4956 ],
4957 contains_boundary: false,
4958 },
4959 );
4960 let protocol = WebUIProtocol::new(fragments);
4961 let state = test_json!({});
4962 let mut writer = TestWriter::new();
4963 handle(
4964 &protocol,
4965 &state,
4966 &RenderOptions::new("index.html", "/"),
4967 &mut writer,
4968 )
4969 .unwrap();
4970 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
4971 }
4972
4973 #[test]
4974 fn test_boolean_attr_multiple() {
4975 let mut fragments = HashMap::new();
4976 fragments.insert(
4977 "index.html".to_string(),
4978 FragmentList {
4979 fragments: vec![
4980 WebUIFragment::raw("<input type=\"checkbox\""),
4981 WebUIFragment::attribute_boolean(
4982 "checked",
4983 ConditionExpr::identifier("checked"),
4984 ),
4985 WebUIFragment::attribute_boolean(
4986 "disabled",
4987 ConditionExpr::identifier("disabled"),
4988 ),
4989 WebUIFragment::raw(">"),
4990 ],
4991 contains_boundary: false,
4992 },
4993 );
4994 let protocol = WebUIProtocol::new(fragments);
4995 let state = test_json!({"checked": true, "disabled": false});
4996 let mut writer = TestWriter::new();
4997 handle(
4998 &protocol,
4999 &state,
5000 &RenderOptions::new("index.html", "/"),
5001 &mut writer,
5002 )
5003 .unwrap();
5004 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5005 }
5006
5007 #[test]
5010 fn test_attribute_with_value() {
5011 let mut fragments = HashMap::new();
5012 fragments.insert(
5013 "index.html".to_string(),
5014 FragmentList {
5015 fragments: vec![
5016 WebUIFragment::raw("<input"),
5017 WebUIFragment::attribute("value", "inputValue"),
5018 WebUIFragment::raw(">"),
5019 ],
5020 contains_boundary: false,
5021 },
5022 );
5023 let protocol = WebUIProtocol::new(fragments);
5024 let state = test_json!({"inputValue": "Hello"});
5025 let mut writer = TestWriter::new();
5026 handle(
5027 &protocol,
5028 &state,
5029 &RenderOptions::new("index.html", "/"),
5030 &mut writer,
5031 )
5032 .unwrap();
5033 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5034 }
5035
5036 #[test]
5037 fn test_attribute_with_falsy_numeric() {
5038 let mut fragments = HashMap::new();
5039 fragments.insert(
5040 "index.html".to_string(),
5041 FragmentList {
5042 fragments: vec![
5043 WebUIFragment::raw("<div name=\"test\""),
5044 WebUIFragment::attribute("handle", "number"),
5045 WebUIFragment::raw("></div>"),
5046 ],
5047 contains_boundary: false,
5048 },
5049 );
5050 let protocol = WebUIProtocol::new(fragments);
5051 let state = test_json!({"number": 0});
5052 let mut writer = TestWriter::new();
5053 handle(
5054 &protocol,
5055 &state,
5056 &RenderOptions::new("index.html", "/"),
5057 &mut writer,
5058 )
5059 .unwrap();
5060 assert_eq!(
5061 writer.get_content(),
5062 "<div name=\"test\" handle=\"0\"></div>"
5063 );
5064 }
5065
5066 #[test]
5069 fn test_attribute_array_value_is_escaped() {
5070 let mut fragments = HashMap::new();
5071 fragments.insert(
5072 "index.html".to_string(),
5073 FragmentList {
5074 fragments: vec![
5075 WebUIFragment::raw("<a"),
5076 WebUIFragment::attribute("href", "value"),
5077 WebUIFragment::raw(">demo</a>"),
5078 ],
5079 contains_boundary: false,
5080 },
5081 );
5082 let protocol = WebUIProtocol::new(fragments);
5083 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5084 let mut writer = TestWriter::new();
5085 handle(
5086 &protocol,
5087 &state,
5088 &RenderOptions::new("index.html", "/"),
5089 &mut writer,
5090 )
5091 .unwrap();
5092 let content = writer.get_content();
5093 assert!(
5096 content.contains("""),
5097 "Double quotes inside attribute value must be escaped: {content}"
5098 );
5099 assert_eq!(
5102 content.matches("=\"").count(),
5103 1,
5104 "Only one attribute assignment expected: {content}"
5105 );
5106 }
5107
5108 #[test]
5109 fn test_attribute_object_value_is_escaped() {
5110 let mut fragments = HashMap::new();
5111 fragments.insert(
5112 "index.html".to_string(),
5113 FragmentList {
5114 fragments: vec![
5115 WebUIFragment::raw("<div"),
5116 WebUIFragment::attribute("data-cfg", "cfg"),
5117 WebUIFragment::raw("></div>"),
5118 ],
5119 contains_boundary: false,
5120 },
5121 );
5122 let protocol = WebUIProtocol::new(fragments);
5123 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5124 let mut writer = TestWriter::new();
5125 handle(
5126 &protocol,
5127 &state,
5128 &RenderOptions::new("index.html", "/"),
5129 &mut writer,
5130 )
5131 .unwrap();
5132 let content = writer.get_content();
5133 assert!(
5134 content.contains("""),
5135 "Double quotes inside attribute value must be escaped: {content}"
5136 );
5137 assert_eq!(
5138 content.matches("=\"").count(),
5139 1,
5140 "Only one attribute assignment expected: {content}"
5141 );
5142 }
5143
5144 #[test]
5147 fn test_mixed_attribute_template() {
5148 let mut fragments = HashMap::new();
5149 fragments.insert(
5150 "index.html".to_string(),
5151 FragmentList {
5152 fragments: vec![
5153 WebUIFragment::raw("<input"),
5154 WebUIFragment::attribute_template("value", "attr-1"),
5155 WebUIFragment::raw(">"),
5156 ],
5157 contains_boundary: false,
5158 },
5159 );
5160 fragments.insert(
5161 "attr-1".to_string(),
5162 FragmentList {
5163 fragments: vec![
5164 WebUIFragment::raw("hello "),
5165 WebUIFragment::signal("item", false),
5166 ],
5167 contains_boundary: false,
5168 },
5169 );
5170 let protocol = WebUIProtocol::new(fragments);
5171 let state = test_json!({"item": "world"});
5172 let mut writer = TestWriter::new();
5173 handle(
5174 &protocol,
5175 &state,
5176 &RenderOptions::new("index.html", "/"),
5177 &mut writer,
5178 )
5179 .unwrap();
5180 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5181 }
5182
5183 #[test]
5186 fn test_raw_signal_not_escaped() {
5187 let mut fragments = HashMap::new();
5188 fragments.insert(
5189 "index.html".to_string(),
5190 FragmentList {
5191 fragments: vec![
5192 WebUIFragment::signal("html", false),
5193 WebUIFragment::signal("html", true),
5194 ],
5195 contains_boundary: false,
5196 },
5197 );
5198 let protocol = WebUIProtocol::new(fragments);
5199 let state = test_json!({"html": "<strong>hi</strong>"});
5200 let mut writer = TestWriter::new();
5201 handle(
5202 &protocol,
5203 &state,
5204 &RenderOptions::new("index.html", "/"),
5205 &mut writer,
5206 )
5207 .unwrap();
5208 assert_eq!(
5209 writer.get_content(),
5210 "<strong>hi</strong><strong>hi</strong>"
5211 );
5212 }
5213
5214 #[test]
5217 fn test_nested_for_loop() {
5218 let mut fragments = HashMap::new();
5219 fragments.insert(
5220 "index.html".to_string(),
5221 FragmentList {
5222 fragments: vec![
5223 WebUIFragment::raw("<div>"),
5224 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5225 WebUIFragment::raw("</div>"),
5226 ],
5227 contains_boundary: false,
5228 },
5229 );
5230 fragments.insert(
5231 "outer".to_string(),
5232 FragmentList {
5233 fragments: vec![
5234 WebUIFragment::raw("<div>"),
5235 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5236 WebUIFragment::raw("</div>"),
5237 ],
5238 contains_boundary: false,
5239 },
5240 );
5241 fragments.insert(
5242 "inner".to_string(),
5243 FragmentList {
5244 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5245 contains_boundary: false,
5246 },
5247 );
5248 let protocol = WebUIProtocol::new(fragments);
5249 let state = test_json!({
5250 "outerItems": [
5251 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5252 {"innerItems": [{"name": "C"}]}
5253 ]
5254 });
5255 let mut writer = TestWriter::new();
5256 handle(
5257 &protocol,
5258 &state,
5259 &RenderOptions::new("index.html", "/"),
5260 &mut writer,
5261 )
5262 .unwrap();
5263 assert_eq!(
5264 writer.get_content(),
5265 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5266 );
5267 }
5268
5269 #[test]
5270 fn test_nested_for_with_signals() {
5271 let mut fragments = HashMap::new();
5272 fragments.insert(
5273 "index.html".to_string(),
5274 FragmentList {
5275 fragments: vec![
5276 WebUIFragment::raw("<div>"),
5277 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5278 WebUIFragment::raw("</div>"),
5279 ],
5280 contains_boundary: false,
5281 },
5282 );
5283 fragments.insert(
5284 "outerTemplate".to_string(),
5285 FragmentList {
5286 fragments: vec![
5287 WebUIFragment::raw("<div>"),
5288 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5289 WebUIFragment::raw("</div>"),
5290 ],
5291 contains_boundary: false,
5292 },
5293 );
5294 fragments.insert(
5295 "innerTemplate".to_string(),
5296 FragmentList {
5297 fragments: vec![
5298 WebUIFragment::raw("<span>"),
5299 WebUIFragment::signal("innerItem.name", false),
5300 WebUIFragment::raw("</span>"),
5301 ],
5302 contains_boundary: false,
5303 },
5304 );
5305 let protocol = WebUIProtocol::new(fragments);
5306 let state = test_json!({
5307 "outerItems": [
5308 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5309 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5310 ]
5311 });
5312 let mut writer = TestWriter::new();
5313 handle(
5314 &protocol,
5315 &state,
5316 &RenderOptions::new("index.html", "/"),
5317 &mut writer,
5318 )
5319 .unwrap();
5320 assert_eq!(
5321 writer.get_content(),
5322 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5323 );
5324 }
5325
5326 #[test]
5327 fn test_nested_for_with_global_state() {
5328 let mut fragments = HashMap::new();
5329 fragments.insert(
5330 "index.html".to_string(),
5331 FragmentList {
5332 fragments: vec![
5333 WebUIFragment::raw("<div>"),
5334 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5335 WebUIFragment::raw("</div>"),
5336 ],
5337 contains_boundary: false,
5338 },
5339 );
5340 fragments.insert(
5341 "outerTemplate".to_string(),
5342 FragmentList {
5343 fragments: vec![
5344 WebUIFragment::raw("<div>"),
5345 WebUIFragment::signal("globalOuter", false),
5346 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5347 WebUIFragment::raw("</div>"),
5348 ],
5349 contains_boundary: false,
5350 },
5351 );
5352 fragments.insert(
5353 "innerTemplate".to_string(),
5354 FragmentList {
5355 fragments: vec![
5356 WebUIFragment::raw("<span>"),
5357 WebUIFragment::signal("innerItem.name", false),
5358 WebUIFragment::signal("globalInner", false),
5359 WebUIFragment::raw("</span>"),
5360 ],
5361 contains_boundary: false,
5362 },
5363 );
5364 let protocol = WebUIProtocol::new(fragments);
5365 let state = test_json!({
5366 "globalOuter": "GO",
5367 "globalInner": "GI",
5368 "outerItems": [
5369 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5370 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5371 ]
5372 });
5373 let mut writer = TestWriter::new();
5374 handle(
5375 &protocol,
5376 &state,
5377 &RenderOptions::new("index.html", "/"),
5378 &mut writer,
5379 )
5380 .unwrap();
5381 assert_eq!(
5382 writer.get_content(),
5383 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5384 );
5385 }
5386
5387 #[test]
5390 fn test_if_in_for_uses_local_state() {
5391 let mut fragments = HashMap::new();
5392 fragments.insert(
5393 "index.html".to_string(),
5394 FragmentList {
5395 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5396 contains_boundary: false,
5397 },
5398 );
5399 fragments.insert(
5400 "item-tpl".to_string(),
5401 FragmentList {
5402 fragments: vec![WebUIFragment::if_cond(
5403 ConditionExpr::identifier("item.visible"),
5404 "visible-tpl",
5405 )],
5406 contains_boundary: false,
5407 },
5408 );
5409 fragments.insert(
5410 "visible-tpl".to_string(),
5411 FragmentList {
5412 fragments: vec![WebUIFragment::signal("item.name", false)],
5413 contains_boundary: false,
5414 },
5415 );
5416 let protocol = WebUIProtocol::new(fragments);
5417 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5418 let mut writer = TestWriter::new();
5419 handle(
5420 &protocol,
5421 &state,
5422 &RenderOptions::new("index.html", "/"),
5423 &mut writer,
5424 )
5425 .unwrap();
5426 assert_eq!(writer.get_content(), "Show");
5427 }
5428
5429 #[test]
5430 fn test_for_if_local_overrides_global() {
5431 let mut fragments = HashMap::new();
5432 fragments.insert(
5433 "index.html".to_string(),
5434 FragmentList {
5435 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5436 contains_boundary: false,
5437 },
5438 );
5439 fragments.insert(
5440 "item-tpl".to_string(),
5441 FragmentList {
5442 fragments: vec![WebUIFragment::if_cond(
5443 ConditionExpr::identifier("item.flag"),
5444 "show-tpl",
5445 )],
5446 contains_boundary: false,
5447 },
5448 );
5449 fragments.insert(
5450 "show-tpl".to_string(),
5451 FragmentList {
5452 fragments: vec![WebUIFragment::raw("yes")],
5453 contains_boundary: false,
5454 },
5455 );
5456 let protocol = WebUIProtocol::new(fragments);
5457 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5459 let mut writer = TestWriter::new();
5460 handle(
5461 &protocol,
5462 &state,
5463 &RenderOptions::new("index.html", "/"),
5464 &mut writer,
5465 )
5466 .unwrap();
5467 assert_eq!(writer.get_content(), "yes");
5468 }
5469
5470 #[test]
5473 fn test_component_attr_state_simple() {
5474 let mut fragments = HashMap::new();
5475 fragments.insert(
5476 "index.html".to_string(),
5477 FragmentList {
5478 fragments: vec![
5479 WebUIFragment::raw("<my-comp"),
5480 WebUIFragment {
5481 fragment: Some(web_ui_fragment::Fragment::Attribute(
5482 WebUIFragmentAttribute {
5483 name: "title".into(),
5484 value: "Attribute Title".into(),
5485 attr_start: true,
5486 raw_value: true,
5487 ..Default::default()
5488 },
5489 )),
5490 },
5491 WebUIFragment::raw(">"),
5492 WebUIFragment::component("my-comp"),
5493 WebUIFragment::raw("</my-comp>"),
5494 ],
5495
5496 contains_boundary: false,
5497 },
5498 );
5499 fragments.insert(
5500 "my-comp".to_string(),
5501 FragmentList {
5502 fragments: vec![
5503 WebUIFragment::raw("<span>"),
5504 WebUIFragment::signal("title", false),
5505 WebUIFragment::raw("</span>"),
5506 ],
5507 contains_boundary: false,
5508 },
5509 );
5510 let protocol = WebUIProtocol::new(fragments);
5511 let state = test_json!({"title": "Global Title"});
5512 let mut writer = TestWriter::new();
5513 handle(
5514 &protocol,
5515 &state,
5516 &RenderOptions::new("index.html", "/"),
5517 &mut writer,
5518 )
5519 .unwrap();
5520 assert_eq!(
5521 writer.get_content(),
5522 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5523 );
5524 }
5525
5526 #[test]
5527 fn test_component_attr_state_template() {
5528 let mut fragments = HashMap::new();
5529 fragments.insert(
5530 "index.html".to_string(),
5531 FragmentList {
5532 fragments: vec![
5533 WebUIFragment::raw("<my-comp"),
5534 WebUIFragment {
5535 fragment: Some(web_ui_fragment::Fragment::Attribute(
5536 WebUIFragmentAttribute {
5537 name: "title".into(),
5538 template: "title-attr".into(),
5539 attr_start: true,
5540 ..Default::default()
5541 },
5542 )),
5543 },
5544 WebUIFragment::raw(">"),
5545 WebUIFragment::component("my-comp"),
5546 WebUIFragment::raw("</my-comp>"),
5547 ],
5548
5549 contains_boundary: false,
5550 },
5551 );
5552 fragments.insert(
5553 "title-attr".to_string(),
5554 FragmentList {
5555 fragments: vec![
5556 WebUIFragment::raw("hello "),
5557 WebUIFragment::signal("item", false),
5558 ],
5559 contains_boundary: false,
5560 },
5561 );
5562 fragments.insert(
5563 "my-comp".to_string(),
5564 FragmentList {
5565 fragments: vec![
5566 WebUIFragment::raw("<span>"),
5567 WebUIFragment::signal("title", false),
5568 WebUIFragment::raw("</span>"),
5569 ],
5570 contains_boundary: false,
5571 },
5572 );
5573 let protocol = WebUIProtocol::new(fragments);
5574 let state = test_json!({"item": "<world>"});
5575 let mut writer = TestWriter::new();
5576 handle(
5577 &protocol,
5578 &state,
5579 &RenderOptions::new("index.html", "/"),
5580 &mut writer,
5581 )
5582 .unwrap();
5583 assert_eq!(
5584 writer.get_content(),
5585 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
5586 );
5587 }
5588
5589 #[test]
5590 fn test_component_attr_camel_case() {
5591 let mut fragments = HashMap::new();
5592 fragments.insert(
5593 "index.html".to_string(),
5594 FragmentList {
5595 fragments: vec![
5596 WebUIFragment::raw("<my-comp"),
5597 WebUIFragment {
5598 fragment: Some(web_ui_fragment::Fragment::Attribute(
5599 WebUIFragmentAttribute {
5600 name: "data-title".into(),
5601 template: "dt-attr".into(),
5602 attr_start: true,
5603 ..Default::default()
5604 },
5605 )),
5606 },
5607 WebUIFragment::raw(">"),
5608 WebUIFragment::component("my-comp"),
5609 WebUIFragment::raw("</my-comp>"),
5610 ],
5611
5612 contains_boundary: false,
5613 },
5614 );
5615 fragments.insert(
5616 "dt-attr".to_string(),
5617 FragmentList {
5618 fragments: vec![
5619 WebUIFragment::raw("prefix "),
5620 WebUIFragment::signal("item", false),
5621 ],
5622 contains_boundary: false,
5623 },
5624 );
5625 fragments.insert(
5626 "my-comp".to_string(),
5627 FragmentList {
5628 fragments: vec![
5629 WebUIFragment::raw("<span>"),
5630 WebUIFragment::signal("dataTitle", false),
5631 WebUIFragment::raw("</span>"),
5632 ],
5633 contains_boundary: false,
5634 },
5635 );
5636 let protocol = WebUIProtocol::new(fragments);
5637 let state = test_json!({"item": "a&b"});
5638 let mut writer = TestWriter::new();
5639 handle(
5640 &protocol,
5641 &state,
5642 &RenderOptions::new("index.html", "/"),
5643 &mut writer,
5644 )
5645 .unwrap();
5646 assert_eq!(
5647 writer.get_content(),
5648 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
5649 );
5650 }
5651
5652 #[test]
5653 fn test_component_complex_attr() {
5654 let mut fragments = HashMap::new();
5655 fragments.insert(
5656 "index.html".to_string(),
5657 FragmentList {
5658 fragments: vec![
5659 WebUIFragment::raw("<my-comp"),
5660 WebUIFragment {
5661 fragment: Some(web_ui_fragment::Fragment::Attribute(
5662 WebUIFragmentAttribute {
5663 name: ":item".into(),
5664 value: "complexItem".into(),
5665 attr_start: true,
5666 complex: true,
5667 ..Default::default()
5668 },
5669 )),
5670 },
5671 WebUIFragment::raw(">"),
5672 WebUIFragment::component("my-comp"),
5673 WebUIFragment::raw("</my-comp>"),
5674 ],
5675
5676 contains_boundary: false,
5677 },
5678 );
5679 fragments.insert(
5680 "my-comp".to_string(),
5681 FragmentList {
5682 fragments: vec![
5683 WebUIFragment::raw("<span>"),
5684 WebUIFragment::signal("item.foo", false),
5685 WebUIFragment::raw("</span><p>"),
5686 WebUIFragment::signal("item.bar", false),
5687 WebUIFragment::raw("</p>"),
5688 ],
5689 contains_boundary: false,
5690 },
5691 );
5692 let protocol = WebUIProtocol::new(fragments);
5693 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5694 let mut writer = TestWriter::new();
5695 handle(
5696 &protocol,
5697 &state,
5698 &RenderOptions::new("index.html", "/"),
5699 &mut writer,
5700 )
5701 .unwrap();
5702 assert_eq!(
5703 writer.get_content(),
5704 "<my-comp><span>1</span><p>true</p></my-comp>"
5705 );
5706 }
5707
5708 #[test]
5709 fn test_component_no_parent_pollution() {
5710 let mut fragments = HashMap::new();
5711 fragments.insert(
5712 "index.html".to_string(),
5713 FragmentList {
5714 fragments: vec![
5715 WebUIFragment::raw("<parent"),
5716 WebUIFragment {
5717 fragment: Some(web_ui_fragment::Fragment::Attribute(
5718 WebUIFragmentAttribute {
5719 name: "var".into(),
5720 value: "var".into(),
5721 attr_start: true,
5722 ..Default::default()
5723 },
5724 )),
5725 },
5726 WebUIFragment::raw(">"),
5727 WebUIFragment::component("parent"),
5728 WebUIFragment::raw("</parent>"),
5729 ],
5730
5731 contains_boundary: false,
5732 },
5733 );
5734 fragments.insert(
5735 "parent".to_string(),
5736 FragmentList {
5737 fragments: vec![
5738 WebUIFragment::raw("Before: "),
5739 WebUIFragment::signal("var", false),
5740 WebUIFragment::raw("<child foo"),
5741 WebUIFragment {
5742 fragment: Some(web_ui_fragment::Fragment::Attribute(
5743 WebUIFragmentAttribute {
5744 name: "var".into(),
5745 value: "replaced".into(),
5746 raw_value: true,
5747 ..Default::default()
5748 },
5749 )),
5750 },
5751 WebUIFragment::raw(">"),
5752 WebUIFragment::component("child"),
5753 WebUIFragment::raw("Label</child>After: "),
5754 WebUIFragment::signal("var", false),
5755 ],
5756
5757 contains_boundary: false,
5758 },
5759 );
5760 fragments.insert(
5761 "child".to_string(),
5762 FragmentList {
5763 fragments: vec![],
5764 contains_boundary: false,
5765 },
5766 );
5767 let protocol = WebUIProtocol::new(fragments);
5768 let state = test_json!({"var": "original"});
5769 let mut writer = TestWriter::new();
5770 handle(
5771 &protocol,
5772 &state,
5773 &RenderOptions::new("index.html", "/"),
5774 &mut writer,
5775 )
5776 .unwrap();
5777 assert_eq!(
5778 writer.get_content(),
5779 "<parent var=\"original\">Before: original<child foo var=\"replaced\">Label</child>After: original</parent>"
5780 );
5781 }
5782
5783 #[test]
5784 fn test_component_boolean_attr_state() {
5785 let mut fragments = HashMap::new();
5786 fragments.insert(
5787 "index.html".to_string(),
5788 FragmentList {
5789 fragments: vec![
5790 WebUIFragment::raw("<my-comp"),
5791 WebUIFragment {
5792 fragment: Some(web_ui_fragment::Fragment::Attribute(
5793 WebUIFragmentAttribute {
5794 name: "disabled".into(),
5795 attr_start: true,
5796 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5797 ..Default::default()
5798 },
5799 )),
5800 },
5801 WebUIFragment::raw(">"),
5802 WebUIFragment::component("my-comp"),
5803 WebUIFragment::raw("</my-comp>"),
5804 ],
5805
5806 contains_boundary: false,
5807 },
5808 );
5809 fragments.insert(
5810 "my-comp".to_string(),
5811 FragmentList {
5812 fragments: vec![WebUIFragment::if_cond(
5813 ConditionExpr::identifier("disabled"),
5814 "show",
5815 )],
5816 contains_boundary: false,
5817 },
5818 );
5819 fragments.insert(
5820 "show".to_string(),
5821 FragmentList {
5822 fragments: vec![WebUIFragment::raw("disabled!")],
5823 contains_boundary: false,
5824 },
5825 );
5826 let protocol = WebUIProtocol::new(fragments);
5827 let state = test_json!({"isDisabled": true});
5828 let mut writer = TestWriter::new();
5829 handle(
5830 &protocol,
5831 &state,
5832 &RenderOptions::new("index.html", "/"),
5833 &mut writer,
5834 )
5835 .unwrap();
5836 assert_eq!(
5837 writer.get_content(),
5838 "<my-comp disabled>disabled!</my-comp>"
5839 );
5840 }
5841
5842 fn render_signal(value: &str) -> String {
5846 let mut fragments = HashMap::new();
5847 fragments.insert(
5848 "index.html".to_string(),
5849 FragmentList {
5850 fragments: vec![WebUIFragment::signal("v", false)],
5851 contains_boundary: false,
5852 },
5853 );
5854 let protocol = WebUIProtocol::new(fragments);
5855 let state = test_json!({"v": value});
5856 let mut writer = TestWriter::new();
5857 handle(
5858 &protocol,
5859 &state,
5860 &RenderOptions::new("index.html", "/"),
5861 &mut writer,
5862 )
5863 .unwrap();
5864 writer.get_content()
5865 }
5866
5867 #[test]
5868 fn test_escape_ampersand() {
5869 assert_eq!(render_signal("&"), "&");
5870 }
5871
5872 #[test]
5873 fn test_escape_less_than() {
5874 assert_eq!(render_signal("<"), "<");
5875 }
5876
5877 #[test]
5878 fn test_escape_greater_than() {
5879 assert_eq!(render_signal(">"), ">");
5880 }
5881
5882 #[test]
5883 fn test_escape_double_quote() {
5884 assert_eq!(render_signal("\""), """);
5885 }
5886
5887 #[test]
5888 fn test_escape_single_quote() {
5889 let result = render_signal("'");
5891 assert_eq!(
5892 result, "'",
5893 "Expected ' for single quote, got: {result}"
5894 );
5895 }
5896
5897 #[test]
5898 fn test_escape_multiple_special_chars() {
5899 let result = render_signal("<script>alert('xss');</script>");
5900 assert!(
5901 result.contains("<") && result.contains(">"),
5902 "Expected escaped HTML, got: {}",
5903 result
5904 );
5905 assert!(
5906 !result.contains("<script>"),
5907 "Should not contain raw <script> tag"
5908 );
5909 }
5910
5911 #[test]
5912 fn test_escape_no_special_chars() {
5913 assert_eq!(render_signal("Hello World"), "Hello World");
5914 }
5915
5916 #[test]
5917 fn test_escape_empty_string() {
5918 assert_eq!(render_signal(""), "");
5919 }
5920
5921 #[test]
5922 fn test_escape_special_at_beginning() {
5923 let result = render_signal("<Hello");
5924 assert!(
5925 result.starts_with("<"),
5926 "Expected < at start, got: {}",
5927 result
5928 );
5929 }
5930
5931 #[test]
5932 fn test_escape_special_at_end() {
5933 let result = render_signal("Hello>");
5934 assert!(
5935 result.ends_with(">"),
5936 "Expected > at end, got: {}",
5937 result
5938 );
5939 }
5940
5941 #[test]
5942 fn test_escape_special_in_middle() {
5943 let result = render_signal("Hel&lo");
5944 assert!(
5945 result.contains("&"),
5946 "Expected & in middle, got: {}",
5947 result
5948 );
5949 }
5950
5951 #[test]
5954 fn test_boolean_attr_truthy_values() {
5955 {
5957 let mut fragments = HashMap::new();
5958 fragments.insert(
5959 "index.html".to_string(),
5960 FragmentList {
5961 fragments: vec![
5962 WebUIFragment::raw("<input"),
5963 WebUIFragment::attribute_boolean(
5964 "checked",
5965 ConditionExpr::identifier("checked"),
5966 ),
5967 WebUIFragment::raw(">"),
5968 ],
5969 contains_boundary: false,
5970 },
5971 );
5972 let protocol = WebUIProtocol::new(fragments);
5973 let state = test_json!({"checked": 1});
5974 let mut writer = TestWriter::new();
5975 handle(
5976 &protocol,
5977 &state,
5978 &RenderOptions::new("index.html", "/"),
5979 &mut writer,
5980 )
5981 .unwrap();
5982 assert_eq!(writer.get_content(), "<input checked>");
5983 }
5984 {
5986 let mut fragments = HashMap::new();
5987 fragments.insert(
5988 "index.html".to_string(),
5989 FragmentList {
5990 fragments: vec![
5991 WebUIFragment::raw("<input"),
5992 WebUIFragment::attribute_boolean(
5993 "checked",
5994 ConditionExpr::identifier("checked"),
5995 ),
5996 WebUIFragment::raw(">"),
5997 ],
5998 contains_boundary: false,
5999 },
6000 );
6001 let protocol = WebUIProtocol::new(fragments);
6002 let state = test_json!({"checked": "yes"});
6003 let mut writer = TestWriter::new();
6004 handle(
6005 &protocol,
6006 &state,
6007 &RenderOptions::new("index.html", "/"),
6008 &mut writer,
6009 )
6010 .unwrap();
6011 assert_eq!(writer.get_content(), "<input checked>");
6012 }
6013 {
6015 let mut fragments = HashMap::new();
6016 fragments.insert(
6017 "index.html".to_string(),
6018 FragmentList {
6019 fragments: vec![
6020 WebUIFragment::raw("<input"),
6021 WebUIFragment::attribute_boolean(
6022 "checked",
6023 ConditionExpr::identifier("checked"),
6024 ),
6025 WebUIFragment::raw(">"),
6026 ],
6027 contains_boundary: false,
6028 },
6029 );
6030 let protocol = WebUIProtocol::new(fragments);
6031 let state = test_json!({"checked": {}});
6032 let mut writer = TestWriter::new();
6033 handle(
6034 &protocol,
6035 &state,
6036 &RenderOptions::new("index.html", "/"),
6037 &mut writer,
6038 )
6039 .unwrap();
6040 assert_eq!(writer.get_content(), "<input>");
6042 }
6043 {
6045 let mut fragments = HashMap::new();
6046 fragments.insert(
6047 "index.html".to_string(),
6048 FragmentList {
6049 fragments: vec![
6050 WebUIFragment::raw("<input"),
6051 WebUIFragment::attribute_boolean(
6052 "checked",
6053 ConditionExpr::identifier("checked"),
6054 ),
6055 WebUIFragment::raw(">"),
6056 ],
6057 contains_boundary: false,
6058 },
6059 );
6060 let protocol = WebUIProtocol::new(fragments);
6061 let state = test_json!({"checked": "false"});
6062 let mut writer = TestWriter::new();
6063 handle(
6064 &protocol,
6065 &state,
6066 &RenderOptions::new("index.html", "/"),
6067 &mut writer,
6068 )
6069 .unwrap();
6070 assert_eq!(writer.get_content(), "<input checked>");
6071 }
6072 }
6073
6074 #[test]
6075 fn test_boolean_attr_falsy_values() {
6076 {
6078 let mut fragments = HashMap::new();
6079 fragments.insert(
6080 "index.html".to_string(),
6081 FragmentList {
6082 fragments: vec![
6083 WebUIFragment::raw("<input"),
6084 WebUIFragment::attribute_boolean(
6085 "checked",
6086 ConditionExpr::identifier("checked"),
6087 ),
6088 WebUIFragment::raw(">"),
6089 ],
6090 contains_boundary: false,
6091 },
6092 );
6093 let protocol = WebUIProtocol::new(fragments);
6094 let state = test_json!({"checked": 0});
6095 let mut writer = TestWriter::new();
6096 handle(
6097 &protocol,
6098 &state,
6099 &RenderOptions::new("index.html", "/"),
6100 &mut writer,
6101 )
6102 .unwrap();
6103 assert_eq!(writer.get_content(), "<input>");
6104 }
6105 {
6107 let mut fragments = HashMap::new();
6108 fragments.insert(
6109 "index.html".to_string(),
6110 FragmentList {
6111 fragments: vec![
6112 WebUIFragment::raw("<input"),
6113 WebUIFragment::attribute_boolean(
6114 "checked",
6115 ConditionExpr::identifier("checked"),
6116 ),
6117 WebUIFragment::raw(">"),
6118 ],
6119 contains_boundary: false,
6120 },
6121 );
6122 let protocol = WebUIProtocol::new(fragments);
6123 let state = test_json!({"checked": ""});
6124 let mut writer = TestWriter::new();
6125 handle(
6126 &protocol,
6127 &state,
6128 &RenderOptions::new("index.html", "/"),
6129 &mut writer,
6130 )
6131 .unwrap();
6132 assert_eq!(writer.get_content(), "<input>");
6133 }
6134 {
6136 let mut fragments = HashMap::new();
6137 fragments.insert(
6138 "index.html".to_string(),
6139 FragmentList {
6140 fragments: vec![
6141 WebUIFragment::raw("<input"),
6142 WebUIFragment::attribute_boolean(
6143 "checked",
6144 ConditionExpr::identifier("checked"),
6145 ),
6146 WebUIFragment::raw(">"),
6147 ],
6148 contains_boundary: false,
6149 },
6150 );
6151 let protocol = WebUIProtocol::new(fragments);
6152 let state = test_json!({"checked": false});
6153 let mut writer = TestWriter::new();
6154 handle(
6155 &protocol,
6156 &state,
6157 &RenderOptions::new("index.html", "/"),
6158 &mut writer,
6159 )
6160 .unwrap();
6161 assert_eq!(writer.get_content(), "<input>");
6162 }
6163 {
6165 let mut fragments = HashMap::new();
6166 fragments.insert(
6167 "index.html".to_string(),
6168 FragmentList {
6169 fragments: vec![
6170 WebUIFragment::raw("<input"),
6171 WebUIFragment::attribute_boolean(
6172 "checked",
6173 ConditionExpr::identifier("checked"),
6174 ),
6175 WebUIFragment::raw(">"),
6176 ],
6177 contains_boundary: false,
6178 },
6179 );
6180 let protocol = WebUIProtocol::new(fragments);
6181 let state = test_json!({});
6182 let mut writer = TestWriter::new();
6183 handle(
6184 &protocol,
6185 &state,
6186 &RenderOptions::new("index.html", "/"),
6187 &mut writer,
6188 )
6189 .unwrap();
6190 assert_eq!(writer.get_content(), "<input>");
6191 }
6192 }
6193
6194 #[test]
6195 fn test_boolean_attr_expression_true() {
6196 let mut fragments = HashMap::new();
6197 fragments.insert(
6198 "index.html".to_string(),
6199 FragmentList {
6200 fragments: vec![
6201 WebUIFragment::raw("<button"),
6202 WebUIFragment::attribute_boolean(
6203 "disabled",
6204 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6205 ),
6206 WebUIFragment::raw(">Click</button>"),
6207 ],
6208 contains_boundary: false,
6209 },
6210 );
6211 let protocol = WebUIProtocol::new(fragments);
6212 let state = test_json!({"itemCount": 5});
6213 let mut writer = TestWriter::new();
6214 handle(
6215 &protocol,
6216 &state,
6217 &RenderOptions::new("index.html", "/"),
6218 &mut writer,
6219 )
6220 .unwrap();
6221 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6222 }
6223
6224 #[test]
6225 fn test_boolean_attr_expression_false() {
6226 let mut fragments = HashMap::new();
6227 fragments.insert(
6228 "index.html".to_string(),
6229 FragmentList {
6230 fragments: vec![
6231 WebUIFragment::raw("<button"),
6232 WebUIFragment::attribute_boolean(
6233 "disabled",
6234 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6235 ),
6236 WebUIFragment::raw(">Click</button>"),
6237 ],
6238 contains_boundary: false,
6239 },
6240 );
6241 let protocol = WebUIProtocol::new(fragments);
6242 let state = test_json!({"itemCount": 3});
6243 let mut writer = TestWriter::new();
6244 handle(
6245 &protocol,
6246 &state,
6247 &RenderOptions::new("index.html", "/"),
6248 &mut writer,
6249 )
6250 .unwrap();
6251 assert_eq!(writer.get_content(), "<button>Click</button>");
6252 }
6253
6254 #[test]
6257 fn test_nested_component_attr_capture() {
6258 let mut fragments = HashMap::new();
6259 fragments.insert(
6260 "index.html".to_string(),
6261 FragmentList {
6262 fragments: vec![
6263 WebUIFragment::raw("<parent-component"),
6264 WebUIFragment {
6265 fragment: Some(web_ui_fragment::Fragment::Attribute(
6266 WebUIFragmentAttribute {
6267 name: "title".into(),
6268 template: "parent-title".into(),
6269 attr_start: true,
6270 ..Default::default()
6271 },
6272 )),
6273 },
6274 WebUIFragment::raw(">"),
6275 WebUIFragment::component("parent-component"),
6276 WebUIFragment::raw("</parent-component>"),
6277 ],
6278
6279 contains_boundary: false,
6280 },
6281 );
6282 fragments.insert(
6283 "parent-title".to_string(),
6284 FragmentList {
6285 fragments: vec![
6286 WebUIFragment::raw("Hello "),
6287 WebUIFragment::signal("who", false),
6288 ],
6289 contains_boundary: false,
6290 },
6291 );
6292 fragments.insert(
6293 "parent-component".to_string(),
6294 FragmentList {
6295 fragments: vec![
6296 WebUIFragment::raw("<child-component"),
6297 WebUIFragment {
6298 fragment: Some(web_ui_fragment::Fragment::Attribute(
6299 WebUIFragmentAttribute {
6300 name: "title".into(),
6301 template: "child-title".into(),
6302 attr_start: true,
6303 ..Default::default()
6304 },
6305 )),
6306 },
6307 WebUIFragment::raw(">"),
6308 WebUIFragment::component("child-component"),
6309 WebUIFragment::raw("</child-component>"),
6310 ],
6311
6312 contains_boundary: false,
6313 },
6314 );
6315 fragments.insert(
6316 "child-title".to_string(),
6317 FragmentList {
6318 fragments: vec![
6319 WebUIFragment::raw("Child of "),
6320 WebUIFragment::signal("title", false),
6321 ],
6322 contains_boundary: false,
6323 },
6324 );
6325 fragments.insert(
6326 "child-component".to_string(),
6327 FragmentList {
6328 fragments: vec![
6329 WebUIFragment::raw("<span>"),
6330 WebUIFragment::signal("title", false),
6331 WebUIFragment::raw("</span>"),
6332 ],
6333 contains_boundary: false,
6334 },
6335 );
6336 let protocol = WebUIProtocol::new(fragments);
6337 let state = test_json!({"who": "<world>"});
6338 let mut writer = TestWriter::new();
6339 handle(
6340 &protocol,
6341 &state,
6342 &RenderOptions::new("index.html", "/"),
6343 &mut writer,
6344 )
6345 .unwrap();
6346 assert_eq!(
6347 writer.get_content(),
6348 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
6349 );
6350 }
6351
6352 #[test]
6353 fn test_grandchild_attr_propagation() {
6354 let mut fragments = HashMap::new();
6355 fragments.insert(
6356 "index.html".to_string(),
6357 FragmentList {
6358 fragments: vec![
6359 WebUIFragment::raw("<parent-component"),
6360 WebUIFragment {
6361 fragment: Some(web_ui_fragment::Fragment::Attribute(
6362 WebUIFragmentAttribute {
6363 name: "title".into(),
6364 template: "p-title".into(),
6365 attr_start: true,
6366 ..Default::default()
6367 },
6368 )),
6369 },
6370 WebUIFragment::raw(">"),
6371 WebUIFragment::component("parent-component"),
6372 WebUIFragment::raw("</parent-component>"),
6373 ],
6374
6375 contains_boundary: false,
6376 },
6377 );
6378 fragments.insert(
6379 "p-title".to_string(),
6380 FragmentList {
6381 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6382 contains_boundary: false,
6383 },
6384 );
6385 fragments.insert(
6386 "parent-component".to_string(),
6387 FragmentList {
6388 fragments: vec![
6389 WebUIFragment::raw("<child-component"),
6390 WebUIFragment {
6391 fragment: Some(web_ui_fragment::Fragment::Attribute(
6392 WebUIFragmentAttribute {
6393 name: "title".into(),
6394 template: "c-title".into(),
6395 attr_start: true,
6396 ..Default::default()
6397 },
6398 )),
6399 },
6400 WebUIFragment::raw(">"),
6401 WebUIFragment::component("child-component"),
6402 WebUIFragment::raw("</child-component>"),
6403 ],
6404
6405 contains_boundary: false,
6406 },
6407 );
6408 fragments.insert(
6409 "c-title".to_string(),
6410 FragmentList {
6411 fragments: vec![
6412 WebUIFragment::raw("C("),
6413 WebUIFragment::signal("title", false),
6414 WebUIFragment::raw(")-"),
6415 WebUIFragment::signal("cExtra", false),
6416 ],
6417 contains_boundary: false,
6418 },
6419 );
6420 fragments.insert(
6421 "child-component".to_string(),
6422 FragmentList {
6423 fragments: vec![
6424 WebUIFragment::raw("<grandchild-component"),
6425 WebUIFragment {
6426 fragment: Some(web_ui_fragment::Fragment::Attribute(
6427 WebUIFragmentAttribute {
6428 name: "title".into(),
6429 value: "title".into(),
6430 attr_start: true,
6431 ..Default::default()
6432 },
6433 )),
6434 },
6435 WebUIFragment::raw(">"),
6436 WebUIFragment::component("grandchild-component"),
6437 WebUIFragment::raw("</grandchild-component>"),
6438 ],
6439
6440 contains_boundary: false,
6441 },
6442 );
6443 fragments.insert(
6444 "grandchild-component".to_string(),
6445 FragmentList {
6446 fragments: vec![
6447 WebUIFragment::raw("<span>"),
6448 WebUIFragment::signal("title", false),
6449 WebUIFragment::raw("</span>"),
6450 ],
6451 contains_boundary: false,
6452 },
6453 );
6454 let protocol = WebUIProtocol::new(fragments);
6455 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6456 let mut writer = TestWriter::new();
6457 handle(
6458 &protocol,
6459 &state,
6460 &RenderOptions::new("index.html", "/"),
6461 &mut writer,
6462 )
6463 .unwrap();
6464 assert_eq!(
6465 writer.get_content(),
6466 "<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>"
6467 );
6468 }
6469
6470 #[test]
6471 fn test_for_loop_component_attr() {
6472 let mut fragments = HashMap::new();
6473 fragments.insert(
6474 "index.html".to_string(),
6475 FragmentList {
6476 fragments: vec![
6477 WebUIFragment::raw("<parent-component"),
6478 WebUIFragment {
6479 fragment: Some(web_ui_fragment::Fragment::Attribute(
6480 WebUIFragmentAttribute {
6481 name: "title".into(),
6482 template: "parent-title-loop".into(),
6483 attr_start: true,
6484 ..Default::default()
6485 },
6486 )),
6487 },
6488 WebUIFragment::raw(">"),
6489 WebUIFragment::component("parent-component"),
6490 WebUIFragment::raw("</parent-component>"),
6491 ],
6492
6493 contains_boundary: false,
6494 },
6495 );
6496 fragments.insert(
6497 "parent-title-loop".to_string(),
6498 FragmentList {
6499 fragments: vec![
6500 WebUIFragment::raw("Parent:"),
6501 WebUIFragment::signal("who", false),
6502 ],
6503 contains_boundary: false,
6504 },
6505 );
6506 fragments.insert(
6507 "parent-component".to_string(),
6508 FragmentList {
6509 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6510 contains_boundary: false,
6511 },
6512 );
6513 fragments.insert(
6514 "child-loop".to_string(),
6515 FragmentList {
6516 fragments: vec![
6517 WebUIFragment::raw("<child-component"),
6518 WebUIFragment {
6519 fragment: Some(web_ui_fragment::Fragment::Attribute(
6520 WebUIFragmentAttribute {
6521 name: "title".into(),
6522 template: "child-title-loop".into(),
6523 attr_start: true,
6524 ..Default::default()
6525 },
6526 )),
6527 },
6528 WebUIFragment::raw(">"),
6529 WebUIFragment::component("child-component"),
6530 WebUIFragment::raw("</child-component>"),
6531 ],
6532
6533 contains_boundary: false,
6534 },
6535 );
6536 fragments.insert(
6537 "child-title-loop".to_string(),
6538 FragmentList {
6539 fragments: vec![
6540 WebUIFragment::raw("Hi "),
6541 WebUIFragment::signal("item.name", false),
6542 WebUIFragment::raw(" / "),
6543 WebUIFragment::signal("title", false),
6544 ],
6545 contains_boundary: false,
6546 },
6547 );
6548 fragments.insert(
6549 "child-component".to_string(),
6550 FragmentList {
6551 fragments: vec![
6552 WebUIFragment::raw("<span>"),
6553 WebUIFragment::signal("title", false),
6554 WebUIFragment::raw("</span>"),
6555 ],
6556 contains_boundary: false,
6557 },
6558 );
6559 let protocol = WebUIProtocol::new(fragments);
6560 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6561 let mut writer = TestWriter::new();
6562 handle(
6563 &protocol,
6564 &state,
6565 &RenderOptions::new("index.html", "/"),
6566 &mut writer,
6567 )
6568 .unwrap();
6569 assert_eq!(
6570 writer.get_content(),
6571 "<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>"
6572 );
6573 }
6574
6575 #[test]
6576 fn test_multiple_template_attrs() {
6577 let mut fragments = HashMap::new();
6578 fragments.insert(
6579 "index.html".to_string(),
6580 FragmentList {
6581 fragments: vec![
6582 WebUIFragment::raw("<my-component"),
6583 WebUIFragment {
6584 fragment: Some(web_ui_fragment::Fragment::Attribute(
6585 WebUIFragmentAttribute {
6586 name: "title".into(),
6587 template: "attr-title".into(),
6588 attr_start: true,
6589 ..Default::default()
6590 },
6591 )),
6592 },
6593 WebUIFragment {
6594 fragment: Some(web_ui_fragment::Fragment::Attribute(
6595 WebUIFragmentAttribute {
6596 name: "data-title".into(),
6597 template: "attr-data-title".into(),
6598 attr_start: false,
6599 ..Default::default()
6600 },
6601 )),
6602 },
6603 WebUIFragment {
6604 fragment: Some(web_ui_fragment::Fragment::Attribute(
6605 WebUIFragmentAttribute {
6606 name: "aria-label".into(),
6607 template: "attr-aria-label".into(),
6608 attr_start: false,
6609 ..Default::default()
6610 },
6611 )),
6612 },
6613 WebUIFragment::raw(">"),
6614 WebUIFragment::component("my-component"),
6615 WebUIFragment::raw("</my-component>"),
6616 ],
6617
6618 contains_boundary: false,
6619 },
6620 );
6621 fragments.insert(
6622 "attr-title".to_string(),
6623 FragmentList {
6624 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6625 contains_boundary: false,
6626 },
6627 );
6628 fragments.insert(
6629 "attr-data-title".to_string(),
6630 FragmentList {
6631 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6632 contains_boundary: false,
6633 },
6634 );
6635 fragments.insert(
6636 "attr-aria-label".to_string(),
6637 FragmentList {
6638 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6639 contains_boundary: false,
6640 },
6641 );
6642 fragments.insert(
6643 "my-component".to_string(),
6644 FragmentList {
6645 fragments: vec![
6646 WebUIFragment::raw("<span>"),
6647 WebUIFragment::signal("title", false),
6648 WebUIFragment::raw("|"),
6649 WebUIFragment::signal("dataTitle", false),
6650 WebUIFragment::raw("|"),
6651 WebUIFragment::signal("ariaLabel", false),
6652 WebUIFragment::raw("</span>"),
6653 ],
6654 contains_boundary: false,
6655 },
6656 );
6657 let protocol = WebUIProtocol::new(fragments);
6658 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6659 let mut writer = TestWriter::new();
6660 handle(
6661 &protocol,
6662 &state,
6663 &RenderOptions::new("index.html", "/"),
6664 &mut writer,
6665 )
6666 .unwrap();
6667 assert_eq!(
6668 writer.get_content(),
6669 "<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>"
6670 );
6671 }
6672
6673 #[test]
6674 fn test_attr_priority_over_global() {
6675 let mut fragments = HashMap::new();
6676 fragments.insert(
6677 "index.html".to_string(),
6678 FragmentList {
6679 fragments: vec![
6680 WebUIFragment::raw("<my-component"),
6681 WebUIFragment {
6682 fragment: Some(web_ui_fragment::Fragment::Attribute(
6683 WebUIFragmentAttribute {
6684 name: "title".into(),
6685 value: "Attribute Title".into(),
6686 raw_value: true,
6687 attr_start: true,
6688 ..Default::default()
6689 },
6690 )),
6691 },
6692 WebUIFragment::raw(">"),
6693 WebUIFragment::component("my-component"),
6694 WebUIFragment::raw("</my-component>"),
6695 ],
6696
6697 contains_boundary: false,
6698 },
6699 );
6700 fragments.insert(
6701 "my-component".to_string(),
6702 FragmentList {
6703 fragments: vec![
6704 WebUIFragment::raw("<span>"),
6705 WebUIFragment::signal("title", false),
6706 WebUIFragment::raw("</span>"),
6707 ],
6708 contains_boundary: false,
6709 },
6710 );
6711 let protocol = WebUIProtocol::new(fragments);
6712 let state = test_json!({"title": "Global Title"});
6713 let mut writer = TestWriter::new();
6714 handle(
6715 &protocol,
6716 &state,
6717 &RenderOptions::new("index.html", "/"),
6718 &mut writer,
6719 )
6720 .unwrap();
6721 assert_eq!(
6722 writer.get_content(),
6723 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6724 );
6725 }
6726
6727 #[test]
6728 fn test_attr_priority_over_local_and_global() {
6729 let mut fragments = HashMap::new();
6730 fragments.insert(
6731 "index.html".to_string(),
6732 FragmentList {
6733 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6734 contains_boundary: false,
6735 },
6736 );
6737 fragments.insert(
6738 "loop".to_string(),
6739 FragmentList {
6740 fragments: vec![
6741 WebUIFragment::raw("<my-component"),
6742 WebUIFragment {
6743 fragment: Some(web_ui_fragment::Fragment::Attribute(
6744 WebUIFragmentAttribute {
6745 name: "title".into(),
6746 value: "Attribute Title".into(),
6747 raw_value: true,
6748 attr_start: true,
6749 ..Default::default()
6750 },
6751 )),
6752 },
6753 WebUIFragment::raw(">"),
6754 WebUIFragment::component("my-component"),
6755 WebUIFragment::raw("</my-component>"),
6756 ],
6757
6758 contains_boundary: false,
6759 },
6760 );
6761 fragments.insert(
6762 "my-component".to_string(),
6763 FragmentList {
6764 fragments: vec![
6765 WebUIFragment::raw("<span>"),
6766 WebUIFragment::signal("title", false),
6767 WebUIFragment::raw("</span>"),
6768 ],
6769 contains_boundary: false,
6770 },
6771 );
6772 let protocol = WebUIProtocol::new(fragments);
6773 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6774 let mut writer = TestWriter::new();
6775 handle(
6776 &protocol,
6777 &state,
6778 &RenderOptions::new("index.html", "/"),
6779 &mut writer,
6780 )
6781 .unwrap();
6782 assert_eq!(
6783 writer.get_content(),
6784 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6785 );
6786 }
6787
6788 #[test]
6789 fn test_boolean_attr_first_component_attr() {
6790 let mut fragments = HashMap::new();
6791 fragments.insert(
6792 "index.html".to_string(),
6793 FragmentList {
6794 fragments: vec![
6795 WebUIFragment::raw("<my-component"),
6796 WebUIFragment {
6797 fragment: Some(web_ui_fragment::Fragment::Attribute(
6798 WebUIFragmentAttribute {
6799 name: "disabled".into(),
6800 attr_start: true,
6801 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6802 ..Default::default()
6803 },
6804 )),
6805 },
6806 WebUIFragment {
6807 fragment: Some(web_ui_fragment::Fragment::Attribute(
6808 WebUIFragmentAttribute {
6809 name: "label".into(),
6810 value: "Component Label".into(),
6811 raw_value: true,
6812 attr_start: false,
6813 ..Default::default()
6814 },
6815 )),
6816 },
6817 WebUIFragment::raw(">"),
6818 WebUIFragment::component("my-component"),
6819 WebUIFragment::raw("</my-component>"),
6820 ],
6821
6822 contains_boundary: false,
6823 },
6824 );
6825 fragments.insert(
6826 "my-component".to_string(),
6827 FragmentList {
6828 fragments: vec![
6829 WebUIFragment::if_cond(
6830 ConditionExpr::identifier("disabled"),
6831 "disabledTemplate",
6832 ),
6833 WebUIFragment::raw("<span>"),
6834 WebUIFragment::signal("label", false),
6835 WebUIFragment::raw("</span>"),
6836 ],
6837 contains_boundary: false,
6838 },
6839 );
6840 fragments.insert(
6841 "disabledTemplate".to_string(),
6842 FragmentList {
6843 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6844 contains_boundary: false,
6845 },
6846 );
6847 let protocol = WebUIProtocol::new(fragments);
6848 let state = test_json!({"isDisabled": true});
6849 let mut writer = TestWriter::new();
6850 handle(
6851 &protocol,
6852 &state,
6853 &RenderOptions::new("index.html", "/"),
6854 &mut writer,
6855 )
6856 .unwrap();
6857 assert_eq!(
6858 writer.get_content(),
6859 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
6860 );
6861 }
6862
6863 #[test]
6864 fn test_hyphenated_attr_camelcase() {
6865 let mut fragments = HashMap::new();
6866 fragments.insert(
6867 "index.html".to_string(),
6868 FragmentList {
6869 fragments: vec![
6870 WebUIFragment::raw("<my-component"),
6871 WebUIFragment {
6872 fragment: Some(web_ui_fragment::Fragment::Attribute(
6873 WebUIFragmentAttribute {
6874 name: "key-hyphen".into(),
6875 value: "Local Value".into(),
6876 raw_value: true,
6877 attr_start: true,
6878 ..Default::default()
6879 },
6880 )),
6881 },
6882 WebUIFragment::raw(">"),
6883 WebUIFragment::component("my-component"),
6884 WebUIFragment::raw("</my-component>"),
6885 ],
6886
6887 contains_boundary: false,
6888 },
6889 );
6890 fragments.insert(
6891 "my-component".to_string(),
6892 FragmentList {
6893 fragments: vec![
6894 WebUIFragment::raw("<span>"),
6895 WebUIFragment::signal("keyHyphen", false),
6896 WebUIFragment::raw("</span>"),
6897 ],
6898 contains_boundary: false,
6899 },
6900 );
6901 let protocol = WebUIProtocol::new(fragments);
6902 let state = test_json!({"keyHyphen": "Global Value"});
6903 let mut writer = TestWriter::new();
6904 handle(
6905 &protocol,
6906 &state,
6907 &RenderOptions::new("index.html", "/"),
6908 &mut writer,
6909 )
6910 .unwrap();
6911 assert_eq!(
6912 writer.get_content(),
6913 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
6914 );
6915 }
6916
6917 #[test]
6918 fn test_skipped_component_attrs() {
6919 let mut fragments = HashMap::new();
6923 fragments.insert(
6924 "index.html".to_string(),
6925 FragmentList {
6926 fragments: vec![
6927 WebUIFragment::raw("<test-component"),
6928 WebUIFragment {
6930 fragment: Some(web_ui_fragment::Fragment::Attribute(
6931 WebUIFragmentAttribute {
6932 name: "class".into(),
6933 value: "skippedClass".into(),
6934 attr_start: true,
6935 attr_skip: true,
6936 ..Default::default()
6937 },
6938 )),
6939 },
6940 WebUIFragment {
6942 fragment: Some(web_ui_fragment::Fragment::Attribute(
6943 WebUIFragmentAttribute {
6944 name: "style".into(),
6945 value: "skippedStyle".into(),
6946 attr_skip: true,
6947 ..Default::default()
6948 },
6949 )),
6950 },
6951 WebUIFragment {
6953 fragment: Some(web_ui_fragment::Fragment::Attribute(
6954 WebUIFragmentAttribute {
6955 name: "role".into(),
6956 value: "skippedRole".into(),
6957 attr_skip: true,
6958 ..Default::default()
6959 },
6960 )),
6961 },
6962 WebUIFragment {
6964 fragment: Some(web_ui_fragment::Fragment::Attribute(
6965 WebUIFragmentAttribute {
6966 name: "data-testid".into(),
6967 value: "skippedDataTestid".into(),
6968 attr_skip: true,
6969 ..Default::default()
6970 },
6971 )),
6972 },
6973 WebUIFragment {
6975 fragment: Some(web_ui_fragment::Fragment::Attribute(
6976 WebUIFragmentAttribute {
6977 name: "aria-label".into(),
6978 value: "skippedAriaLabel".into(),
6979 attr_skip: true,
6980 ..Default::default()
6981 },
6982 )),
6983 },
6984 WebUIFragment {
6986 fragment: Some(web_ui_fragment::Fragment::Attribute(
6987 WebUIFragmentAttribute {
6988 name: "title".into(),
6989 value: "title".into(),
6990 ..Default::default()
6991 },
6992 )),
6993 },
6994 WebUIFragment::raw(">"),
6995 WebUIFragment::component("test-component"),
6996 WebUIFragment::raw("</test-component>"),
6997 ],
6998
6999 contains_boundary: false,
7000 },
7001 );
7002 fragments.insert(
7003 "test-component".to_string(),
7004 FragmentList {
7005 fragments: vec![
7006 WebUIFragment::raw("<span>"),
7007 WebUIFragment::signal("title", false),
7008 WebUIFragment::raw("-"),
7009 WebUIFragment::signal("class", false),
7010 WebUIFragment::raw("-"),
7011 WebUIFragment::signal("style", false),
7012 WebUIFragment::raw("-"),
7013 WebUIFragment::signal("role", false),
7014 WebUIFragment::raw("-"),
7015 WebUIFragment::signal("dataTestid", false),
7016 WebUIFragment::raw("-"),
7017 WebUIFragment::signal("ariaLabel", false),
7018 WebUIFragment::raw("</span>"),
7019 ],
7020 contains_boundary: false,
7021 },
7022 );
7023 let protocol = WebUIProtocol::new(fragments);
7024 let state = test_json!({
7025 "title": "Hello",
7026 "skippedClass": "my-class",
7027 "skippedStyle": "color:red",
7028 "skippedRole": "button",
7029 "skippedDataTestid": "test-id",
7030 "skippedAriaLabel": "label-text"
7031 });
7032 let mut writer = TestWriter::new();
7033 handle(
7034 &protocol,
7035 &state,
7036 &RenderOptions::new("index.html", "/"),
7037 &mut writer,
7038 )
7039 .unwrap();
7040 assert_eq!(
7044 writer.get_content(),
7045 "<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>"
7046 );
7047 }
7048
7049 #[test]
7052 fn test_attr_inherit_parent_to_child() {
7053 let mut fragments = HashMap::new();
7054 fragments.insert(
7055 "index.html".to_string(),
7056 FragmentList {
7057 fragments: vec![
7058 WebUIFragment::raw("<parent-component"),
7059 WebUIFragment {
7060 fragment: Some(web_ui_fragment::Fragment::Attribute(
7061 WebUIFragmentAttribute {
7062 name: "title".into(),
7063 value: "Parent Title".into(),
7064 raw_value: true,
7065 attr_start: true,
7066 ..Default::default()
7067 },
7068 )),
7069 },
7070 WebUIFragment::raw(">"),
7071 WebUIFragment::component("parent-component"),
7072 WebUIFragment::raw("</parent-component>"),
7073 ],
7074
7075 contains_boundary: false,
7076 },
7077 );
7078 fragments.insert(
7079 "parent-component".to_string(),
7080 FragmentList {
7081 fragments: vec![
7082 WebUIFragment::raw("<h1>"),
7083 WebUIFragment::signal("title", false),
7084 WebUIFragment::raw("</h1><child-component"),
7085 WebUIFragment {
7086 fragment: Some(web_ui_fragment::Fragment::Attribute(
7087 WebUIFragmentAttribute {
7088 name: "title".into(),
7089 value: "title".into(),
7090 attr_start: true,
7091 ..Default::default()
7092 },
7093 )),
7094 },
7095 WebUIFragment::raw(">"),
7096 WebUIFragment::component("child-component"),
7097 WebUIFragment::raw("</child-component>"),
7098 ],
7099
7100 contains_boundary: false,
7101 },
7102 );
7103 fragments.insert(
7104 "child-component".to_string(),
7105 FragmentList {
7106 fragments: vec![
7107 WebUIFragment::raw("<h2>"),
7108 WebUIFragment::signal("title", false),
7109 WebUIFragment::raw("</h2>"),
7110 ],
7111 contains_boundary: false,
7112 },
7113 );
7114 let protocol = WebUIProtocol::new(fragments);
7115 let state = test_json!({});
7116 let mut writer = TestWriter::new();
7117 handle(
7118 &protocol,
7119 &state,
7120 &RenderOptions::new("index.html", "/"),
7121 &mut writer,
7122 )
7123 .unwrap();
7124 assert_eq!(
7125 writer.get_content(),
7126 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7127 );
7128 }
7129
7130 #[test]
7131 fn test_attr_inherit_deep() {
7132 let mut fragments = HashMap::new();
7133 fragments.insert(
7134 "index.html".to_string(),
7135 FragmentList {
7136 fragments: vec![
7137 WebUIFragment::raw("<parent-component"),
7138 WebUIFragment {
7139 fragment: Some(web_ui_fragment::Fragment::Attribute(
7140 WebUIFragmentAttribute {
7141 name: "title".into(),
7142 value: "Parent Title".into(),
7143 raw_value: true,
7144 attr_start: true,
7145 ..Default::default()
7146 },
7147 )),
7148 },
7149 WebUIFragment::raw(">"),
7150 WebUIFragment::component("parent-component"),
7151 WebUIFragment::raw("</parent-component>"),
7152 ],
7153
7154 contains_boundary: false,
7155 },
7156 );
7157 fragments.insert(
7158 "parent-component".to_string(),
7159 FragmentList {
7160 fragments: vec![
7161 WebUIFragment::raw("<child-component"),
7162 WebUIFragment {
7163 fragment: Some(web_ui_fragment::Fragment::Attribute(
7164 WebUIFragmentAttribute {
7165 name: "title".into(),
7166 value: "Child Title".into(),
7167 raw_value: true,
7168 attr_start: true,
7169 ..Default::default()
7170 },
7171 )),
7172 },
7173 WebUIFragment::raw(">"),
7174 WebUIFragment::component("child-component"),
7175 WebUIFragment::raw("</child-component>"),
7176 ],
7177
7178 contains_boundary: false,
7179 },
7180 );
7181 fragments.insert(
7182 "child-component".to_string(),
7183 FragmentList {
7184 fragments: vec![
7185 WebUIFragment::raw("<grandchild-component"),
7186 WebUIFragment {
7187 fragment: Some(web_ui_fragment::Fragment::Attribute(
7188 WebUIFragmentAttribute {
7189 name: "title".into(),
7190 value: "title".into(),
7191 attr_start: true,
7192 ..Default::default()
7193 },
7194 )),
7195 },
7196 WebUIFragment::raw(">"),
7197 WebUIFragment::component("grandchild-component"),
7198 WebUIFragment::raw("</grandchild-component>"),
7199 ],
7200
7201 contains_boundary: false,
7202 },
7203 );
7204 fragments.insert(
7205 "grandchild-component".to_string(),
7206 FragmentList {
7207 fragments: vec![
7208 WebUIFragment::raw("<h3>"),
7209 WebUIFragment::signal("title", false),
7210 WebUIFragment::raw("</h3>"),
7211 ],
7212 contains_boundary: false,
7213 },
7214 );
7215 let protocol = WebUIProtocol::new(fragments);
7216 let state = test_json!({});
7217 let mut writer = TestWriter::new();
7218 handle(
7219 &protocol,
7220 &state,
7221 &RenderOptions::new("index.html", "/"),
7222 &mut writer,
7223 )
7224 .unwrap();
7225 assert_eq!(
7226 writer.get_content(),
7227 "<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>"
7228 );
7229 }
7230
7231 #[test]
7232 fn test_complex_attr_access() {
7233 let mut fragments = HashMap::new();
7234 fragments.insert(
7235 "index.html".to_string(),
7236 FragmentList {
7237 fragments: vec![
7238 WebUIFragment::raw("<my-component"),
7239 WebUIFragment {
7240 fragment: Some(web_ui_fragment::Fragment::Attribute(
7241 WebUIFragmentAttribute {
7242 name: ":item".into(),
7243 value: "complexItem".into(),
7244 attr_start: true,
7245 complex: true,
7246 ..Default::default()
7247 },
7248 )),
7249 },
7250 WebUIFragment::raw(">"),
7251 WebUIFragment::component("my-component"),
7252 WebUIFragment::raw("</my-component>"),
7253 ],
7254
7255 contains_boundary: false,
7256 },
7257 );
7258 fragments.insert(
7259 "my-component".to_string(),
7260 FragmentList {
7261 fragments: vec![
7262 WebUIFragment::raw("<span>"),
7263 WebUIFragment::signal("item.foo", false),
7264 WebUIFragment::raw("</span><p>"),
7265 WebUIFragment::signal("item.bar", false),
7266 WebUIFragment::raw("</p>"),
7267 ],
7268 contains_boundary: false,
7269 },
7270 );
7271 let protocol = WebUIProtocol::new(fragments);
7272 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7273 let mut writer = TestWriter::new();
7274 handle(
7275 &protocol,
7276 &state,
7277 &RenderOptions::new("index.html", "/"),
7278 &mut writer,
7279 )
7280 .unwrap();
7281 assert_eq!(
7282 writer.get_content(),
7283 "<my-component><span>1</span><p>true</p></my-component>"
7284 );
7285 }
7286
7287 #[test]
7288 fn test_complex_attr_for_loop() {
7289 let mut fragments = HashMap::new();
7290 fragments.insert(
7291 "index.html".to_string(),
7292 FragmentList {
7293 fragments: vec![WebUIFragment::for_loop(
7294 "item",
7295 "list.items",
7296 "listTemplate",
7297 )],
7298 contains_boundary: false,
7299 },
7300 );
7301 fragments.insert(
7302 "listTemplate".to_string(),
7303 FragmentList {
7304 fragments: vec![
7305 WebUIFragment {
7306 fragment: Some(web_ui_fragment::Fragment::Attribute(
7307 WebUIFragmentAttribute {
7308 name: ":item".into(),
7309 value: "item".into(),
7310 attr_start: true,
7311 complex: true,
7312 ..Default::default()
7313 },
7314 )),
7315 },
7316 WebUIFragment::component("item_component"),
7317 ],
7318
7319 contains_boundary: false,
7320 },
7321 );
7322 fragments.insert(
7323 "item_component".to_string(),
7324 FragmentList {
7325 fragments: vec![
7326 WebUIFragment::raw("<span>"),
7327 WebUIFragment::signal("item.name", false),
7328 WebUIFragment::raw("</span>"),
7329 ],
7330 contains_boundary: false,
7331 },
7332 );
7333 let protocol = WebUIProtocol::new(fragments);
7334 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7335 let mut writer = TestWriter::new();
7336 handle(
7337 &protocol,
7338 &state,
7339 &RenderOptions::new("index.html", "/"),
7340 &mut writer,
7341 )
7342 .unwrap();
7343 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7344 }
7345
7346 #[test]
7347 fn test_complex_attr_nested_for() {
7348 let mut fragments = HashMap::new();
7349 fragments.insert(
7350 "index.html".to_string(),
7351 FragmentList {
7352 fragments: vec![WebUIFragment::for_loop(
7353 "outer",
7354 "data.outer",
7355 "outerTemplate",
7356 )],
7357 contains_boundary: false,
7358 },
7359 );
7360 fragments.insert(
7361 "outerTemplate".to_string(),
7362 FragmentList {
7363 fragments: vec![WebUIFragment::for_loop(
7364 "middle",
7365 "outer.middle",
7366 "middleTemplate",
7367 )],
7368 contains_boundary: false,
7369 },
7370 );
7371 fragments.insert(
7372 "middleTemplate".to_string(),
7373 FragmentList {
7374 fragments: vec![WebUIFragment::for_loop(
7375 "inner",
7376 "middle.inner",
7377 "innerTemplate",
7378 )],
7379 contains_boundary: false,
7380 },
7381 );
7382 fragments.insert(
7383 "innerTemplate".to_string(),
7384 FragmentList {
7385 fragments: vec![
7386 WebUIFragment::raw("<card"),
7387 WebUIFragment {
7388 fragment: Some(web_ui_fragment::Fragment::Attribute(
7389 WebUIFragmentAttribute {
7390 name: ":outer".into(),
7391 value: "outer".into(),
7392 attr_start: true,
7393 complex: true,
7394 ..Default::default()
7395 },
7396 )),
7397 },
7398 WebUIFragment {
7399 fragment: Some(web_ui_fragment::Fragment::Attribute(
7400 WebUIFragmentAttribute {
7401 name: ":middle".into(),
7402 value: "middle".into(),
7403 attr_start: false,
7404 complex: true,
7405 ..Default::default()
7406 },
7407 )),
7408 },
7409 WebUIFragment {
7410 fragment: Some(web_ui_fragment::Fragment::Attribute(
7411 WebUIFragmentAttribute {
7412 name: ":inner".into(),
7413 value: "inner".into(),
7414 attr_start: false,
7415 complex: true,
7416 ..Default::default()
7417 },
7418 )),
7419 },
7420 WebUIFragment::raw(">"),
7421 WebUIFragment::component("card_component"),
7422 WebUIFragment::raw("</card>"),
7423 ],
7424
7425 contains_boundary: false,
7426 },
7427 );
7428 fragments.insert(
7429 "card_component".to_string(),
7430 FragmentList {
7431 fragments: vec![
7432 WebUIFragment::raw("<p>"),
7433 WebUIFragment::signal("outer.label", false),
7434 WebUIFragment::raw(" / "),
7435 WebUIFragment::signal("middle.label", false),
7436 WebUIFragment::raw(" / "),
7437 WebUIFragment::signal("inner.label", false),
7438 WebUIFragment::raw("</p>"),
7439 ],
7440 contains_boundary: false,
7441 },
7442 );
7443 let protocol = WebUIProtocol::new(fragments);
7444 let state = test_json!({"data": {"outer": [
7445 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7446 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7447 ]}});
7448 let mut writer = TestWriter::new();
7449 handle(
7450 &protocol,
7451 &state,
7452 &RenderOptions::new("index.html", "/"),
7453 &mut writer,
7454 )
7455 .unwrap();
7456 assert_eq!(
7457 writer.get_content(),
7458 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7459 );
7460 }
7461
7462 #[test]
7465 fn test_bool_component_state_true() {
7466 let mut fragments = HashMap::new();
7467 fragments.insert(
7468 "index.html".to_string(),
7469 FragmentList {
7470 fragments: vec![
7471 WebUIFragment::raw("<my-component"),
7472 WebUIFragment {
7473 fragment: Some(web_ui_fragment::Fragment::Attribute(
7474 WebUIFragmentAttribute {
7475 name: "disabled".into(),
7476 attr_start: true,
7477 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7478 ..Default::default()
7479 },
7480 )),
7481 },
7482 WebUIFragment::raw(">"),
7483 WebUIFragment::component("my-component"),
7484 WebUIFragment::raw("</my-component>"),
7485 ],
7486
7487 contains_boundary: false,
7488 },
7489 );
7490 fragments.insert(
7491 "my-component".to_string(),
7492 FragmentList {
7493 fragments: vec![
7494 WebUIFragment::if_cond(
7495 ConditionExpr::identifier("disabled"),
7496 "disabledTemplate",
7497 ),
7498 WebUIFragment::if_cond(
7499 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7500 "enabledTemplate",
7501 ),
7502 ],
7503 contains_boundary: false,
7504 },
7505 );
7506 fragments.insert(
7507 "disabledTemplate".to_string(),
7508 FragmentList {
7509 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7510 contains_boundary: false,
7511 },
7512 );
7513 fragments.insert(
7514 "enabledTemplate".to_string(),
7515 FragmentList {
7516 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7517 contains_boundary: false,
7518 },
7519 );
7520 let protocol = WebUIProtocol::new(fragments);
7521 let state = test_json!({"isDisabled": true});
7522 let mut writer = TestWriter::new();
7523 handle(
7524 &protocol,
7525 &state,
7526 &RenderOptions::new("index.html", "/"),
7527 &mut writer,
7528 )
7529 .unwrap();
7530 assert_eq!(
7531 writer.get_content(),
7532 "<my-component disabled><span>Disabled</span></my-component>"
7533 );
7534 }
7535
7536 #[test]
7537 fn test_bool_component_state_false() {
7538 let mut fragments = HashMap::new();
7539 fragments.insert(
7540 "index.html".to_string(),
7541 FragmentList {
7542 fragments: vec![
7543 WebUIFragment::raw("<my-component"),
7544 WebUIFragment {
7545 fragment: Some(web_ui_fragment::Fragment::Attribute(
7546 WebUIFragmentAttribute {
7547 name: "disabled".into(),
7548 attr_start: true,
7549 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7550 ..Default::default()
7551 },
7552 )),
7553 },
7554 WebUIFragment::raw(">"),
7555 WebUIFragment::component("my-component"),
7556 WebUIFragment::raw("</my-component>"),
7557 ],
7558
7559 contains_boundary: false,
7560 },
7561 );
7562 fragments.insert(
7563 "my-component".to_string(),
7564 FragmentList {
7565 fragments: vec![
7566 WebUIFragment::if_cond(
7567 ConditionExpr::identifier("disabled"),
7568 "disabledTemplate",
7569 ),
7570 WebUIFragment::if_cond(
7571 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7572 "enabledTemplate",
7573 ),
7574 ],
7575 contains_boundary: false,
7576 },
7577 );
7578 fragments.insert(
7579 "disabledTemplate".to_string(),
7580 FragmentList {
7581 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7582 contains_boundary: false,
7583 },
7584 );
7585 fragments.insert(
7586 "enabledTemplate".to_string(),
7587 FragmentList {
7588 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7589 contains_boundary: false,
7590 },
7591 );
7592 let protocol = WebUIProtocol::new(fragments);
7593 let state = test_json!({"isDisabled": false});
7594 let mut writer = TestWriter::new();
7595 handle(
7596 &protocol,
7597 &state,
7598 &RenderOptions::new("index.html", "/"),
7599 &mut writer,
7600 )
7601 .unwrap();
7602 assert_eq!(
7603 writer.get_content(),
7604 "<my-component><span>Enabled</span></my-component>"
7605 );
7606 }
7607
7608 #[test]
7609 fn test_bool_component_state_forward() {
7610 let mut fragments = HashMap::new();
7611 fragments.insert(
7612 "index.html".to_string(),
7613 FragmentList {
7614 fragments: vec![
7615 WebUIFragment::raw("<parent-component"),
7616 WebUIFragment {
7617 fragment: Some(web_ui_fragment::Fragment::Attribute(
7618 WebUIFragmentAttribute {
7619 name: "disabled".into(),
7620 attr_start: true,
7621 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7622 ..Default::default()
7623 },
7624 )),
7625 },
7626 WebUIFragment::raw(">"),
7627 WebUIFragment::component("parent-component"),
7628 WebUIFragment::raw("</parent-component>"),
7629 ],
7630
7631 contains_boundary: false,
7632 },
7633 );
7634 fragments.insert(
7635 "parent-component".to_string(),
7636 FragmentList {
7637 fragments: vec![
7638 WebUIFragment::if_cond(
7639 ConditionExpr::identifier("disabled"),
7640 "parentDisabledTemplate",
7641 ),
7642 WebUIFragment::raw("<child-component"),
7643 WebUIFragment {
7644 fragment: Some(web_ui_fragment::Fragment::Attribute(
7645 WebUIFragmentAttribute {
7646 name: "disabled".into(),
7647 attr_start: true,
7648 condition_tree: Some(ConditionExpr::identifier("disabled")),
7649 ..Default::default()
7650 },
7651 )),
7652 },
7653 WebUIFragment::raw(">"),
7654 WebUIFragment::component("child-component"),
7655 WebUIFragment::raw("</child-component>"),
7656 ],
7657
7658 contains_boundary: false,
7659 },
7660 );
7661 fragments.insert(
7662 "parentDisabledTemplate".to_string(),
7663 FragmentList {
7664 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7665 contains_boundary: false,
7666 },
7667 );
7668 fragments.insert(
7669 "child-component".to_string(),
7670 FragmentList {
7671 fragments: vec![
7672 WebUIFragment::if_cond(
7673 ConditionExpr::identifier("disabled"),
7674 "childDisabledTemplate",
7675 ),
7676 WebUIFragment::if_cond(
7677 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7678 "childEnabledTemplate",
7679 ),
7680 ],
7681 contains_boundary: false,
7682 },
7683 );
7684 fragments.insert(
7685 "childDisabledTemplate".to_string(),
7686 FragmentList {
7687 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7688 contains_boundary: false,
7689 },
7690 );
7691 fragments.insert(
7692 "childEnabledTemplate".to_string(),
7693 FragmentList {
7694 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7695 contains_boundary: false,
7696 },
7697 );
7698
7699 {
7701 let protocol = WebUIProtocol::new(fragments.clone());
7702 let state = test_json!({"isDisabled": true});
7703 let mut writer = TestWriter::new();
7704 handle(
7705 &protocol,
7706 &state,
7707 &RenderOptions::new("index.html", "/"),
7708 &mut writer,
7709 )
7710 .unwrap();
7711 assert_eq!(
7712 writer.get_content(),
7713 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7714 );
7715 }
7716
7717 {
7719 let protocol = WebUIProtocol::new(fragments.clone());
7720 let state = test_json!({"isDisabled": false});
7721 let mut writer = TestWriter::new();
7722 handle(
7723 &protocol,
7724 &state,
7725 &RenderOptions::new("index.html", "/"),
7726 &mut writer,
7727 )
7728 .unwrap();
7729 assert_eq!(
7730 writer.get_content(),
7731 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7732 );
7733 }
7734 }
7735
7736 #[test]
7743 fn test_component_with_template() {
7744 let mut fragments = HashMap::new();
7745 fragments.insert(
7746 "index.html".to_string(),
7747 FragmentList {
7748 fragments: vec![
7749 WebUIFragment::raw("<custom-element>"),
7750 WebUIFragment::component("custom-element"),
7751 WebUIFragment::raw("</custom-element>"),
7752 ],
7753 contains_boundary: false,
7754 },
7755 );
7756 fragments.insert(
7757 "custom-element".to_string(),
7758 FragmentList {
7759 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7760 contains_boundary: false,
7761 },
7762 );
7763 let protocol = WebUIProtocol::new(fragments);
7764 let state = test_json!({});
7765 let mut writer = TestWriter::new();
7766 handle(
7767 &protocol,
7768 &state,
7769 &RenderOptions::new("index.html", "/"),
7770 &mut writer,
7771 )
7772 .unwrap();
7773 assert_eq!(
7774 writer.get_content(),
7775 "<custom-element><div>Custom Element</div></custom-element>"
7776 );
7777 assert!(writer.is_ended());
7778 }
7779
7780 #[test]
7781 fn test_component_with_slots() {
7782 let mut fragments = HashMap::new();
7783 fragments.insert(
7784 "index.html".to_string(),
7785 FragmentList {
7786 fragments: vec![
7787 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7788 WebUIFragment::component("custom-element"),
7789 WebUIFragment::raw("Hello World</custom-element>"),
7790 ],
7791 contains_boundary: false,
7792 },
7793 );
7794 fragments.insert(
7795 "custom-element".to_string(),
7796 FragmentList {
7797 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7798 contains_boundary: false,
7799 },
7800 );
7801 let protocol = WebUIProtocol::new(fragments);
7802 let state = test_json!({});
7803 let mut writer = TestWriter::new();
7804 handle(
7805 &protocol,
7806 &state,
7807 &RenderOptions::new("index.html", "/"),
7808 &mut writer,
7809 )
7810 .unwrap();
7811 assert_eq!(
7812 writer.get_content(),
7813 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7814 );
7815 assert!(writer.is_ended());
7816 }
7817
7818 #[test]
7819 fn test_multiple_nested_components() {
7820 let mut fragments = HashMap::new();
7821 fragments.insert(
7822 "index.html".to_string(),
7823 FragmentList {
7824 fragments: vec![
7825 WebUIFragment::raw("<div>"),
7826 WebUIFragment::for_loop("item", "items", "templateRepeat"),
7827 WebUIFragment::raw("</div>"),
7828 ],
7829 contains_boundary: false,
7830 },
7831 );
7832 fragments.insert(
7833 "custom-button".to_string(),
7834 FragmentList {
7835 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7836 contains_boundary: false,
7837 },
7838 );
7839 fragments.insert(
7840 "custom-element".to_string(),
7841 FragmentList {
7842 fragments: vec![
7843 WebUIFragment::raw("<custom-child>"),
7844 WebUIFragment::component("custom-child"),
7845 WebUIFragment::raw("</custom-child><slot></slot>"),
7846 ],
7847 contains_boundary: false,
7848 },
7849 );
7850 fragments.insert(
7851 "custom-child".to_string(),
7852 FragmentList {
7853 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
7854 contains_boundary: false,
7855 },
7856 );
7857 fragments.insert(
7858 "templateRepeat".to_string(),
7859 FragmentList {
7860 fragments: vec![
7861 WebUIFragment::raw("<custom-element>"),
7862 WebUIFragment::component("custom-element"),
7863 WebUIFragment::raw("<custom-button>"),
7864 WebUIFragment::component("custom-button"),
7865 WebUIFragment::raw("Ok</custom-button></custom-element>"),
7866 ],
7867 contains_boundary: false,
7868 },
7869 );
7870 let protocol = WebUIProtocol::new(fragments);
7871 let state = test_json!({"items": [{"name": "Item1"}]});
7872 let mut writer = TestWriter::new();
7873 handle(
7874 &protocol,
7875 &state,
7876 &RenderOptions::new("index.html", "/"),
7877 &mut writer,
7878 )
7879 .unwrap();
7880 assert_eq!(
7881 writer.get_content(),
7882 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
7883 );
7884 assert!(writer.is_ended());
7885 }
7886
7887 #[test]
7890 fn test_if_with_binary_expression() {
7891 let mut fragments = HashMap::new();
7892 fragments.insert(
7893 "index.html".to_string(),
7894 FragmentList {
7895 fragments: vec![
7896 WebUIFragment::raw("<div>"),
7897 WebUIFragment::if_cond(
7898 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
7899 "if-1",
7900 ),
7901 WebUIFragment::raw("</div>"),
7902 ],
7903 contains_boundary: false,
7904 },
7905 );
7906 fragments.insert(
7907 "if-1".to_string(),
7908 FragmentList {
7909 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
7910 contains_boundary: false,
7911 },
7912 );
7913 let protocol = WebUIProtocol::new(fragments);
7914
7915 let state_true = test_json!({"x": 10});
7917 let mut writer_true = TestWriter::new();
7918 handle(
7919 &protocol,
7920 &state_true,
7921 &RenderOptions::new("index.html", "/"),
7922 &mut writer_true,
7923 )
7924 .unwrap();
7925 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
7926
7927 let state_false = test_json!({"x": 1});
7929 let mut writer_false = TestWriter::new();
7930 handle(
7931 &protocol,
7932 &state_false,
7933 &RenderOptions::new("index.html", "/"),
7934 &mut writer_false,
7935 )
7936 .unwrap();
7937 assert_eq!(writer_false.get_content(), "<div></div>");
7938 }
7939
7940 #[test]
7941 fn test_for_if_overlapping_local_state() {
7942 let mut fragments = HashMap::new();
7943 fragments.insert(
7944 "index.html".to_string(),
7945 FragmentList {
7946 fragments: vec![
7947 WebUIFragment::raw("<div>"),
7948 WebUIFragment::for_loop("item", "items", "template1"),
7949 WebUIFragment::raw("</div>"),
7950 ],
7951 contains_boundary: false,
7952 },
7953 );
7954 fragments.insert(
7955 "template1".to_string(),
7956 FragmentList {
7957 fragments: vec![
7958 WebUIFragment::raw("<div>"),
7959 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
7960 WebUIFragment::raw("</div>"),
7961 ],
7962 contains_boundary: false,
7963 },
7964 );
7965 fragments.insert(
7966 "ifBlock".to_string(),
7967 FragmentList {
7968 fragments: vec![
7969 WebUIFragment::raw("<span>"),
7970 WebUIFragment::signal("item.label", false),
7971 WebUIFragment::raw("</span>"),
7972 ],
7973 contains_boundary: false,
7974 },
7975 );
7976 let protocol = WebUIProtocol::new(fragments);
7977 let state = test_json!({
7978 "flag": false,
7979 "items": [
7980 {"label": "A", "flag": true},
7981 {"label": "B", "flag": false},
7982 {"label": "C", "flag": true}
7983 ]
7984 });
7985 let mut writer = TestWriter::new();
7986 handle(
7987 &protocol,
7988 &state,
7989 &RenderOptions::new("index.html", "/"),
7990 &mut writer,
7991 )
7992 .unwrap();
7993 assert_eq!(
7994 writer.get_content(),
7995 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
7996 );
7997 }
7998
7999 #[test]
8000 fn test_for_if_global_flag_no_effect() {
8001 let mut fragments = HashMap::new();
8002 fragments.insert(
8003 "index.html".to_string(),
8004 FragmentList {
8005 fragments: vec![
8006 WebUIFragment::raw("<div>"),
8007 WebUIFragment::for_loop("item", "items", "template1"),
8008 WebUIFragment::raw("</div>"),
8009 ],
8010 contains_boundary: false,
8011 },
8012 );
8013 fragments.insert(
8014 "template1".to_string(),
8015 FragmentList {
8016 fragments: vec![
8017 WebUIFragment::raw("<div>"),
8018 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8019 WebUIFragment::raw("</div>"),
8020 ],
8021 contains_boundary: false,
8022 },
8023 );
8024 fragments.insert(
8025 "ifBlock".to_string(),
8026 FragmentList {
8027 fragments: vec![
8028 WebUIFragment::raw("<span>"),
8029 WebUIFragment::signal("item.label", false),
8030 WebUIFragment::raw("</span>"),
8031 ],
8032 contains_boundary: false,
8033 },
8034 );
8035 let protocol = WebUIProtocol::new(fragments);
8036 let state = test_json!({
8037 "item": {"flag": true},
8038 "items": [
8039 {"label": "A", "flag": false},
8040 {"label": "B", "flag": true},
8041 {"label": "C", "flag": false}
8042 ]
8043 });
8044 let mut writer = TestWriter::new();
8045 handle(
8046 &protocol,
8047 &state,
8048 &RenderOptions::new("index.html", "/"),
8049 &mut writer,
8050 )
8051 .unwrap();
8052 assert_eq!(
8053 writer.get_content(),
8054 "<div><div></div><div><span>B</span></div><div></div></div>"
8055 );
8056 }
8057
8058 #[test]
8061 fn test_recursive_template_refs() {
8062 let mut fragments = HashMap::new();
8063 fragments.insert(
8064 "index.html".to_string(),
8065 FragmentList {
8066 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8067 contains_boundary: false,
8068 },
8069 );
8070 fragments.insert(
8071 "static".to_string(),
8072 FragmentList {
8073 fragments: vec![
8074 WebUIFragment::raw("<div expanded=\""),
8075 WebUIFragment::signal("item.expanded", false),
8076 WebUIFragment::raw("\" class=\""),
8077 WebUIFragment::signal("testScenario", false),
8078 WebUIFragment::raw("\"><span>"),
8079 WebUIFragment::signal("item.name", false),
8080 WebUIFragment::raw("</span>"),
8081 WebUIFragment::for_loop("item", "item.children", "static"),
8082 WebUIFragment::raw("</div>"),
8083 ],
8084 contains_boundary: false,
8085 },
8086 );
8087 let protocol = WebUIProtocol::new(fragments);
8088 let state = test_json!({
8089 "testScenario": "RecursiveTemplatesWithGlobalState",
8090 "items": [
8091 {"name": "A", "expanded": "false", "children": []},
8092 {"name": "B", "expanded": "true", "children": [
8093 {"name": "C", "expanded": "false"},
8094 {"name": "D", "expanded": "false"}
8095 ]},
8096 {"name": "E", "expanded": "false"}
8097 ]
8098 });
8099 let mut writer = TestWriter::new();
8100 handle(
8101 &protocol,
8102 &state,
8103 &RenderOptions::new("index.html", "/"),
8104 &mut writer,
8105 )
8106 .unwrap();
8107 assert_eq!(
8108 writer.get_content(),
8109 "<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>"
8110 );
8111 }
8112
8113 #[test]
8116 fn test_component_in_for_no_local_access() {
8117 let mut fragments = HashMap::new();
8118 fragments.insert(
8119 "index.html".to_string(),
8120 FragmentList {
8121 fragments: vec![
8122 WebUIFragment::raw("<div>"),
8123 WebUIFragment::for_loop("item", "items", "templateComponent"),
8124 WebUIFragment::raw("</div>"),
8125 ],
8126 contains_boundary: false,
8127 },
8128 );
8129 fragments.insert(
8130 "templateComponent".to_string(),
8131 FragmentList {
8132 fragments: vec![
8133 WebUIFragment::raw("<component-tag>"),
8134 WebUIFragment::component("my-component"),
8135 WebUIFragment::raw("</component-tag>"),
8136 ],
8137 contains_boundary: false,
8138 },
8139 );
8140 fragments.insert(
8141 "my-component".to_string(),
8142 FragmentList {
8143 fragments: vec![
8144 WebUIFragment::raw("<span>"),
8145 WebUIFragment::signal("name", false),
8146 WebUIFragment::raw("</span>"),
8147 ],
8148 contains_boundary: false,
8149 },
8150 );
8151 let protocol = WebUIProtocol::new(fragments);
8152 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8153 let mut writer = TestWriter::new();
8154 handle(
8155 &protocol,
8156 &state,
8157 &RenderOptions::new("index.html", "/"),
8158 &mut writer,
8159 )
8160 .unwrap();
8161 assert_eq!(
8162 writer.get_content(),
8163 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8164 );
8165 }
8166
8167 #[test]
8168 fn test_nested_for_hierarchical_state() {
8169 let mut fragments = HashMap::new();
8170 fragments.insert(
8171 "index.html".to_string(),
8172 FragmentList {
8173 fragments: vec![
8174 WebUIFragment::raw("<div>"),
8175 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8176 WebUIFragment::raw("</div>"),
8177 ],
8178 contains_boundary: false,
8179 },
8180 );
8181 fragments.insert(
8182 "outerTemplate".to_string(),
8183 FragmentList {
8184 fragments: vec![
8185 WebUIFragment::raw("<section>"),
8186 WebUIFragment::signal("globalPrefix", false),
8187 WebUIFragment::signal("outerItem.outerLabel", false),
8188 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8189 WebUIFragment::raw("</section>"),
8190 ],
8191 contains_boundary: false,
8192 },
8193 );
8194 fragments.insert(
8195 "innerTemplate".to_string(),
8196 FragmentList {
8197 fragments: vec![
8198 WebUIFragment::raw("<p>"),
8199 WebUIFragment::signal("globalPrefix", false),
8200 WebUIFragment::signal("outerItem.outerLabel", false),
8201 WebUIFragment::raw(": "),
8202 WebUIFragment::signal("innerItem.innerLabel", false),
8203 WebUIFragment::raw("</p>"),
8204 ],
8205 contains_boundary: false,
8206 },
8207 );
8208 let protocol = WebUIProtocol::new(fragments);
8209 let state = test_json!({
8210 "globalPrefix": "Prefix: ",
8211 "outerItems": [
8212 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8213 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8214 ]
8215 });
8216 let mut writer = TestWriter::new();
8217 handle(
8218 &protocol,
8219 &state,
8220 &RenderOptions::new("index.html", "/"),
8221 &mut writer,
8222 )
8223 .unwrap();
8224 assert_eq!(
8225 writer.get_content(),
8226 "<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>"
8227 );
8228 }
8229
8230 #[test]
8231 fn test_component_in_for_global_only() {
8232 let mut fragments = HashMap::new();
8233 fragments.insert(
8234 "index.html".to_string(),
8235 FragmentList {
8236 fragments: vec![
8237 WebUIFragment::raw("<div>"),
8238 WebUIFragment::for_loop("item", "items", "templateComponent"),
8239 WebUIFragment::raw("</div>"),
8240 ],
8241 contains_boundary: false,
8242 },
8243 );
8244 fragments.insert(
8245 "templateComponent".to_string(),
8246 FragmentList {
8247 fragments: vec![
8248 WebUIFragment::raw("<component-tag>"),
8249 WebUIFragment::component("my-component"),
8250 WebUIFragment::raw("</component-tag>"),
8251 ],
8252 contains_boundary: false,
8253 },
8254 );
8255 fragments.insert(
8256 "my-component".to_string(),
8257 FragmentList {
8258 fragments: vec![
8259 WebUIFragment::raw("<span>"),
8260 WebUIFragment::signal("name", false),
8261 WebUIFragment::raw("-"),
8262 WebUIFragment::signal("globalSuffix", false),
8263 WebUIFragment::raw("</span>"),
8264 ],
8265 contains_boundary: false,
8266 },
8267 );
8268 let protocol = WebUIProtocol::new(fragments);
8269 let state =
8270 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8271 let mut writer = TestWriter::new();
8272 handle(
8273 &protocol,
8274 &state,
8275 &RenderOptions::new("index.html", "/"),
8276 &mut writer,
8277 )
8278 .unwrap();
8279 assert_eq!(
8280 writer.get_content(),
8281 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8282 );
8283 }
8284
8285 #[test]
8286 fn test_component_no_item_moniker() {
8287 let mut fragments = HashMap::new();
8288 fragments.insert(
8289 "index.html".to_string(),
8290 FragmentList {
8291 fragments: vec![
8292 WebUIFragment::raw("<div>"),
8293 WebUIFragment::for_loop("item", "items", "templateComponent"),
8294 WebUIFragment::raw("</div>"),
8295 ],
8296 contains_boundary: false,
8297 },
8298 );
8299 fragments.insert(
8300 "templateComponent".to_string(),
8301 FragmentList {
8302 fragments: vec![
8303 WebUIFragment::raw("<component-tag>"),
8304 WebUIFragment::component("my-component"),
8305 WebUIFragment::raw("</component-tag>"),
8306 ],
8307 contains_boundary: false,
8308 },
8309 );
8310 fragments.insert(
8311 "my-component".to_string(),
8312 FragmentList {
8313 fragments: vec![
8314 WebUIFragment::raw("<span>"),
8315 WebUIFragment::signal("item.name", false),
8316 WebUIFragment::raw("-"),
8317 WebUIFragment::signal("globalSuffix", false),
8318 WebUIFragment::raw("</span>"),
8319 ],
8320 contains_boundary: false,
8321 },
8322 );
8323 let protocol = WebUIProtocol::new(fragments);
8324 let state =
8325 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8326 let mut writer = TestWriter::new();
8327 handle(
8328 &protocol,
8329 &state,
8330 &RenderOptions::new("index.html", "/"),
8331 &mut writer,
8332 )
8333 .unwrap();
8334 assert_eq!(
8335 writer.get_content(),
8336 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8337 );
8338 }
8339
8340 #[test]
8341 fn test_for_nonqualified_uses_global() {
8342 let mut fragments = HashMap::new();
8343 fragments.insert(
8344 "index.html".to_string(),
8345 FragmentList {
8346 fragments: vec![
8347 WebUIFragment::raw("<div>"),
8348 WebUIFragment::for_loop("item", "items", "template1"),
8349 WebUIFragment::raw("</div>"),
8350 ],
8351 contains_boundary: false,
8352 },
8353 );
8354 fragments.insert(
8355 "template1".to_string(),
8356 FragmentList {
8357 fragments: vec![
8358 WebUIFragment::raw("<span>"),
8359 WebUIFragment::signal("name", false),
8360 WebUIFragment::raw("</span>"),
8361 ],
8362 contains_boundary: false,
8363 },
8364 );
8365 let protocol = WebUIProtocol::new(fragments);
8366 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8367 let mut writer = TestWriter::new();
8368 handle(
8369 &protocol,
8370 &state,
8371 &RenderOptions::new("index.html", "/"),
8372 &mut writer,
8373 )
8374 .unwrap();
8375 assert_eq!(
8376 writer.get_content(),
8377 "<div><span>GlobalName</span><span>GlobalName</span></div>"
8378 );
8379 }
8380
8381 #[test]
8382 fn test_nested_for_if_interleaved() {
8383 let mut fragments = HashMap::new();
8384 fragments.insert(
8385 "index.html".to_string(),
8386 FragmentList {
8387 fragments: vec![
8388 WebUIFragment::raw("<div>"),
8389 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8390 WebUIFragment::raw("</div>"),
8391 ],
8392 contains_boundary: false,
8393 },
8394 );
8395 fragments.insert(
8396 "outerTemplate".to_string(),
8397 FragmentList {
8398 fragments: vec![
8399 WebUIFragment::raw("<section>"),
8400 WebUIFragment::signal("globalPrefix", false),
8401 WebUIFragment::signal("outerItem.outerLabel", false),
8402 WebUIFragment::if_cond(
8403 ConditionExpr::identifier("outerItem.include"),
8404 "ifTemplate",
8405 ),
8406 WebUIFragment::raw("</section>"),
8407 ],
8408 contains_boundary: false,
8409 },
8410 );
8411 fragments.insert(
8412 "ifTemplate".to_string(),
8413 FragmentList {
8414 fragments: vec![
8415 WebUIFragment::raw("<div>"),
8416 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8417 WebUIFragment::raw("</div>"),
8418 ],
8419 contains_boundary: false,
8420 },
8421 );
8422 fragments.insert(
8423 "innerTemplate".to_string(),
8424 FragmentList {
8425 fragments: vec![
8426 WebUIFragment::raw("<p>"),
8427 WebUIFragment::signal("globalSuffix", false),
8428 WebUIFragment::raw(": "),
8429 WebUIFragment::signal("innerItem.innerLabel", false),
8430 WebUIFragment::raw("</p>"),
8431 ],
8432 contains_boundary: false,
8433 },
8434 );
8435 let protocol = WebUIProtocol::new(fragments);
8436 let state = test_json!({
8437 "globalPrefix": "Prefix: ",
8438 "globalSuffix": "Suffix",
8439 "outerItems": [
8440 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8441 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8442 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8443 ]
8444 });
8445 let mut writer = TestWriter::new();
8446 handle(
8447 &protocol,
8448 &state,
8449 &RenderOptions::new("index.html", "/"),
8450 &mut writer,
8451 )
8452 .unwrap();
8453 assert_eq!(
8454 writer.get_content(),
8455 "<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>"
8456 );
8457 }
8458
8459 #[test]
8460 fn test_nested_for_if_outer_state() {
8461 let mut fragments = HashMap::new();
8462 fragments.insert(
8463 "index.html".to_string(),
8464 FragmentList {
8465 fragments: vec![
8466 WebUIFragment::raw("<div>"),
8467 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8468 WebUIFragment::raw("</div>"),
8469 ],
8470 contains_boundary: false,
8471 },
8472 );
8473 fragments.insert(
8474 "outerTemplate".to_string(),
8475 FragmentList {
8476 fragments: vec![
8477 WebUIFragment::raw("<section>"),
8478 WebUIFragment::signal("globalPrefix", false),
8479 WebUIFragment::signal("outerItem.label", false),
8480 WebUIFragment::for_loop(
8481 "middleItem",
8482 "outerItem.middleItems",
8483 "middleTemplate",
8484 ),
8485 WebUIFragment::raw("</section>"),
8486 ],
8487 contains_boundary: false,
8488 },
8489 );
8490 fragments.insert(
8491 "middleTemplate".to_string(),
8492 FragmentList {
8493 fragments: vec![
8494 WebUIFragment::raw("<div>"),
8495 WebUIFragment::if_cond(
8496 ConditionExpr::identifier("outerItem.active"),
8497 "ifTemplate",
8498 ),
8499 WebUIFragment::raw("</div>"),
8500 ],
8501 contains_boundary: false,
8502 },
8503 );
8504 fragments.insert(
8505 "ifTemplate".to_string(),
8506 FragmentList {
8507 fragments: vec![
8508 WebUIFragment::raw("<p>"),
8509 WebUIFragment::signal("middleItem.value", false),
8510 WebUIFragment::raw("</p>"),
8511 ],
8512 contains_boundary: false,
8513 },
8514 );
8515 let protocol = WebUIProtocol::new(fragments);
8516 let state = test_json!({
8517 "globalPrefix": "GP-",
8518 "outerItems": [
8519 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8520 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8521 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8522 ]
8523 });
8524 let mut writer = TestWriter::new();
8525 handle(
8526 &protocol,
8527 &state,
8528 &RenderOptions::new("index.html", "/"),
8529 &mut writer,
8530 )
8531 .unwrap();
8532 assert_eq!(
8533 writer.get_content(),
8534 "<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>"
8535 );
8536 }
8537
8538 #[test]
8539 fn test_nested_for_if_inner_state() {
8540 let mut fragments = HashMap::new();
8541 fragments.insert(
8542 "index.html".to_string(),
8543 FragmentList {
8544 fragments: vec![
8545 WebUIFragment::raw("<div>"),
8546 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8547 WebUIFragment::raw("</div>"),
8548 ],
8549 contains_boundary: false,
8550 },
8551 );
8552 fragments.insert(
8553 "outerTemplate".to_string(),
8554 FragmentList {
8555 fragments: vec![
8556 WebUIFragment::raw("<section>"),
8557 WebUIFragment::signal("outerItem.label", false),
8558 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8559 WebUIFragment::raw("</section>"),
8560 ],
8561 contains_boundary: false,
8562 },
8563 );
8564 fragments.insert(
8565 "innerTemplate".to_string(),
8566 FragmentList {
8567 fragments: vec![
8568 WebUIFragment::raw("<article>"),
8569 WebUIFragment::if_cond(
8570 ConditionExpr::identifier("innerItem.show"),
8571 "ifTemplate",
8572 ),
8573 WebUIFragment::raw("</article>"),
8574 ],
8575 contains_boundary: false,
8576 },
8577 );
8578 fragments.insert(
8579 "ifTemplate".to_string(),
8580 FragmentList {
8581 fragments: vec![
8582 WebUIFragment::raw("<p>"),
8583 WebUIFragment::signal("innerItem.detail", false),
8584 WebUIFragment::raw("</p>"),
8585 ],
8586 contains_boundary: false,
8587 },
8588 );
8589 let protocol = WebUIProtocol::new(fragments);
8590 let state = test_json!({
8591 "outerItems": [
8592 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8593 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
8594 ]
8595 });
8596 let mut writer = TestWriter::new();
8597 handle(
8598 &protocol,
8599 &state,
8600 &RenderOptions::new("index.html", "/"),
8601 &mut writer,
8602 )
8603 .unwrap();
8604 assert_eq!(
8605 writer.get_content(),
8606 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8607 );
8608 }
8609
8610 #[test]
8611 fn test_for_merge_local_global_monikers() {
8612 let mut fragments = HashMap::new();
8613 fragments.insert(
8614 "index.html".to_string(),
8615 FragmentList {
8616 fragments: vec![
8617 WebUIFragment::raw("<div>"),
8618 WebUIFragment::for_loop("item", "items", "template1"),
8619 WebUIFragment::raw("</div>"),
8620 ],
8621 contains_boundary: false,
8622 },
8623 );
8624 fragments.insert(
8625 "template1".to_string(),
8626 FragmentList {
8627 fragments: vec![
8628 WebUIFragment::raw("<span>"),
8629 WebUIFragment::signal("item.name", false),
8630 WebUIFragment::raw("-"),
8631 WebUIFragment::signal("item.globalValue", false),
8632 WebUIFragment::raw("-"),
8633 WebUIFragment::signal("item.localOnly", false),
8634 WebUIFragment::raw("-"),
8635 WebUIFragment::signal("item.otherVal", false),
8636 WebUIFragment::raw("</span>"),
8637 ],
8638 contains_boundary: false,
8639 },
8640 );
8641 let protocol = WebUIProtocol::new(fragments);
8642 let state = test_json!({
8643 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8644 "items": [
8645 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8646 {"name": "Local2", "localOnly": "Only2"}
8647 ]
8648 });
8649 let mut writer = TestWriter::new();
8650 handle(
8651 &protocol,
8652 &state,
8653 &RenderOptions::new("index.html", "/"),
8654 &mut writer,
8655 )
8656 .unwrap();
8657 assert_eq!(
8658 writer.get_content(),
8659 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8660 );
8661 }
8662
8663 #[test]
8664 fn test_component_in_for_global_moniker_shadow() {
8665 let mut fragments = HashMap::new();
8666 fragments.insert(
8667 "index.html".to_string(),
8668 FragmentList {
8669 fragments: vec![
8670 WebUIFragment::raw("<div>"),
8671 WebUIFragment::for_loop("item", "items", "templateComponent"),
8672 WebUIFragment::raw("</div>"),
8673 ],
8674 contains_boundary: false,
8675 },
8676 );
8677 fragments.insert(
8678 "templateComponent".to_string(),
8679 FragmentList {
8680 fragments: vec![
8681 WebUIFragment::raw("<component-tag>"),
8682 WebUIFragment::component("my-component"),
8683 WebUIFragment::raw("</component-tag>"),
8684 ],
8685 contains_boundary: false,
8686 },
8687 );
8688 fragments.insert(
8689 "my-component".to_string(),
8690 FragmentList {
8691 fragments: vec![
8692 WebUIFragment::raw("<span>"),
8693 WebUIFragment::signal("name", false),
8694 WebUIFragment::raw("-"),
8695 WebUIFragment::signal("item.globalValue", false),
8696 WebUIFragment::raw("-"),
8697 WebUIFragment::signal("localOnly", false),
8698 WebUIFragment::raw("-"),
8699 WebUIFragment::signal("item.otherVal", false),
8700 WebUIFragment::raw("</span>"),
8701 ],
8702 contains_boundary: false,
8703 },
8704 );
8705 let protocol = WebUIProtocol::new(fragments);
8706 let state = test_json!({
8707 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8708 "items": [
8709 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8710 {"name": "Local2", "localOnly": "Only2"}
8711 ]
8712 });
8713 let mut writer = TestWriter::new();
8714 handle(
8715 &protocol,
8716 &state,
8717 &RenderOptions::new("index.html", "/"),
8718 &mut writer,
8719 )
8720 .unwrap();
8721 assert_eq!(
8722 writer.get_content(),
8723 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8724 );
8725 }
8726
8727 #[test]
8728 fn test_if_in_nested_for_local_flag() {
8729 let mut fragments = HashMap::new();
8730 fragments.insert(
8731 "index.html".to_string(),
8732 FragmentList {
8733 fragments: vec![
8734 WebUIFragment::raw("<div>"),
8735 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8736 WebUIFragment::raw("</div>"),
8737 ],
8738 contains_boundary: false,
8739 },
8740 );
8741 fragments.insert(
8742 "outerTemplate".to_string(),
8743 FragmentList {
8744 fragments: vec![
8745 WebUIFragment::raw("<section>"),
8746 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8747 WebUIFragment::raw("</section>"),
8748 ],
8749 contains_boundary: false,
8750 },
8751 );
8752 fragments.insert(
8753 "innerTemplate".to_string(),
8754 FragmentList {
8755 fragments: vec![WebUIFragment::if_cond(
8756 ConditionExpr::identifier("inner_item.flag"),
8757 "ifInner",
8758 )],
8759 contains_boundary: false,
8760 },
8761 );
8762 fragments.insert(
8763 "ifInner".to_string(),
8764 FragmentList {
8765 fragments: vec![
8766 WebUIFragment::raw("<p>"),
8767 WebUIFragment::signal("inner_item.value", false),
8768 WebUIFragment::raw("</p>"),
8769 ],
8770 contains_boundary: false,
8771 },
8772 );
8773 let protocol = WebUIProtocol::new(fragments);
8774 let state = test_json!({
8775 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8776 "inner_item": {"flag": false}
8777 });
8778 let mut writer = TestWriter::new();
8779 handle(
8780 &protocol,
8781 &state,
8782 &RenderOptions::new("index.html", "/"),
8783 &mut writer,
8784 )
8785 .unwrap();
8786 assert_eq!(
8787 writer.get_content(),
8788 "<div><section><p>X</p></section></div>"
8789 );
8790 }
8791
8792 #[test]
8793 fn test_if_in_nested_for_global_fallback() {
8794 let mut fragments = HashMap::new();
8795 fragments.insert(
8796 "index.html".to_string(),
8797 FragmentList {
8798 fragments: vec![
8799 WebUIFragment::raw("<div>"),
8800 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8801 WebUIFragment::raw("</div>"),
8802 ],
8803 contains_boundary: false,
8804 },
8805 );
8806 fragments.insert(
8807 "outerTemplate".to_string(),
8808 FragmentList {
8809 fragments: vec![
8810 WebUIFragment::raw("<section>"),
8811 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8812 WebUIFragment::raw("</section>"),
8813 ],
8814 contains_boundary: false,
8815 },
8816 );
8817 fragments.insert(
8818 "innerTemplate".to_string(),
8819 FragmentList {
8820 fragments: vec![WebUIFragment::if_cond(
8821 ConditionExpr::identifier("inner_item.flag"),
8822 "ifInner",
8823 )],
8824 contains_boundary: false,
8825 },
8826 );
8827 fragments.insert(
8828 "ifInner".to_string(),
8829 FragmentList {
8830 fragments: vec![
8831 WebUIFragment::raw("<p>"),
8832 WebUIFragment::signal("inner_item.value", false),
8833 WebUIFragment::raw("</p>"),
8834 ],
8835 contains_boundary: false,
8836 },
8837 );
8838 let protocol = WebUIProtocol::new(fragments);
8839 let state = test_json!({
8840 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8841 "inner_item": {"flag": true}
8842 });
8843 let mut writer = TestWriter::new();
8844 handle(
8845 &protocol,
8846 &state,
8847 &RenderOptions::new("index.html", "/"),
8848 &mut writer,
8849 )
8850 .unwrap();
8851 assert_eq!(
8852 writer.get_content(),
8853 "<div><section><p>X</p><p>Y</p></section></div>"
8854 );
8855 }
8856
8857 #[test]
8858 fn test_if_mixed_for_monikers() {
8859 let mut fragments = HashMap::new();
8860 fragments.insert(
8861 "index.html".to_string(),
8862 FragmentList {
8863 fragments: vec![
8864 WebUIFragment::raw("<div>"),
8865 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
8866 WebUIFragment::raw("</div>"),
8867 ],
8868 contains_boundary: false,
8869 },
8870 );
8871 fragments.insert(
8872 "outerTemplate".to_string(),
8873 FragmentList {
8874 fragments: vec![
8875 WebUIFragment::raw("<section>"),
8876 WebUIFragment::signal("outer.outerLabel", false),
8877 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
8878 WebUIFragment::raw("</section>"),
8879 ],
8880 contains_boundary: false,
8881 },
8882 );
8883 fragments.insert(
8884 "innerTemplate".to_string(),
8885 FragmentList {
8886 fragments: vec![WebUIFragment::if_cond(
8887 ConditionExpr::compound(
8888 ConditionExpr::identifier("outer.active"),
8889 LogicalOperator::And,
8890 ConditionExpr::predicate(
8891 "inner.value",
8892 ComparisonOperator::GreaterThan,
8893 "globalLimit",
8894 ),
8895 ),
8896 "ifInner",
8897 )],
8898 contains_boundary: false,
8899 },
8900 );
8901 fragments.insert(
8902 "ifInner".to_string(),
8903 FragmentList {
8904 fragments: vec![
8905 WebUIFragment::raw("<p>"),
8906 WebUIFragment::signal("inner.value", false),
8907 WebUIFragment::raw("</p>"),
8908 ],
8909 contains_boundary: false,
8910 },
8911 );
8912 let protocol = WebUIProtocol::new(fragments);
8913 let state = test_json!({
8914 "globalLimit": 10,
8915 "list": {"outerItems": [
8916 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
8917 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
8918 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
8919 ]}
8920 });
8921 let mut writer = TestWriter::new();
8922 handle(
8923 &protocol,
8924 &state,
8925 &RenderOptions::new("index.html", "/"),
8926 &mut writer,
8927 )
8928 .unwrap();
8929 assert_eq!(
8930 writer.get_content(),
8931 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
8932 );
8933 }
8934
8935 fn make_route_protocol() -> WebUIProtocol {
8938 use webui_protocol::WebUiFragmentRoute;
8939
8940 let mut fragments = HashMap::new();
8941
8942 fragments.insert(
8944 "index.html".to_string(),
8945 FragmentList {
8946 fragments: vec![
8947 WebUIFragment::raw("<h1>Shell</h1>"),
8948 WebUIFragment::route_from(WebUiFragmentRoute {
8949 path: "/".into(),
8950 fragment_id: "dash-page".into(),
8951 exact: true,
8952 keep_alive: false,
8953 ..Default::default()
8954 }),
8955 WebUIFragment::route_from(WebUiFragmentRoute {
8956 path: "/contacts/:id".into(),
8957 fragment_id: "detail-page".into(),
8958 exact: true,
8959 keep_alive: true,
8960 ..Default::default()
8961 }),
8962 ],
8963
8964 contains_boundary: false,
8965 },
8966 );
8967
8968 fragments.insert(
8970 "dash-page".to_string(),
8971 FragmentList {
8972 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
8973 contains_boundary: false,
8974 },
8975 );
8976
8977 fragments.insert(
8979 "detail-page".to_string(),
8980 FragmentList {
8981 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
8982 contains_boundary: false,
8983 },
8984 );
8985
8986 WebUIProtocol::new(fragments)
8987 }
8988
8989 fn make_nested_route_protocol() -> WebUIProtocol {
8990 use webui_protocol::WebUiFragmentRoute;
8991
8992 let mut fragments = HashMap::new();
8993
8994 fragments.insert(
8995 "index.html".to_string(),
8996 FragmentList {
8997 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
8998 path: "/".into(),
8999 fragment_id: "app-shell".into(),
9000 exact: false,
9001 children: vec![WebUiFragmentRoute {
9002 path: "sections/:id".into(),
9003 fragment_id: "section-comp".into(),
9004 exact: false,
9005 children: vec![WebUiFragmentRoute {
9006 path: "topics/:topicId".into(),
9007 fragment_id: "topic-comp".into(),
9008 exact: true,
9009 children: vec![],
9010 keep_alive: false,
9011 ..Default::default()
9012 }],
9013 keep_alive: false,
9014 ..Default::default()
9015 }],
9016 keep_alive: false,
9017 ..Default::default()
9018 })],
9019
9020 contains_boundary: false,
9021 },
9022 );
9023
9024 fragments.insert(
9025 "app-shell".to_string(),
9026 FragmentList {
9027 fragments: vec![
9028 WebUIFragment::raw("<h1>Shell</h1>"),
9029 WebUIFragment::outlet(),
9030 ],
9031 contains_boundary: false,
9032 },
9033 );
9034
9035 fragments.insert(
9036 "section-comp".to_string(),
9037 FragmentList {
9038 fragments: vec![
9039 WebUIFragment::raw("<h2>Section</h2>"),
9040 WebUIFragment::outlet(),
9041 ],
9042 contains_boundary: false,
9043 },
9044 );
9045
9046 fragments.insert(
9047 "topic-comp".to_string(),
9048 FragmentList {
9049 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9050 contains_boundary: false,
9051 },
9052 );
9053
9054 WebUIProtocol::new(fragments)
9055 }
9056
9057 #[test]
9058 fn test_route_renders_shell_always() {
9059 let protocol = make_route_protocol();
9060 let state = test_json!({});
9061 let mut writer = TestWriter::new();
9062 handle(
9063 &protocol,
9064 &state,
9065 &RenderOptions::new("index.html", "/"),
9066 &mut writer,
9067 )
9068 .unwrap();
9069 let html = writer.get_content();
9070
9071 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9073 assert!(html.contains(" active>"), "matched route should be active");
9075 assert!(
9077 html.contains("style=\"display:none\""),
9078 "non-matched routes should be hidden"
9079 );
9080 }
9081
9082 #[test]
9083 fn test_route_matched_renders_visible() {
9084 let protocol = make_route_protocol();
9085 let state = test_json!({});
9086 let mut writer = TestWriter::new();
9087 handle(
9088 &protocol,
9089 &state,
9090 &RenderOptions::new("index.html", "/"),
9091 &mut writer,
9092 )
9093 .unwrap();
9094 let html = writer.get_content();
9095
9096 assert!(
9098 html.contains("<webui-route path=\"/\""),
9099 "dashboard route should exist"
9100 );
9101 assert!(
9102 html.contains("active>") && html.contains("<dash-page"),
9103 "matched route should be active with component tag: {html}"
9104 );
9105 assert!(
9106 html.contains("<p>Dashboard</p>"),
9107 "matched route should have content"
9108 );
9109 }
9110
9111 #[test]
9112 fn test_route_non_matched_renders_hidden_empty() {
9113 let protocol = make_route_protocol();
9114 let state = test_json!({});
9115 let mut writer = TestWriter::new();
9116 handle(
9117 &protocol,
9118 &state,
9119 &RenderOptions::new("index.html", "/"),
9120 &mut writer,
9121 )
9122 .unwrap();
9123 let html = writer.get_content();
9124
9125 assert!(
9127 html.contains("<webui-route path=\"/contacts/:id\""),
9128 "detail route element should exist"
9129 );
9130 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9132 let after_detail = &html[detail_start..];
9133 assert!(
9134 after_detail.contains("style=\"display:none\">")
9135 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9136 "non-matched route should be hidden: {after_detail}"
9137 );
9138 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9140 let detail_body = &after_detail[..detail_end];
9141 assert!(
9142 !detail_body.contains("<detail-page"),
9143 "non-matched route should not render component content: {detail_body}"
9144 );
9145 }
9146
9147 #[test]
9148 fn test_route_parameterized_match() {
9149 let protocol = make_route_protocol();
9150 let state = test_json!({});
9151 let mut writer = TestWriter::new();
9152 handle(
9153 &protocol,
9154 &state,
9155 &RenderOptions::new("index.html", "/contacts/42"),
9156 &mut writer,
9157 )
9158 .unwrap();
9159 let html = writer.get_content();
9160
9161 assert!(
9163 html.contains("active>") && html.contains("<detail-page"),
9164 "detail route should be active: {html}"
9165 );
9166 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9167 let dash_start = html
9169 .find("component=\"dash-page\"")
9170 .expect("dashboard route");
9171 let after_dash = &html[dash_start..];
9172 assert!(
9173 after_dash.contains("style=\"display:none\">"),
9174 "dashboard should be hidden when detail matches: {after_dash}"
9175 );
9176 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9177 let dash_body = &after_dash[..dash_end];
9178 assert!(
9179 !dash_body.contains("<dash-page"),
9180 "dashboard should not render component content: {dash_body}"
9181 );
9182 }
9183
9184 #[test]
9185 fn test_route_no_match_all_hidden_empty() {
9186 let protocol = make_route_protocol();
9187 let state = test_json!({});
9188 let mut writer = TestWriter::new();
9189 handle(
9190 &protocol,
9191 &state,
9192 &RenderOptions::new("index.html", "/nonexistent"),
9193 &mut writer,
9194 )
9195 .unwrap();
9196 let html = writer.get_content();
9197
9198 assert!(html.contains("<h1>Shell</h1>"));
9200 assert!(
9202 !html.contains("<p>Dashboard</p>"),
9203 "no route content when nothing matches"
9204 );
9205 assert!(
9206 !html.contains("<p>Detail</p>"),
9207 "no route content when nothing matches"
9208 );
9209 }
9210
9211 #[test]
9212 fn test_route_component_attr_emitted() {
9213 let protocol = make_route_protocol();
9214 let state = test_json!({});
9215 let mut writer = TestWriter::new();
9216 handle(
9217 &protocol,
9218 &state,
9219 &RenderOptions::new("index.html", "/"),
9220 &mut writer,
9221 )
9222 .unwrap();
9223 let html = writer.get_content();
9224 assert!(
9226 html.contains("component=\"dash-page\""),
9227 "component attr should be on webui-route: {html}"
9228 );
9229 assert!(
9230 html.contains("component=\"detail-page\""),
9231 "component attr should be on webui-route: {html}"
9232 );
9233 assert!(
9234 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9235 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9236 );
9237 }
9238
9239 #[test]
9240 fn test_no_plugin_no_state_attributes() {
9241 let protocol = make_route_protocol();
9242 let state = test_json!({
9243 "title": "Fish & Chips",
9244 "cartOpen": true,
9245 "items": [{"name": "A&B"}]
9246 });
9247 let mut writer = TestWriter::new();
9248
9249 handle(
9250 &protocol,
9251 &state,
9252 &RenderOptions::new("index.html", "/"),
9253 &mut writer,
9254 )
9255 .unwrap();
9256
9257 let html = writer.get_content();
9258 assert!(
9260 !html.contains("data-state"),
9261 "no data-state without plugin: {html}"
9262 );
9263 assert!(
9264 !html.contains(r#"title="Fish"#),
9265 "no scalar attrs without plugin: {html}"
9266 );
9267 }
9268
9269 #[test]
9270 fn test_nested_routes_render_webui_route_as_light_dom() {
9271 let protocol = make_nested_route_protocol();
9272 let state = test_json!({"title": "Test"});
9273 let handler = WebUIHandler::new();
9274 let mut writer = TestWriter::new();
9275
9276 handler
9277 .handle(
9278 &protocol,
9279 &state,
9280 &RenderOptions::new("index.html", "/sections/frontend"),
9281 &mut writer,
9282 )
9283 .expect("render failed");
9284
9285 let html = writer.get_content();
9286
9287 assert!(
9288 html.contains("component=\"app-shell\"") && html.contains("active>"),
9289 "root route should be active: {html}"
9290 );
9291 assert!(
9293 !html.contains("<template shadowrootmode"),
9294 "webui-route should be light DOM (no shadow template): {html}"
9295 );
9296 }
9297
9298 #[test]
9299 fn test_nested_routes_render_outlet_as_light_dom() {
9300 let protocol = make_nested_route_protocol();
9301 let state = test_json!({"title": "Test"});
9302 let handler = WebUIHandler::new();
9303 let mut writer = TestWriter::new();
9304
9305 handler
9306 .handle(
9307 &protocol,
9308 &state,
9309 &RenderOptions::new("index.html", "/sections/frontend"),
9310 &mut writer,
9311 )
9312 .expect("render failed");
9313
9314 let html = writer.get_content();
9315
9316 assert!(
9318 !html.contains("<webui-outlet>"),
9319 "should not contain webui-outlet wrapper: {html}"
9320 );
9321 assert!(
9323 html.contains("<webui-route"),
9324 "should contain webui-route elements: {html}"
9325 );
9326 }
9327
9328 #[test]
9329 fn test_nested_routes_match_child_at_outlet() {
9330 let protocol = make_nested_route_protocol();
9331 let state = test_json!({"title": "Test"});
9332 let handler = WebUIHandler::new();
9333 let mut writer = TestWriter::new();
9334
9335 handler
9336 .handle(
9337 &protocol,
9338 &state,
9339 &RenderOptions::new("index.html", "/sections/frontend"),
9340 &mut writer,
9341 )
9342 .expect("render failed");
9343
9344 let html = writer.get_content();
9345
9346 assert!(
9347 html.contains("component=\"section-comp\"") && html.contains("active>"),
9348 "section route should be active: {html}"
9349 );
9350 assert!(
9351 html.contains("<h2>Section</h2>"),
9352 "section content should be present: {html}"
9353 );
9354 }
9355
9356 #[test]
9357 fn test_nested_routes_three_levels_deep() {
9358 let protocol = make_nested_route_protocol();
9359 let state = test_json!({"title": "Test"});
9360 let handler = WebUIHandler::new();
9361 let mut writer = TestWriter::new();
9362
9363 handler
9364 .handle(
9365 &protocol,
9366 &state,
9367 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9368 &mut writer,
9369 )
9370 .expect("render failed");
9371
9372 let html = writer.get_content();
9373
9374 assert!(
9375 html.contains("component=\"app-shell\"") && html.contains("active>"),
9376 "root active: {html}"
9377 );
9378 assert!(
9379 html.contains("component=\"section-comp\"") && html.contains("active>"),
9380 "section active: {html}"
9381 );
9382 assert!(
9383 html.contains("component=\"topic-comp\"")
9384 && html.contains("exact")
9385 && html.contains("active>"),
9386 "topic active: {html}"
9387 );
9388 assert!(
9389 html.contains("<p>Topic content</p>"),
9390 "leaf content present: {html}"
9391 );
9392 }
9393
9394 #[test]
9395 fn test_nested_routes_nonmatched_siblings_hidden() {
9396 let protocol = make_nested_route_protocol();
9397 let state = test_json!({"title": "Test"});
9398 let handler = WebUIHandler::new();
9399 let mut writer = TestWriter::new();
9400
9401 handler
9402 .handle(
9403 &protocol,
9404 &state,
9405 &RenderOptions::new("index.html", "/sections/frontend"),
9406 &mut writer,
9407 )
9408 .expect("render failed");
9409
9410 let html = writer.get_content();
9411
9412 assert!(
9413 html.contains(r#"component="topic-comp" exact style="display:none">"#),
9414 "topic should be hidden: {html}"
9415 );
9416 }
9417
9418 #[test]
9419 fn test_nested_routes_root_only() {
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", "/"),
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 active at /: {html}"
9439 );
9440 assert!(
9441 html.contains("<h1>Shell</h1>"),
9442 "shell renders at /: {html}"
9443 );
9444 assert!(
9445 html.contains(r#"component="section-comp" style="display:none">"#),
9446 "section hidden at /: {html}"
9447 );
9448 }
9449
9450 #[test]
9453 fn test_css_module_emitted_once_inline_in_component() {
9454 let template = r#"<p><slot></slot></p>"#;
9457
9458 let mut fragments = HashMap::new();
9459 fragments.insert(
9460 "index.html".to_string(),
9461 FragmentList {
9462 fragments: vec![
9463 WebUIFragment::raw("<html><head>".to_string()),
9464 structural_fragment("head_end"),
9465 WebUIFragment::raw("</head><body><div>".to_string()),
9466 WebUIFragment::component("my-card"),
9467 WebUIFragment::raw("A".to_string()),
9468 WebUIFragment::component("my-card"),
9469 WebUIFragment::raw("B</div>".to_string()),
9470 structural_fragment("body_end"),
9471 WebUIFragment::raw("</body></html>".to_string()),
9472 ],
9473 contains_boundary: false,
9474 },
9475 );
9476 fragments.insert(
9477 "my-card".to_string(),
9478 FragmentList {
9479 fragments: vec![WebUIFragment::raw(template.to_string())],
9480 contains_boundary: false,
9481 },
9482 );
9483
9484 let mut protocol = WebUIProtocol::new(fragments);
9485 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9486 protocol
9487 .components
9488 .entry("my-card".to_string())
9489 .or_default()
9490 .css = "p{color:red}".to_string();
9491 protocol.populate_style_closures(&["index.html"]);
9492 let state = test_json!({});
9493 let mut writer = TestWriter::new();
9494
9495 handle(
9496 &protocol,
9497 &state,
9498 &RenderOptions::new("index.html", "/"),
9499 &mut writer,
9500 )
9501 .unwrap();
9502
9503 let html = writer.get_content();
9504
9505 let count = html.matches(r#"<script type="importmap""#).count();
9507 assert_eq!(
9508 count, 1,
9509 "CSS module importmap should be emitted once, got {count} in: {html}"
9510 );
9511 assert!(
9512 html.contains(r#""my-card":"data:text/css,"#),
9513 "Importmap must register my-card under a data: URI: {html}"
9514 );
9515
9516 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9518 assert_eq!(
9519 tmpl_count, 2,
9520 "Template should render twice, got {tmpl_count} in: {html}"
9521 );
9522
9523 let css_pos = html
9525 .find(r#"<script type="importmap""#)
9526 .expect("CSS module importmap missing");
9527 let body_pos = html.find("<body>").expect("<body> missing");
9528 assert!(
9529 css_pos > body_pos,
9530 "CSS module should be inline in component, not in <head>: {html}"
9531 );
9532 }
9533
9534 #[test]
9535 fn test_component_without_css_renders_normally() {
9536 let mut fragments = HashMap::new();
9538 fragments.insert(
9539 "index.html".to_string(),
9540 FragmentList {
9541 fragments: vec![WebUIFragment::component("my-card")],
9542 contains_boundary: false,
9543 },
9544 );
9545 fragments.insert(
9546 "my-card".to_string(),
9547 FragmentList {
9548 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9549 contains_boundary: false,
9550 },
9551 );
9552
9553 let protocol = WebUIProtocol::new(fragments);
9554 let state = test_json!({});
9555 let mut writer = TestWriter::new();
9556
9557 handle(
9558 &protocol,
9559 &state,
9560 &RenderOptions::new("index.html", "/"),
9561 &mut writer,
9562 )
9563 .unwrap();
9564
9565 let html = writer.get_content();
9566 assert!(
9567 html.contains("<p>hello</p>"),
9568 "Non-module component should render normally: {html}"
9569 );
9570 }
9571
9572 #[test]
9573 fn test_non_module_strategy_no_css_in_head() {
9574 let template = r#"<p>hello</p>"#;
9577
9578 let mut fragments = HashMap::new();
9579 fragments.insert(
9580 "index.html".to_string(),
9581 FragmentList {
9582 fragments: vec![
9583 WebUIFragment::raw("<html><head>".to_string()),
9584 structural_fragment("head_end"),
9585 WebUIFragment::raw("</head><body>".to_string()),
9586 WebUIFragment::component("my-card"),
9587 WebUIFragment::raw("</body></html>".to_string()),
9588 ],
9589 contains_boundary: false,
9590 },
9591 );
9592 fragments.insert(
9593 "my-card".to_string(),
9594 FragmentList {
9595 fragments: vec![WebUIFragment::raw(template.to_string())],
9596 contains_boundary: false,
9597 },
9598 );
9599
9600 let protocol = WebUIProtocol::new(fragments);
9602 let state = test_json!({});
9603 let mut writer = TestWriter::new();
9604
9605 handle(
9606 &protocol,
9607 &state,
9608 &RenderOptions::new("index.html", "/"),
9609 &mut writer,
9610 )
9611 .unwrap();
9612
9613 let html = writer.get_content();
9614
9615 assert!(
9616 !html.contains(r#"<style type="module""#),
9617 "Non-module strategy should not emit CSS module tags in <head>: {html}"
9618 );
9619 assert!(
9620 !html.contains(r#"<script type="importmap""#),
9621 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9622 );
9623 assert!(
9624 html.contains("<p>hello</p>"),
9625 "Component should still render: {html}"
9626 );
9627 }
9628
9629 #[test]
9630 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9631 let mut fragments = HashMap::new();
9632 fragments.insert(
9633 "index.html".to_string(),
9634 FragmentList {
9635 fragments: vec![
9636 WebUIFragment::raw("<html><head>".to_string()),
9637 structural_fragment("head_end"),
9638 WebUIFragment::raw("</head><body><my-card>".to_string()),
9639 WebUIFragment::component("my-card"),
9640 WebUIFragment::raw("</my-card></body></html>".to_string()),
9641 ],
9642 contains_boundary: false,
9643 },
9644 );
9645 fragments.insert(
9646 "my-card".to_string(),
9647 FragmentList {
9648 fragments: vec![WebUIFragment::raw(
9649 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9650 .to_string(),
9651 )],
9652 contains_boundary: false,
9653},
9654 );
9655
9656 let protocol = WebUIProtocol::new(fragments);
9657 let state = test_json!({});
9658 let mut writer = TestWriter::new();
9659
9660 handle(
9661 &protocol,
9662 &state,
9663 &RenderOptions::new("index.html", "/"),
9664 &mut writer,
9665 )
9666 .unwrap();
9667
9668 let html = writer.get_content();
9669
9670 assert!(
9671 html.contains("<style>.card{color:red}</style>"),
9672 "Style strategy should embed inline CSS in shadow template: {html}"
9673 );
9674 assert!(
9675 !html.contains(r#"<style type="module""#),
9676 "Style strategy should not emit legacy module CSS in <head>: {html}"
9677 );
9678 assert!(
9679 !html.contains(r#"<script type="importmap""#),
9680 "Style strategy should not emit CSS module importmaps in <head>: {html}"
9681 );
9682 }
9683
9684 #[test]
9685 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9686 let mut fragments = HashMap::new();
9689 fragments.insert(
9690 "index.html".to_string(),
9691 FragmentList {
9692 fragments: vec![
9693 WebUIFragment::raw("<html><head>".to_string()),
9694 structural_fragment("head_end"),
9695 WebUIFragment::raw("</head><body><my-card>".to_string()),
9696 WebUIFragment::component("my-card"),
9697 WebUIFragment::raw("</my-card>".to_string()),
9698 structural_fragment("body_end"),
9699 WebUIFragment::raw("</body></html>".to_string()),
9700 ],
9701 contains_boundary: false,
9702 },
9703 );
9704 fragments.insert(
9705 "my-card".to_string(),
9706 FragmentList {
9707 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9708 contains_boundary: false,
9709 },
9710 );
9711
9712 let mut protocol = WebUIProtocol::new(fragments);
9713 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9714
9715 let comp = protocol
9716 .components
9717 .entry("my-card".to_string())
9718 .or_default();
9719 comp.css_href = "my-card.css".to_string();
9720 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9721 protocol.populate_style_closures(&["index.html"]);
9722
9723 let state = test_json!({});
9724 let mut writer = TestWriter::new();
9725
9726 handle(
9727 &protocol,
9728 &state,
9729 &RenderOptions::new("index.html", "/"),
9730 &mut writer,
9731 )
9732 .unwrap();
9733
9734 let html = writer.get_content();
9735
9736 let head_end = html.find("</head>").expect("</head> missing");
9737 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9738 assert!(
9739 link_pos.is_some_and(|p| p < head_end),
9740 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9741 );
9742 assert!(
9743 !html.contains(r#"<link rel="preload""#),
9744 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9745 );
9746 assert!(
9747 !html.contains(r#"<style type="module""#),
9748 "Link strategy should not emit legacy module CSS: {html}"
9749 );
9750 assert!(
9751 !html.contains(r#"<script type="importmap""#),
9752 "Link strategy should not emit CSS module importmaps: {html}"
9753 );
9754 }
9755
9756 #[test]
9757 fn link_strategy_preloads_static_shadow_css_in_head() {
9758 let mut fragments = HashMap::new();
9759 fragments.insert(
9760 "index.html".to_string(),
9761 FragmentList {
9762 fragments: vec![
9763 WebUIFragment::raw("<html><head>".to_string()),
9764 structural_fragment("head_end"),
9765 WebUIFragment::raw("</head><body><my-card>".to_string()),
9766 WebUIFragment::component("my-card"),
9767 WebUIFragment::raw("</my-card></body></html>".to_string()),
9768 ],
9769 contains_boundary: false,
9770 },
9771 );
9772 fragments.insert(
9773 "o-loading-state".to_string(),
9774 FragmentList {
9775 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9776 contains_boundary: false,
9777 },
9778 );
9779 fragments.insert(
9780 "my-card".to_string(),
9781 FragmentList {
9782 fragments: vec![
9783 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9784 structural_fragment("shadow_styles:my-card"),
9785 WebUIFragment::raw("<div>card</div></template>".to_string()),
9786 ],
9787 contains_boundary: false,
9788 },
9789 );
9790
9791 let mut protocol = WebUIProtocol::new(fragments);
9792 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9793 let component = protocol
9794 .components
9795 .entry("my-card".to_string())
9796 .or_default();
9797 component.css_href = "my-card.css".to_string();
9798 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9799 component.uses_shadow_dom = true;
9800 protocol.populate_style_closures(&["index.html"]);
9801
9802 let mut writer = TestWriter::new();
9803 handle(
9804 &protocol,
9805 &test_json!({}),
9806 &RenderOptions::new("index.html", "/"),
9807 &mut writer,
9808 )
9809 .unwrap();
9810
9811 let html = writer.get_content();
9812 let head_end = html.find("</head>").expect("</head> missing");
9813 let preload = html
9814 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9815 .expect("Shadow preload missing");
9816 let stylesheet = html
9817 .find(r#"<link rel="stylesheet" href="my-card.css""#)
9818 .expect("Shadow stylesheet missing");
9819 assert!(preload < head_end);
9820 assert!(stylesheet > head_end);
9821 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9822 }
9823
9824 #[test]
9825 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9826 let fragments = HashMap::from([
9827 (
9828 "index.html".to_string(),
9829 FragmentList {
9830 fragments: vec![
9831 WebUIFragment::raw("<html><head>".to_string()),
9832 structural_fragment("head_end"),
9833 WebUIFragment::raw("</head><body><outer-box>".to_string()),
9834 WebUIFragment::component("outer-box"),
9835 WebUIFragment::raw("</outer-box></body></html>".to_string()),
9836 ],
9837 contains_boundary: false,
9838 },
9839 ),
9840 (
9841 "outer-box".to_string(),
9842 FragmentList {
9843 fragments: vec![
9844 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9845 structural_fragment("shadow_styles:outer-box"),
9846 WebUIFragment::raw("<light-card>".to_string()),
9847 WebUIFragment::component("light-card"),
9848 WebUIFragment::raw("</light-card><light-card>".to_string()),
9849 WebUIFragment::component("light-card"),
9850 WebUIFragment::raw("</light-card><inner-box>".to_string()),
9851 WebUIFragment::component("inner-box"),
9852 WebUIFragment::raw("</inner-box><light-card>".to_string()),
9853 WebUIFragment::component("light-card"),
9854 WebUIFragment::raw("</light-card></template>".to_string()),
9855 ],
9856 contains_boundary: false,
9857 },
9858 ),
9859 (
9860 "inner-box".to_string(),
9861 FragmentList {
9862 fragments: vec![
9863 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9864 structural_fragment("shadow_styles:inner-box"),
9865 WebUIFragment::raw("<light-card>".to_string()),
9866 WebUIFragment::component("light-card"),
9867 WebUIFragment::raw("</light-card></template>".to_string()),
9868 ],
9869 contains_boundary: false,
9870 },
9871 ),
9872 (
9873 "light-card".to_string(),
9874 FragmentList {
9875 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
9876 contains_boundary: false,
9877 },
9878 ),
9879 ]);
9880 let mut protocol = WebUIProtocol::new(fragments);
9881 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
9882 for (tag, css, uses_shadow_dom) in [
9883 ("outer-box", ".outer{}", true),
9884 ("inner-box", ".inner{}", true),
9885 ("light-card", ".card{}", false),
9886 ] {
9887 let component = protocol.components.entry(tag.to_string()).or_default();
9888 component.css = css.to_string();
9889 component.uses_shadow_dom = uses_shadow_dom;
9890 }
9891 protocol.populate_style_closures(&["index.html"]);
9892
9893 let mut writer = TestWriter::new();
9894 handle(
9895 &protocol,
9896 &test_json!({}),
9897 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
9898 &mut writer,
9899 )
9900 .unwrap();
9901 let html = writer.get_content();
9902
9903 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
9904 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
9905 assert_eq!(
9906 html.matches("data-webui-resource=\"light-card\"").count(),
9907 2,
9908 "the Light resource installs once in each Shadow tree: {html}"
9909 );
9910 assert_eq!(
9911 html.matches("data-webui-resource=").count(),
9912 4,
9913 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
9914 );
9915 assert_eq!(
9916 html.matches("nonce=\"css-nonce\"").count(),
9917 4,
9918 "each inline style resource carries the nonce"
9919 );
9920 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
9921 let inner_end = html.find("</inner-box>").expect("inner host close");
9922 let caller_continuation = &html[inner_end..];
9923 let trailing_card = caller_continuation
9924 .find("<p>card</p>")
9925 .expect("caller continues after nested Shadow root");
9926 assert!(trailing_card > 0);
9927 assert!(
9928 !caller_continuation.contains("data-webui-resource=\"light-card\""),
9929 "returning from the nested Shadow root must restore the outer frame's delivered set"
9930 );
9931 }
9932
9933 #[test]
9934 fn routed_document_styles_are_hoisted_for_the_active_chain() {
9935 let route = WebUiFragmentRoute {
9936 path: "/".to_string(),
9937 fragment_id: "app-shell".to_string(),
9938 children: vec![
9939 WebUiFragmentRoute {
9940 path: String::new(),
9941 fragment_id: "dashboard-page".to_string(),
9942 exact: true,
9943 ..Default::default()
9944 },
9945 WebUiFragmentRoute {
9946 path: "details".to_string(),
9947 fragment_id: "detail-page".to_string(),
9948 exact: true,
9949 ..Default::default()
9950 },
9951 ],
9952 ..Default::default()
9953 };
9954 let fragments = HashMap::from([
9955 (
9956 "index.html".to_string(),
9957 FragmentList {
9958 fragments: vec![
9959 WebUIFragment::raw("<html><head>"),
9960 structural_fragment("head_start"),
9961 structural_fragment("head_end"),
9962 WebUIFragment::raw("</head><body>"),
9963 structural_fragment("body_start"),
9964 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
9965 WebUIFragment::route_from(route),
9966 WebUIFragment::boundary_end(0),
9967 structural_fragment("body_end"),
9968 WebUIFragment::raw("</body></html>"),
9969 ],
9970 contains_boundary: true,
9971 },
9972 ),
9973 (
9974 "app-shell".to_string(),
9975 FragmentList {
9976 fragments: vec![
9977 WebUIFragment::raw("<header>App</header>"),
9978 WebUIFragment::outlet(),
9979 ],
9980 contains_boundary: false,
9981 },
9982 ),
9983 (
9984 "dashboard-page".to_string(),
9985 FragmentList {
9986 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
9987 contains_boundary: false,
9988 },
9989 ),
9990 (
9991 "detail-page".to_string(),
9992 FragmentList {
9993 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
9994 contains_boundary: false,
9995 },
9996 ),
9997 ]);
9998 let mut protocol = WebUIProtocol::new(fragments);
9999 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10000 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10001 let component = protocol.components.entry(tag.to_string()).or_default();
10002 component.css_href = format!("/{tag}.css");
10003 component.template_json = r#"{"h":""}"#.to_string();
10004 }
10005 protocol.populate_style_closures(&["index.html"]);
10006
10007 let mut writer = TestWriter::new();
10008 handle(
10009 &protocol,
10010 &test_json!({}),
10011 &RenderOptions::new("index.html", "/"),
10012 &mut writer,
10013 )
10014 .unwrap();
10015 let html = writer.get_content();
10016
10017 assert_eq!(
10018 html.matches(r#"data-webui-resource="app-shell""#).count(),
10019 1,
10020 "the matched route shell must install once: {html}"
10021 );
10022 assert_eq!(
10023 html.matches(r#"data-webui-resource="dashboard-page""#)
10024 .count(),
10025 1,
10026 "the matched child route must install once: {html}"
10027 );
10028 assert!(
10029 !html.contains(r#"data-webui-resource="detail-page""#),
10030 "inactive route CSS must not be delivered: {html}"
10031 );
10032 let head_end = html.find("</head>").expect("head close");
10033 let head = &html[..head_end];
10034 for (resource, href) in [
10035 ("app-shell", "/app-shell.css"),
10036 ("dashboard-page", "/dashboard-page.css"),
10037 ] {
10038 let encoded_href = crate::html_encode::encode_safe(href);
10039 assert!(
10040 head.contains(&format!(
10041 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10042 )),
10043 "Document-targeted route CSS must be render-blocking in head: {html}"
10044 );
10045 assert!(
10046 !head.contains(&format!(
10047 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10048 )),
10049 "a hoisted stylesheet must not also be preloaded: {html}"
10050 );
10051 }
10052 assert!(
10053 !head.contains("detail-page.css"),
10054 "inactive route CSS must not be preloaded: {html}"
10055 );
10056 let app_style = html
10057 .find(r#"data-webui-resource="app-shell""#)
10058 .expect("app style");
10059 let dashboard_style = html
10060 .find(r#"data-webui-resource="dashboard-page""#)
10061 .expect("dashboard style");
10062 let app_host = html.find("<app-shell").expect("app route host");
10063 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10064 assert!(
10065 app_style < dashboard_style
10066 && dashboard_style < head_end
10067 && head_end < app_host
10068 && app_host < dashboard_host,
10069 "matched Document route closures must be hoisted in chain order: {html}"
10070 );
10071
10072 let protocol = Protocol::new(protocol);
10073 let mut streamed = FlushTestWriter::default();
10074 WebUIHandler::new()
10075 .render_streaming(
10076 &protocol,
10077 &test_json!({}),
10078 &RenderOptions::new("index.html", "/"),
10079 &mut streamed,
10080 )
10081 .unwrap();
10082 let streamed_head_end = streamed
10083 .output
10084 .find("</head>")
10085 .expect("streamed head close");
10086 for resource in ["app-shell", "dashboard-page"] {
10087 let marker = format!(r#"data-webui-resource="{resource}""#);
10088 let position = streamed.output.find(&marker).expect("streamed route style");
10089 assert!(
10090 position < streamed_head_end,
10091 "streaming must hoist {resource} into head: {}",
10092 streamed.output
10093 );
10094 assert_eq!(
10095 streamed.output.matches(&marker).count(),
10096 1,
10097 "streaming must not emit {resource} twice: {}",
10098 streamed.output
10099 );
10100 }
10101 }
10102
10103 #[test]
10104 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10105 let route = WebUiFragmentRoute {
10106 path: "/".to_string(),
10107 fragment_id: "dashboard-page".to_string(),
10108 exact: true,
10109 ..Default::default()
10110 };
10111 let mut protocol = WebUIProtocol::new(HashMap::from([
10112 (
10113 "index.html".to_string(),
10114 FragmentList {
10115 fragments: vec![
10116 WebUIFragment::raw("<html><head>"),
10117 structural_fragment("head_start"),
10118 structural_fragment("head_end"),
10119 WebUIFragment::raw("</head><body>"),
10120 WebUIFragment::route_from(route),
10121 structural_fragment("body_end"),
10122 WebUIFragment::raw("</body></html>"),
10123 ],
10124 contains_boundary: false,
10125 },
10126 ),
10127 (
10128 "dashboard-page".to_string(),
10129 FragmentList {
10130 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10131 contains_boundary: false,
10132 },
10133 ),
10134 ]));
10135 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10136 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10137 protocol
10138 .components
10139 .entry(tag.to_string())
10140 .or_default()
10141 .css_href = format!("/{tag}.css");
10142 }
10143 protocol.style_closures.insert(
10144 "index.html".to_string(),
10145 webui_protocol::ComponentStyleClosure::default(),
10146 );
10147 protocol.style_closures.insert(
10148 "dashboard-page".to_string(),
10149 webui_protocol::ComponentStyleClosure {
10150 component_tags: vec![
10151 "dashboard-page".to_string(),
10152 "summary-card".to_string(),
10153 "activity-list".to_string(),
10154 ],
10155 style_chunks: vec![0],
10156 },
10157 );
10158 protocol.style_chunks.push(webui_protocol::StyleChunk {
10159 name: "_chunk-dashboard-page-3".to_string(),
10160 css: String::new(),
10161 css_href: "/_chunk-dashboard-page-3.css".to_string(),
10162 component_tags: vec![
10163 "dashboard-page".to_string(),
10164 "summary-card".to_string(),
10165 "activity-list".to_string(),
10166 ],
10167 });
10168
10169 let mut writer = TestWriter::new();
10170 handle(
10171 &protocol,
10172 &test_json!({}),
10173 &RenderOptions::new("index.html", "/"),
10174 &mut writer,
10175 )
10176 .unwrap();
10177 let html = writer.get_content();
10178 let head_end = html.find("</head>").expect("head close");
10179 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10180 let style = html.find(marker).expect("bundled route stylesheet");
10181
10182 assert!(
10183 style < head_end,
10184 "bundled route CSS must be in head: {html}"
10185 );
10186 assert_eq!(
10187 html.matches(marker).count(),
10188 1,
10189 "the route host must not emit the hoisted chunk again: {html}"
10190 );
10191 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10192 assert!(
10193 !html.contains(&format!(
10194 r#"<link rel="preload" as="style" href="{bundled_href}">"#
10195 )),
10196 "the applied bundled stylesheet must not also be preloaded: {html}"
10197 );
10198 }
10199
10200 #[test]
10201 fn routed_document_inline_styles_are_hoisted() {
10202 for strategy in [
10203 webui_protocol::CssStrategy::Style,
10204 webui_protocol::CssStrategy::Module,
10205 ] {
10206 let route = WebUiFragmentRoute {
10207 path: "/".to_string(),
10208 fragment_id: "dashboard-page".to_string(),
10209 exact: true,
10210 ..Default::default()
10211 };
10212 let mut protocol = WebUIProtocol::new(HashMap::from([
10213 (
10214 "index.html".to_string(),
10215 FragmentList {
10216 fragments: vec![
10217 WebUIFragment::raw("<html><head>"),
10218 structural_fragment("head_start"),
10219 structural_fragment("head_end"),
10220 WebUIFragment::raw("</head><body>"),
10221 WebUIFragment::route_from(route),
10222 structural_fragment("body_end"),
10223 WebUIFragment::raw("</body></html>"),
10224 ],
10225 contains_boundary: false,
10226 },
10227 ),
10228 (
10229 "dashboard-page".to_string(),
10230 FragmentList {
10231 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10232 contains_boundary: false,
10233 },
10234 ),
10235 ]));
10236 protocol.set_css_strategy(strategy);
10237 protocol
10238 .components
10239 .entry("dashboard-page".to_string())
10240 .or_default()
10241 .css = ".dashboard{display:grid}".to_string();
10242 protocol.populate_style_closures(&["index.html"]);
10243
10244 let mut writer = TestWriter::new();
10245 handle(
10246 &protocol,
10247 &test_json!({}),
10248 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10249 &mut writer,
10250 )
10251 .unwrap();
10252 let html = writer.get_content();
10253 let head_end = html.find("</head>").expect("head close");
10254 let marker = r#"data-webui-resource="dashboard-page""#;
10255 let style = html.find(marker).expect("route style fallback");
10256 let host = html.find("<dashboard-page").expect("dashboard route host");
10257 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10258 "module"
10259 } else {
10260 "style"
10261 };
10262 let style_marker = format!(
10263 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10264 );
10265
10266 assert!(
10267 style < head_end && head_end < host,
10268 "{strategy:?} route CSS must be applied in head before its host: {html}"
10269 );
10270 assert_eq!(
10271 html.matches(&style_marker).count(),
10272 1,
10273 "{strategy:?} route CSS must not be emitted twice: {html}"
10274 );
10275 }
10276 }
10277
10278 #[test]
10279 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10280 let route = WebUiFragmentRoute {
10281 path: "/".to_string(),
10282 fragment_id: "dashboard-page".to_string(),
10283 exact: true,
10284 ..Default::default()
10285 };
10286 let mut protocol = WebUIProtocol::new(HashMap::from([
10287 (
10288 "index.html".to_string(),
10289 FragmentList {
10290 fragments: vec![
10291 WebUIFragment::raw("<html><head>"),
10292 structural_fragment("head_start"),
10293 structural_fragment("head_end"),
10294 WebUIFragment::raw("</head><body><outer-box>"),
10295 WebUIFragment::component("outer-box"),
10296 WebUIFragment::raw("</outer-box></body></html>"),
10297 ],
10298 contains_boundary: false,
10299 },
10300 ),
10301 (
10302 "outer-box".to_string(),
10303 FragmentList {
10304 fragments: vec![
10305 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10306 structural_fragment("shadow_styles:outer-box"),
10307 WebUIFragment::route_from(route),
10308 WebUIFragment::raw("</template>"),
10309 ],
10310 contains_boundary: false,
10311 },
10312 ),
10313 (
10314 "dashboard-page".to_string(),
10315 FragmentList {
10316 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10317 contains_boundary: false,
10318 },
10319 ),
10320 ]));
10321 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10322 for tag in ["outer-box", "dashboard-page"] {
10323 protocol
10324 .components
10325 .entry(tag.to_string())
10326 .or_default()
10327 .css_href = format!("/{tag}.css");
10328 }
10329 protocol
10330 .components
10331 .get_mut("outer-box")
10332 .expect("outer-box component")
10333 .uses_shadow_dom = true;
10334 protocol.populate_style_closures(&["index.html"]);
10335
10336 let mut writer = TestWriter::new();
10337 handle(
10338 &protocol,
10339 &test_json!({}),
10340 &RenderOptions::new("index.html", "/"),
10341 &mut writer,
10342 )
10343 .unwrap();
10344 let html = writer.get_content();
10345 let head_end = html.find("</head>").expect("head close");
10346 let head = &html[..head_end];
10347 let dashboard_style = html
10348 .find(r#"data-webui-resource="dashboard-page""#)
10349 .expect("tree-local dashboard stylesheet");
10350 let shadow_start = html
10351 .find("<template shadowrootmode=\"open\">")
10352 .expect("Shadow template");
10353 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10354 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10355
10356 assert!(
10357 head.contains(&format!(
10358 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10359 )),
10360 "Shadow-targeted route CSS must start loading from head: {html}"
10361 );
10362 assert!(
10363 !head.contains(r#"data-webui-resource="dashboard-page""#),
10364 "Shadow-targeted route CSS must not be applied to Document: {html}"
10365 );
10366 assert!(
10367 shadow_start < dashboard_style && dashboard_style < dashboard_host,
10368 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10369 );
10370 }
10371
10372 #[test]
10373 fn isolated_shadow_entry_does_not_emit_document_preloads() {
10374 let route = WebUiFragmentRoute {
10375 path: "/".to_string(),
10376 fragment_id: "dashboard-page".to_string(),
10377 exact: true,
10378 ..Default::default()
10379 };
10380 let mut protocol = WebUIProtocol::new(HashMap::from([
10381 (
10382 "outer-box".to_string(),
10383 FragmentList {
10384 fragments: vec![
10385 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10386 structural_fragment("shadow_styles:outer-box"),
10387 WebUIFragment::route_from(route),
10388 WebUIFragment::raw("</template>"),
10389 ],
10390 contains_boundary: false,
10391 },
10392 ),
10393 (
10394 "dashboard-page".to_string(),
10395 FragmentList {
10396 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10397 contains_boundary: false,
10398 },
10399 ),
10400 ]));
10401 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10402 for tag in ["outer-box", "dashboard-page"] {
10403 protocol
10404 .components
10405 .entry(tag.to_string())
10406 .or_default()
10407 .css_href = format!("/{tag}.css");
10408 }
10409 protocol
10410 .components
10411 .get_mut("outer-box")
10412 .expect("outer-box component")
10413 .uses_shadow_dom = true;
10414 protocol.populate_style_closures(&["outer-box"]);
10415
10416 let mut writer = TestWriter::new();
10417 handle(
10418 &protocol,
10419 &test_json!({}),
10420 &RenderOptions::new("outer-box", "/"),
10421 &mut writer,
10422 )
10423 .unwrap();
10424 let html = writer.get_content();
10425
10426 assert!(
10427 html.starts_with("<template shadowrootmode=\"open\">"),
10428 "isolated component output must not be prefixed with document metadata: {html}"
10429 );
10430 assert!(
10431 !html.contains(r#"<link rel="preload""#),
10432 "an isolated Shadow entry has no Document head for preloads: {html}"
10433 );
10434 assert!(
10435 html.contains(r#"data-webui-resource="dashboard-page""#),
10436 "the route stylesheet must still install inside the ShadowRoot: {html}"
10437 );
10438 }
10439
10440 #[test]
10441 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10442 let route = WebUiFragmentRoute {
10443 path: "/".to_string(),
10444 fragment_id: "app-shell".to_string(),
10445 children: vec![
10446 WebUiFragmentRoute {
10447 path: String::new(),
10448 fragment_id: "dashboard-page".to_string(),
10449 exact: true,
10450 ..Default::default()
10451 },
10452 WebUiFragmentRoute {
10453 path: "details".to_string(),
10454 fragment_id: "detail-page".to_string(),
10455 exact: true,
10456 ..Default::default()
10457 },
10458 ],
10459 ..Default::default()
10460 };
10461 let fragments = HashMap::from([
10462 (
10463 "index.html".to_string(),
10464 FragmentList {
10465 fragments: vec![
10466 WebUIFragment::raw("<html><head>"),
10467 structural_fragment("head_start"),
10468 structural_fragment("head_end"),
10469 WebUIFragment::raw("</head><body>"),
10470 structural_fragment("body_start"),
10471 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10472 WebUIFragment::route_from(route),
10473 WebUIFragment::boundary_end(0),
10474 structural_fragment("body_end"),
10475 WebUIFragment::raw("</body></html>"),
10476 ],
10477 contains_boundary: true,
10478 },
10479 ),
10480 (
10481 "app-shell".to_string(),
10482 FragmentList {
10483 fragments: vec![
10484 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10485 structural_fragment("shadow_styles:app-shell"),
10486 WebUIFragment::raw("<shared-card"),
10487 structural_fragment("streaming_root:shared-card"),
10488 WebUIFragment::raw(">"),
10489 WebUIFragment::component("shared-card"),
10490 WebUIFragment::raw("</shared-card>"),
10491 WebUIFragment::outlet(),
10492 WebUIFragment::raw("</template>"),
10493 ],
10494 contains_boundary: false,
10495 },
10496 ),
10497 (
10498 "dashboard-page".to_string(),
10499 FragmentList {
10500 fragments: vec![
10501 WebUIFragment::raw("<shared-card"),
10502 structural_fragment("streaming_root:shared-card"),
10503 WebUIFragment::raw(">"),
10504 WebUIFragment::component("shared-card"),
10505 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10506 ],
10507 contains_boundary: false,
10508 },
10509 ),
10510 (
10511 "detail-page".to_string(),
10512 FragmentList {
10513 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10514 contains_boundary: false,
10515 },
10516 ),
10517 (
10518 "shared-card".to_string(),
10519 FragmentList {
10520 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10521 contains_boundary: false,
10522 },
10523 ),
10524 ]);
10525 let mut protocol = WebUIProtocol::new(fragments);
10526 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10527 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10528 let component = protocol.components.entry(tag.to_string()).or_default();
10529 component.css = format!(".{tag}{{display:block}}");
10530 component.template_json = r#"{"h":""}"#.to_string();
10531 }
10532 protocol
10533 .components
10534 .get_mut("app-shell")
10535 .expect("app shell component")
10536 .uses_shadow_dom = true;
10537 protocol.populate_style_closures(&["index.html"]);
10538
10539 let mut writer = TestWriter::new();
10540 handle(
10541 &protocol,
10542 &test_json!({}),
10543 &RenderOptions::new("index.html", "/"),
10544 &mut writer,
10545 )
10546 .unwrap();
10547 let html = writer.get_content();
10548
10549 for resource in ["app-shell", "shared-card", "dashboard-page"] {
10550 assert_eq!(
10551 html.matches(&format!(r#"data-webui-resource="{resource}""#))
10552 .count(),
10553 1,
10554 "{resource} must install once in the active ShadowRoot: {html}"
10555 );
10556 }
10557 assert!(
10558 !html.contains(r#"data-webui-resource="detail-page""#),
10559 "inactive route CSS must not enter the ShadowRoot: {html}"
10560 );
10561 let shadow_start = html
10562 .find("<template shadowrootmode=\"open\">")
10563 .expect("Shadow template");
10564 let dashboard_style = html
10565 .find(r#"data-webui-resource="dashboard-page""#)
10566 .expect("dashboard style");
10567 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10568 let shadow_end = html.find("</template>").expect("Shadow template close");
10569 assert!(
10570 shadow_start < dashboard_style
10571 && dashboard_style < dashboard_host
10572 && dashboard_host < shadow_end,
10573 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10574 );
10575
10576 let protocol = Protocol::new(protocol);
10577 let mut streamed = FlushTestWriter::default();
10578 WebUIHandler::new()
10579 .render_streaming(
10580 &protocol,
10581 &test_json!({}),
10582 &RenderOptions::new("index.html", "/"),
10583 &mut streamed,
10584 )
10585 .unwrap();
10586 for resource in ["app-shell", "shared-card"] {
10587 assert_eq!(
10588 streamed
10589 .output
10590 .matches(&format!(r#"data-webui-resource="{resource}""#))
10591 .count(),
10592 1,
10593 "streaming must preserve tree-local delivery for {resource}: {}",
10594 streamed.output
10595 );
10596 }
10597 assert!(
10598 !streamed
10599 .output
10600 .contains(r#"data-webui-resource="dashboard-page""#),
10601 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10602 streamed.output
10603 );
10604 assert!(
10605 streamed
10606 .output
10607 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10608 && streamed
10609 .output
10610 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10611 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10612 streamed.output
10613 );
10614 assert!(
10615 !streamed
10616 .output
10617 .contains(r#"data-webui-resource="detail-page""#),
10618 "streaming must omit inactive route CSS: {}",
10619 streamed.output
10620 );
10621 }
10622
10623 #[test]
10624 fn document_styles_follow_doctype_before_headless_document_content() {
10625 for entry_fragments in [
10626 vec![
10627 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10628 structural_fragment("body_start"),
10629 WebUIFragment::raw("<my-card>"),
10630 WebUIFragment::component("my-card"),
10631 WebUIFragment::raw("</my-card></body></html>"),
10632 ],
10633 vec![
10634 WebUIFragment::raw("<!doctype html><html><my-card>"),
10635 WebUIFragment::component("my-card"),
10636 WebUIFragment::raw("</my-card></html>"),
10637 ],
10638 ] {
10639 let mut protocol = WebUIProtocol::new(HashMap::from([
10640 (
10641 "index.html".to_string(),
10642 FragmentList {
10643 fragments: entry_fragments,
10644 contains_boundary: false,
10645 },
10646 ),
10647 (
10648 "my-card".to_string(),
10649 FragmentList {
10650 fragments: vec![WebUIFragment::raw("<p>card</p>")],
10651 contains_boundary: false,
10652 },
10653 ),
10654 ]));
10655 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10656 let component = protocol
10657 .components
10658 .entry("my-card".to_string())
10659 .or_default();
10660 component.css = ".card{color:red}".to_string();
10661 component.uses_shadow_dom = false;
10662 protocol.populate_style_closures(&["index.html"]);
10663
10664 let mut writer = TestWriter::new();
10665 handle(
10666 &protocol,
10667 &test_json!({}),
10668 &RenderOptions::new("index.html", "/"),
10669 &mut writer,
10670 )
10671 .expect("render");
10672 let html = writer.get_content();
10673
10674 assert!(
10675 html.to_ascii_lowercase().starts_with("<!doctype html>"),
10676 "the doctype must remain the first document token: {html}"
10677 );
10678 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10679 let document = html.find("<html").expect("document root");
10680 let component = html.find("<my-card>").expect("component host");
10681 assert!(
10682 style < document && style < component,
10683 "headless document styles must precede document content: {html}"
10684 );
10685 }
10686 }
10687
10688 #[test]
10689 fn authored_shadow_component_can_render_as_entry_fragment() {
10690 let mut protocol = WebUIProtocol::new(HashMap::from([(
10691 "my-card".to_string(),
10692 FragmentList {
10693 fragments: vec![
10694 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10695 structural_fragment("shadow_styles:my-card"),
10696 WebUIFragment::raw("<p>card</p></template>"),
10697 ],
10698 contains_boundary: false,
10699 },
10700 )]));
10701 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10702 let component = protocol
10703 .components
10704 .entry("my-card".to_string())
10705 .or_default();
10706 component.css = ".card{color:red}".to_string();
10707 component.uses_shadow_dom = true;
10708 protocol.populate_style_closures(&["my-card"]);
10709
10710 let mut writer = TestWriter::new();
10711 handle(
10712 &protocol,
10713 &test_json!({}),
10714 &RenderOptions::new("my-card", "/"),
10715 &mut writer,
10716 )
10717 .expect("render");
10718 let html = writer.get_content();
10719
10720 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10721 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10722 assert!(html.ends_with("<p>card</p></template>"));
10723 }
10724
10725 #[test]
10726 fn component_style_closures_escape_mixed_case_style_end_tags() {
10727 for strategy in [
10728 webui_protocol::CssStrategy::Style,
10729 webui_protocol::CssStrategy::Module,
10730 ] {
10731 let mut protocol = WebUIProtocol::new(HashMap::from([
10732 (
10733 "index.html".to_string(),
10734 FragmentList {
10735 fragments: vec![
10736 WebUIFragment::raw("<html><head>"),
10737 structural_fragment("head_end"),
10738 WebUIFragment::raw("</head><body><safe-card>"),
10739 WebUIFragment::component("safe-card"),
10740 WebUIFragment::raw("</safe-card></body></html>"),
10741 ],
10742 contains_boundary: false,
10743 },
10744 ),
10745 (
10746 "safe-card".to_string(),
10747 FragmentList {
10748 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10749 contains_boundary: false,
10750 },
10751 ),
10752 ]));
10753 protocol.set_css_strategy(strategy);
10754 protocol.components.insert(
10755 "safe-card".to_string(),
10756 webui_protocol::ComponentData {
10757 css: ".safe{content:'</StYlE>'}".to_string(),
10758 ..Default::default()
10759 },
10760 );
10761 protocol.populate_style_closures(&["index.html"]);
10762 let mut writer = TestWriter::new();
10763
10764 handle(
10765 &protocol,
10766 &test_json!({}),
10767 &RenderOptions::new("index.html", "/"),
10768 &mut writer,
10769 )
10770 .unwrap();
10771
10772 let html = writer.get_content();
10773 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10774 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10775 }
10776 }
10777
10778 #[test]
10779 fn shadow_closure_supports_link_and_module_ssr_paths() {
10780 for strategy in [
10781 webui_protocol::CssStrategy::Link,
10782 webui_protocol::CssStrategy::Module,
10783 ] {
10784 let mut protocol = WebUIProtocol::new(HashMap::from([
10785 (
10786 "index.html".to_string(),
10787 FragmentList {
10788 fragments: vec![
10789 WebUIFragment::raw("<html><head>".to_string()),
10790 structural_fragment("head_end"),
10791 WebUIFragment::raw("</head><body><my-card>".to_string()),
10792 WebUIFragment::component("my-card"),
10793 WebUIFragment::raw("</my-card></body></html>".to_string()),
10794 ],
10795 contains_boundary: false,
10796 },
10797 ),
10798 (
10799 "my-card".to_string(),
10800 FragmentList {
10801 fragments: vec![
10802 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10803 structural_fragment("shadow_styles:my-card"),
10804 WebUIFragment::raw("<p>card</p></template>".to_string()),
10805 ],
10806 contains_boundary: false,
10807 },
10808 ),
10809 ]));
10810 protocol.set_css_strategy(strategy);
10811 let component = protocol
10812 .components
10813 .entry("my-card".to_string())
10814 .or_default();
10815 component.css = ".card{color:red}".to_string();
10816 component.css_href = "/my-card.css".to_string();
10817 component.uses_shadow_dom = true;
10818 protocol.populate_style_closures(&["index.html"]);
10819
10820 let mut writer = TestWriter::new();
10821 handle(
10822 &protocol,
10823 &test_json!({}),
10824 &RenderOptions::new("index.html", "/"),
10825 &mut writer,
10826 )
10827 .unwrap();
10828 let html = writer.get_content();
10829 match strategy {
10830 webui_protocol::CssStrategy::Link => {
10831 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10832 assert!(html.contains(&format!(
10833 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10834 )));
10835 }
10836 webui_protocol::CssStrategy::Module => {
10837 assert!(html.contains(
10838 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10839 ));
10840 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10841 }
10842 webui_protocol::CssStrategy::Style => unreachable!(),
10843 }
10844 }
10845 }
10846
10847 #[test]
10848 fn test_module_preloads_emit_in_head_in_compiler_order() {
10849 let mut fragments = HashMap::new();
10853 fragments.insert(
10854 "index.html".to_string(),
10855 FragmentList {
10856 fragments: vec![
10857 WebUIFragment::raw(
10858 r#"<html><head><script type="module" async src="/index.js"></script>"#
10859 .to_string(),
10860 ),
10861 structural_fragment("head_end"),
10862 WebUIFragment::raw("</head><body>".to_string()),
10863 structural_fragment("body_end"),
10864 WebUIFragment::raw("</body></html>".to_string()),
10865 ],
10866 contains_boundary: false,
10867 },
10868 );
10869
10870 let mut protocol = WebUIProtocol::new(fragments);
10871 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
10872
10873 let state = test_json!({});
10874 let mut writer = TestWriter::new();
10875 handle(
10876 &protocol,
10877 &state,
10878 &RenderOptions::new("index.html", "/"),
10879 &mut writer,
10880 )
10881 .unwrap();
10882
10883 let html = writer.get_content();
10884 let head_end = html.find("</head>").expect("</head> missing");
10885 let head = &html[..head_end];
10886 assert!(
10887 head.contains(
10888 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
10889 ),
10890 "hints must appear in <head> in the compiler's order: {html}"
10891 );
10892 }
10893
10894 #[test]
10895 fn test_no_module_preloads_emits_nothing() {
10896 let mut fragments = HashMap::new();
10897 fragments.insert(
10898 "index.html".to_string(),
10899 FragmentList {
10900 fragments: vec![
10901 WebUIFragment::raw("<html><head>".to_string()),
10902 structural_fragment("head_end"),
10903 WebUIFragment::raw("</head><body>".to_string()),
10904 structural_fragment("body_end"),
10905 WebUIFragment::raw("</body></html>".to_string()),
10906 ],
10907 contains_boundary: false,
10908 },
10909 );
10910 let protocol = WebUIProtocol::new(fragments);
10911
10912 let state = test_json!({});
10913 let mut writer = TestWriter::new();
10914 handle(
10915 &protocol,
10916 &state,
10917 &RenderOptions::new("index.html", "/"),
10918 &mut writer,
10919 )
10920 .unwrap();
10921
10922 assert!(
10923 !writer.get_content().contains("modulepreload"),
10924 "a build without hints must be byte-identical to before"
10925 );
10926 }
10927
10928 #[test]
10929 fn test_link_strategy_head_links_follow_document_order() {
10930 let mut fragments = HashMap::new();
10935 fragments.insert(
10936 "index.html".to_string(),
10937 FragmentList {
10938 fragments: vec![
10939 WebUIFragment::raw("<html><head>".to_string()),
10940 structural_fragment("head_end"),
10941 WebUIFragment::raw("</head><body><z-widget>".to_string()),
10942 WebUIFragment::component("z-widget"),
10943 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
10944 WebUIFragment::component("a-widget"),
10945 WebUIFragment::raw("</a-widget>".to_string()),
10946 structural_fragment("body_end"),
10947 WebUIFragment::raw("</body></html>".to_string()),
10948 ],
10949 contains_boundary: false,
10950 },
10951 );
10952 fragments.insert(
10953 "z-widget".to_string(),
10954 FragmentList {
10955 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
10956 contains_boundary: false,
10957 },
10958 );
10959 fragments.insert(
10960 "a-widget".to_string(),
10961 FragmentList {
10962 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
10963 contains_boundary: false,
10964 },
10965 );
10966
10967 let mut protocol = WebUIProtocol::new(fragments);
10968 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10969
10970 let z = protocol
10971 .components
10972 .entry("z-widget".to_string())
10973 .or_default();
10974 z.css_href = "z-widget.css".to_string();
10975 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
10976
10977 let a = protocol
10978 .components
10979 .entry("a-widget".to_string())
10980 .or_default();
10981 a.css_href = "a-widget.css".to_string();
10982 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
10983 protocol.populate_style_closures(&["index.html"]);
10984
10985 let state = test_json!({});
10986 let mut writer = TestWriter::new();
10987
10988 handle(
10989 &protocol,
10990 &state,
10991 &RenderOptions::new("index.html", "/"),
10992 &mut writer,
10993 )
10994 .unwrap();
10995
10996 let html = writer.get_content();
10997 let head_end = html.find("</head>").expect("</head> missing");
10998 let head_section = &html[..head_end];
10999
11000 let z_pos = head_section
11001 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11002 .expect("z-widget stylesheet link missing from <head>");
11003 let a_pos = head_section
11004 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11005 .expect("a-widget stylesheet link missing from <head>");
11006
11007 assert!(
11008 z_pos < a_pos,
11009 "CSS <link> tags must follow document order (z-widget before \
11010 a-widget), not alphabetical order: {html}"
11011 );
11012 }
11013
11014 #[test]
11015 fn test_css_module_emitted_in_component_light_dom() {
11016 let mut fragments = HashMap::new();
11020 fragments.insert(
11021 "index.html".to_string(),
11022 FragmentList {
11023 fragments: vec![
11024 WebUIFragment::raw("<html><head>".to_string()),
11025 structural_fragment("head_end"),
11026 WebUIFragment::raw("</head><body><my-card>".to_string()),
11027 WebUIFragment::component("my-card"),
11028 WebUIFragment::raw("</my-card>".to_string()),
11029 WebUIFragment::raw("</body></html>".to_string()),
11030 ],
11031 contains_boundary: false,
11032 },
11033 );
11034 fragments.insert(
11035 "my-card".to_string(),
11036 FragmentList {
11037 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11038 contains_boundary: false,
11039 },
11040 );
11041
11042 let mut protocol = WebUIProtocol::new(fragments);
11043 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11044 protocol
11045 .components
11046 .entry("my-card".to_string())
11047 .or_default()
11048 .css = "p{color:red}".to_string();
11049 protocol.populate_style_closures(&["index.html"]);
11050 let state = test_json!({});
11051 let mut writer = TestWriter::new();
11052
11053 handle(
11054 &protocol,
11055 &state,
11056 &RenderOptions::new("index.html", "/"),
11057 &mut writer,
11058 )
11059 .unwrap();
11060
11061 let html = writer.get_content();
11062
11063 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11065 let css_pos = html
11066 .find(r#"<script type="importmap""#)
11067 .expect("CSS module importmap missing");
11068 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11069 assert!(
11070 css_pos > tag_open && css_pos < tag_close,
11071 "CSS module should be inside component light DOM: {html}"
11072 );
11073
11074 let head_end = html.find("</head>").expect("</head> missing");
11076 assert!(
11077 css_pos > head_end,
11078 "CSS module should not be in <head>: {html}"
11079 );
11080 }
11081
11082 #[test]
11083 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11084 let mut protocol = WebUIProtocol::default();
11085 protocol.fragments = HashMap::from([
11086 (
11087 "index.html".to_string(),
11088 FragmentList {
11089 fragments: vec![
11090 WebUIFragment::raw("<legacy-card>"),
11091 WebUIFragment::component("legacy-card"),
11092 WebUIFragment::raw("</legacy-card>"),
11093 ],
11094 contains_boundary: false,
11095 },
11096 ),
11097 (
11098 "legacy-card".to_string(),
11099 FragmentList {
11100 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11101 contains_boundary: false,
11102 },
11103 ),
11104 ]);
11105 protocol.components.insert(
11106 "legacy-card".to_string(),
11107 webui_protocol::ComponentData {
11108 css: ".legacy{color:red}".to_string(),
11109 ..Default::default()
11110 },
11111 );
11112 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11113 let mut writer = TestWriter::new();
11114
11115 let error = handle(
11116 &protocol,
11117 &test_json!({}),
11118 &RenderOptions::new("index.html", "/"),
11119 &mut writer,
11120 )
11121 .expect_err("styled protocols require closure metadata");
11122 assert!(error
11123 .to_string()
11124 .contains("component style closure metadata is required"));
11125 }
11126
11127 #[test]
11128 fn test_css_module_emitted_for_route_components() {
11129 let template = r#"<h1>Dashboard</h1>"#;
11131
11132 let mut fragments = HashMap::new();
11133 fragments.insert(
11134 "index.html".to_string(),
11135 FragmentList {
11136 fragments: vec![
11137 WebUIFragment::raw("<html><head>".to_string()),
11138 structural_fragment("head_end"),
11139 WebUIFragment::raw("</head><body>".to_string()),
11140 WebUIFragment::route("/", "dash-page"),
11141 structural_fragment("body_end"),
11142 WebUIFragment::raw("</body></html>".to_string()),
11143 ],
11144 contains_boundary: false,
11145 },
11146 );
11147 fragments.insert(
11148 "dash-page".to_string(),
11149 FragmentList {
11150 fragments: vec![WebUIFragment::raw(template.to_string())],
11151 contains_boundary: false,
11152 },
11153 );
11154
11155 let mut protocol = WebUIProtocol::new(fragments);
11156 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11157 let comp = protocol
11158 .components
11159 .entry("dash-page".to_string())
11160 .or_default();
11161 comp.css = "h1{font-size:2rem}".to_string();
11162 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11163 protocol.populate_style_closures(&["index.html"]);
11164 let state = test_json!({});
11165 let mut writer = TestWriter::new();
11166
11167 handle(
11168 &protocol,
11169 &state,
11170 &RenderOptions::new("index.html", "/"),
11171 &mut writer,
11172 )
11173 .unwrap();
11174
11175 let html = writer.get_content();
11176
11177 assert!(
11178 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11179 "Route component should have CSS module importmap with data: URI: {html}"
11180 );
11181 assert!(
11182 html.contains("<h1>Dashboard</h1>"),
11183 "Route component should render content: {html}"
11184 );
11185 let fallback = html
11186 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11187 .expect("route Module fallback");
11188 let host_open = html.find("<dash-page>").expect("route host");
11189 let content = html.find("<h1>Dashboard</h1>").expect("route content");
11190 assert!(
11191 fallback < host_open && host_open < content,
11192 "the Module fallback must precede the generated Light route host: {html}"
11193 );
11194 let importmap = html
11200 .find(r#"<script type="importmap""#)
11201 .expect("route CSS module importmap");
11202 assert!(
11203 host_open < importmap,
11204 "the CSS module importmap must live inside the route host, not as a route child: {html}"
11205 );
11206 }
11207
11208 #[test]
11209 fn test_head_css_link_skipped_for_components_without_css() {
11210 let mut fragments = HashMap::new();
11213 fragments.insert(
11214 "index.html".to_string(),
11215 FragmentList {
11216 fragments: vec![
11217 WebUIFragment::raw("<html><head>".to_string()),
11218 structural_fragment("head_end"),
11219 WebUIFragment::raw("</head><body>".to_string()),
11220 WebUIFragment::component("has-css"),
11221 WebUIFragment::component("no-css"),
11222 structural_fragment("body_end"),
11223 WebUIFragment::raw("</body></html>".to_string()),
11224 ],
11225 contains_boundary: false,
11226 },
11227 );
11228 fragments.insert(
11229 "has-css".to_string(),
11230 FragmentList {
11231 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11232 contains_boundary: false,
11233 },
11234 );
11235 fragments.insert(
11236 "no-css".to_string(),
11237 FragmentList {
11238 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11239 contains_boundary: false,
11240 },
11241 );
11242
11243 let mut protocol = WebUIProtocol::new(fragments);
11244 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11245
11246 protocol
11248 .components
11249 .entry("has-css".to_string())
11250 .or_default()
11251 .css_href = "has-css.css".to_string();
11252 protocol
11253 .components
11254 .entry("no-css".to_string())
11255 .or_default()
11256 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11257 protocol.populate_style_closures(&["index.html"]);
11258
11259 let state = test_json!({});
11260 let mut writer = TestWriter::new();
11261
11262 let handler = WebUIHandler::with_plugin(|| {
11263 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11264 });
11265 handler
11266 .handle(
11267 &protocol,
11268 &state,
11269 &RenderOptions::new("index.html", "/"),
11270 &mut writer,
11271 )
11272 .unwrap();
11273
11274 let html = writer.get_content();
11275 assert!(
11276 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11277 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11278 );
11279 assert!(
11280 !html.contains("no-css.css"),
11281 "Component without CSS must NOT get a <link> in <head>: {html}"
11282 );
11283 }
11284
11285 #[test]
11286 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11287 let mut fragments = HashMap::new();
11295 fragments.insert(
11296 "index.html".to_string(),
11297 FragmentList {
11298 fragments: vec![
11299 WebUIFragment::raw("<html><head>".to_string()),
11300 structural_fragment("head_end"),
11301 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11302 WebUIFragment::component("app-shell"),
11303 WebUIFragment::raw("</app-shell>".to_string()),
11304 structural_fragment("body_end"),
11305 WebUIFragment::raw("</body></html>".to_string()),
11306 ],
11307 contains_boundary: false,
11308 },
11309 );
11310 fragments.insert(
11312 "app-shell".to_string(),
11313 FragmentList {
11314 fragments: vec![
11315 WebUIFragment::raw("<div>Shell</div>".to_string()),
11316 WebUIFragment::component("cart-panel"),
11317 ],
11318 contains_boundary: false,
11319 },
11320 );
11321 fragments.insert(
11323 "cart-panel".to_string(),
11324 FragmentList {
11325 fragments: vec![
11326 WebUIFragment::raw("<aside>".to_string()),
11327 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11328 WebUIFragment::raw("</aside>".to_string()),
11329 ],
11330 contains_boundary: false,
11331 },
11332 );
11333 fragments.insert(
11335 "cart-items".to_string(),
11336 FragmentList {
11337 fragments: vec![WebUIFragment::component("product-card")],
11338 contains_boundary: false,
11339 },
11340 );
11341 fragments.insert(
11342 "product-card".to_string(),
11343 FragmentList {
11344 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11345 contains_boundary: false,
11346 },
11347 );
11348
11349 let mut protocol = WebUIProtocol::new(fragments);
11350 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11351 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11352 for name in ["app-shell", "cart-panel", "product-card"] {
11353 let comp = protocol.components.entry(name.to_string()).or_default();
11354 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11355 comp.css = format!(".{name}{{display:block}}");
11356 if name == "cart-panel" {
11357 comp.hydration_mode = StateProjectionMode::Keys as i32;
11358 comp.hydration_keys = vec!["hasItems".to_string()];
11359 }
11360 if name == "product-card" {
11361 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11362 }
11363 }
11364 protocol.populate_style_closures(&["index.html"]);
11365
11366 let state = test_json!({ "hasItems": false });
11368 let mut writer = TestWriter::new();
11369
11370 let handler = WebUIHandler::with_plugin(|| {
11371 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11372 });
11373 handler
11374 .handle(
11375 &protocol,
11376 &state,
11377 &RenderOptions::new("index.html", "/"),
11378 &mut writer,
11379 )
11380 .unwrap();
11381
11382 let html = writer.get_content();
11383
11384 assert!(
11385 html.contains(r#"<script type="application/json" id="webui-data">"#),
11386 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11387 );
11388 assert!(
11389 html.contains(r#""state":{"hasItems":false}"#),
11390 "SSR state should live in the JSON data block: {html}"
11391 );
11392 assert!(
11393 html.contains(r#""inventory":"#),
11394 "SSR inventory should live in the JSON data block: {html}"
11395 );
11396 assert!(
11397 !html.contains("window.__webui={\""),
11398 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11399 );
11400 assert!(
11401 !html.contains(r#"document.getElementById("webui-data")"#),
11402 "SSR must not parse webui-data; client packages own that lazy load: {html}"
11403 );
11404 assert!(
11405 !html.contains("window.__webui=w;"),
11406 "executable bootstrap must not replace existing window.__webui registrations: {html}"
11407 );
11408 assert!(
11409 !html.contains("w.templateFns={\""),
11410 "template function emission must not replace existing templateFns registrations: {html}"
11411 );
11412 assert!(
11413 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11414 "template functions should merge into the flat templateFns registry: {html}"
11415 );
11416
11417 assert!(
11420 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11421 "product-card template should be emitted even when unrendered: {html}"
11422 );
11423
11424 assert!(
11427 html.contains(r#""product-card":"data:text/css,"#),
11428 "reachable product-card CSS module importmap should be emitted: {html}"
11429 );
11430
11431 assert!(
11433 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11434 "rendered app-shell template should be emitted: {html}"
11435 );
11436 assert!(
11437 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11438 "rendered cart-panel template should be emitted: {html}"
11439 );
11440 }
11441
11442 #[test]
11443 fn component_asset_manifest_falls_back_to_body_start() {
11444 let fragments = HashMap::from([(
11445 "index.html".to_string(),
11446 FragmentList {
11447 fragments: vec![
11448 WebUIFragment::raw("<body>".to_string()),
11449 structural_fragment("body_start"),
11450 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11451 ],
11452 contains_boundary: false,
11453 },
11454 )]);
11455 let mut protocol = WebUIProtocol::new(fragments);
11456 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11457 root: "lazy-panel".to_string(),
11458 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11459 }];
11460 let mut writer = TestWriter::new();
11461
11462 handle(
11463 &protocol,
11464 &Value::Null,
11465 &RenderOptions::new("index.html", "/"),
11466 &mut writer,
11467 )
11468 .unwrap();
11469
11470 assert_eq!(
11471 writer.get_content(),
11472 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11473 );
11474 }
11475
11476 #[test]
11477 fn component_asset_styles_emit_once_when_body_precedes_head() {
11478 let fragments = HashMap::from([(
11479 "index.html".to_string(),
11480 FragmentList {
11481 fragments: vec![
11482 WebUIFragment::raw("<body>".to_string()),
11483 structural_fragment("body_start"),
11484 WebUIFragment::raw("</body><head>".to_string()),
11485 structural_fragment("head_end"),
11486 WebUIFragment::raw("</head>".to_string()),
11487 ],
11488 contains_boundary: false,
11489 },
11490 )]);
11491 let mut protocol = WebUIProtocol::new(fragments);
11492 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11493 root: "lazy-panel".to_string(),
11494 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11495 }];
11496 let mut writer = TestWriter::new();
11497
11498 handle(
11499 &protocol,
11500 &Value::Null,
11501 &RenderOptions::new("index.html", "/"),
11502 &mut writer,
11503 )
11504 .unwrap();
11505
11506 assert_eq!(
11507 writer
11508 .get_content()
11509 .matches(r#"id="webui-component-assets""#)
11510 .count(),
11511 1
11512 );
11513 }
11514
11515 #[test]
11516 fn light_component_asset_styles_apply_at_head_end() {
11517 let fragments = HashMap::from([(
11518 "index.html".to_string(),
11519 FragmentList {
11520 fragments: vec![
11521 WebUIFragment::raw("<html><head>".to_string()),
11522 structural_fragment("head_end"),
11523 WebUIFragment::raw("</head><body></body></html>".to_string()),
11524 ],
11525 contains_boundary: false,
11526 },
11527 )]);
11528 let mut protocol = WebUIProtocol::new(fragments);
11529 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11530 for root in ["lazy-panel", "secondary-panel"] {
11531 protocol.components.insert(
11532 root.to_string(),
11533 webui_protocol::ComponentData {
11534 uses_shadow_dom: false,
11535 ..Default::default()
11536 },
11537 );
11538 }
11539 protocol.component_asset_style_preloads = vec![
11540 ComponentAssetStylePreload {
11541 root: "lazy-panel".to_string(),
11542 style_hrefs: vec![
11543 "/assets/lazy-panel.css".to_string(),
11544 "/assets/shared.css".to_string(),
11545 ],
11546 },
11547 ComponentAssetStylePreload {
11548 root: "secondary-panel".to_string(),
11549 style_hrefs: vec![
11550 "/assets/secondary-panel.css".to_string(),
11551 "/assets/shared.css".to_string(),
11552 ],
11553 },
11554 ];
11555 let mut writer = TestWriter::new();
11556
11557 handle(
11558 &protocol,
11559 &Value::Null,
11560 &RenderOptions::new("index.html", "/"),
11561 &mut writer,
11562 )
11563 .unwrap();
11564
11565 let html = writer.get_content();
11566 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11567 assert_eq!(html.matches("/assets/shared.css").count(), 1);
11568 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11569 let links_end = html
11570 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11571 .expect("asset stylesheet must be emitted");
11572 let head_end = html.find("</head>").expect("head must close");
11573 assert!(links_end < head_end);
11574 }
11575
11576 #[test]
11586 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11587 let template = r#"<h1>Dashboard</h1>"#;
11589
11590 let mut fragments = HashMap::new();
11591 fragments.insert(
11592 "index.html".to_string(),
11593 FragmentList {
11594 fragments: vec![
11595 WebUIFragment::raw("<html><head>".to_string()),
11596 structural_fragment("head_end"),
11597 WebUIFragment::raw("</head><body>".to_string()),
11598 WebUIFragment::route("/", "dash-page"),
11599 structural_fragment("body_end"),
11600 WebUIFragment::raw("</body></html>".to_string()),
11601 ],
11602 contains_boundary: false,
11603 },
11604 );
11605 fragments.insert(
11606 "dash-page".to_string(),
11607 FragmentList {
11608 fragments: vec![WebUIFragment::raw(template.to_string())],
11609 contains_boundary: false,
11610 },
11611 );
11612
11613 let mut protocol = WebUIProtocol::new(fragments);
11614 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11615 let comp = protocol
11616 .components
11617 .entry("dash-page".to_string())
11618 .or_default();
11619 comp.css = "h1{font-size:2rem}".to_string();
11620 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11621 protocol.populate_style_closures(&["index.html"]);
11622 let state = test_json!({});
11623 let mut writer = TestWriter::new();
11624
11625 handle(
11626 &protocol,
11627 &state,
11628 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11629 &mut writer,
11630 )
11631 .unwrap();
11632
11633 let html = writer.get_content();
11634
11635 assert!(
11636 html.contains(
11637 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11638 ),
11639 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11640 );
11641 }
11642
11643 #[test]
11644 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11645 let mut fragments = HashMap::new();
11650 fragments.insert(
11651 "index.html".to_string(),
11652 FragmentList {
11653 fragments: vec![
11654 WebUIFragment::raw("<html><head>".to_string()),
11655 structural_fragment("head_end"),
11656 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11657 WebUIFragment::component("app-shell"),
11658 WebUIFragment::raw("</app-shell>".to_string()),
11659 structural_fragment("body_end"),
11660 WebUIFragment::raw("</body></html>".to_string()),
11661 ],
11662 contains_boundary: false,
11663 },
11664 );
11665 fragments.insert(
11666 "app-shell".to_string(),
11667 FragmentList {
11668 fragments: vec![WebUIFragment::if_cond(
11669 ConditionExpr::identifier("hasItems"),
11670 "cart-items",
11671 )],
11672 contains_boundary: false,
11673 },
11674 );
11675 fragments.insert(
11676 "cart-items".to_string(),
11677 FragmentList {
11678 fragments: vec![WebUIFragment::component("product-card")],
11679 contains_boundary: false,
11680 },
11681 );
11682 fragments.insert(
11683 "product-card".to_string(),
11684 FragmentList {
11685 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11686 contains_boundary: false,
11687 },
11688 );
11689
11690 let mut protocol = WebUIProtocol::new(fragments);
11691 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11692 for name in ["app-shell", "product-card"] {
11693 let comp = protocol.components.entry(name.to_string()).or_default();
11694 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11695 comp.css = format!(".{name}{{display:block}}");
11696 }
11697 protocol.populate_style_closures(&["index.html"]);
11698
11699 let state = test_json!({ "hasItems": false });
11702 let mut writer = TestWriter::new();
11703
11704 let handler = WebUIHandler::with_plugin(|| {
11705 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11706 });
11707 handler
11708 .handle(
11709 &protocol,
11710 &state,
11711 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11712 &mut writer,
11713 )
11714 .unwrap();
11715
11716 let html = writer.get_content();
11717
11718 assert!(
11719 html.contains(
11720 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11721 ),
11722 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11723 );
11724 }
11725
11726 #[test]
11727 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11728 let mut fragments = HashMap::new();
11729 fragments.insert(
11730 "index.html".to_string(),
11731 FragmentList {
11732 fragments: vec![
11733 WebUIFragment::raw("<html><body><style>".to_string()),
11734 WebUIFragment::signal("tokens.light", true),
11735 WebUIFragment::raw("</style>".to_string()),
11736 structural_fragment("body_end"),
11737 WebUIFragment::raw("</body></html>".to_string()),
11738 ],
11739 contains_boundary: false,
11740 },
11741 );
11742 fragments.insert(
11743 "app-shell".to_string(),
11744 FragmentList {
11745 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
11746 contains_boundary: false,
11747 },
11748 );
11749 let index_fragments = fragments
11750 .get_mut("index.html")
11751 .expect("index fixture should exist");
11752 index_fragments
11753 .fragments
11754 .insert(1, WebUIFragment::component("app-shell"));
11755 let mut protocol = WebUIProtocol::new(fragments);
11756 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11757 protocol.components.insert(
11762 "app-shell".to_string(),
11763 webui_protocol::ComponentData {
11764 hydration_mode: StateProjectionMode::Keys as i32,
11765 hydration_keys: vec!["name".to_string()],
11766 ..Default::default()
11767 },
11768 );
11769 let state = test_json!({
11770 "name": "Alice",
11771 "tokens": {
11772 "light": "--color-brand: red;"
11773 }
11774 });
11775 let mut writer = TestWriter::new();
11776 let handler = WebUIHandler::with_plugin(|| {
11777 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11778 });
11779 handler.handle(
11780 &protocol,
11781 &state,
11782 &RenderOptions::new("index.html", "/"),
11783 &mut writer,
11784 )?;
11785 let output = writer.get_content();
11786
11787 assert!(output.contains("--color-brand: red;"));
11789 assert!(output.contains(r#""name":"Alice""#));
11791 assert!(!output.contains(r#""tokens""#));
11792 Ok(())
11793 }
11794
11795 #[test]
11796 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11797 let mut fragments = HashMap::new();
11798 fragments.insert(
11799 "index.html".to_string(),
11800 FragmentList {
11801 fragments: vec![
11802 WebUIFragment::raw("<html><body>"),
11803 structural_fragment("body_end"),
11804 WebUIFragment::raw("</body></html>"),
11805 ],
11806 contains_boundary: false,
11807 },
11808 );
11809 let protocol = WebUIProtocol::new(fragments);
11810 let state = test_json!({
11811 "client": "visible",
11812 "serverOnly": "also preserved",
11813 });
11814 let handler = WebUIHandler::with_plugin(|| {
11815 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11816 });
11817 let mut writer = TestWriter::new();
11818 handler.handle(
11819 &protocol,
11820 &state,
11821 &RenderOptions::new("index.html", "/"),
11822 &mut writer,
11823 )?;
11824 let output = writer.get_content();
11825 assert!(output.contains(r#""client":"visible""#));
11826 assert!(output.contains(r#""serverOnly":"also preserved""#));
11827 Ok(())
11828 }
11829
11830 #[test]
11831 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11832 let mut fragments = HashMap::new();
11833 fragments.insert(
11834 "index.html".to_string(),
11835 FragmentList {
11836 fragments: vec![
11837 WebUIFragment::raw("<html><body>"),
11838 WebUIFragment::component("app-shell"),
11839 structural_fragment("body_end"),
11840 WebUIFragment::raw("</body></html>"),
11841 ],
11842 contains_boundary: false,
11843 },
11844 );
11845 fragments.insert(
11846 "app-shell".to_string(),
11847 FragmentList {
11848 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11849 contains_boundary: false,
11850 },
11851 );
11852 let mut protocol = WebUIProtocol::new(fragments);
11853 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11854 protocol.components.insert(
11855 "app-shell".to_string(),
11856 webui_protocol::ComponentData {
11857 hydration_mode: StateProjectionMode::All as i32,
11858 ..Default::default()
11859 },
11860 );
11861 let state = test_json!({
11862 "known": "value",
11863 "possiblyInherited": "must not be dropped",
11864 });
11865 let handler = WebUIHandler::with_plugin(|| {
11866 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11867 });
11868 let mut writer = TestWriter::new();
11869 handler.handle(
11870 &protocol,
11871 &state,
11872 &RenderOptions::new("index.html", "/"),
11873 &mut writer,
11874 )?;
11875 let output = writer.get_content();
11876 assert!(output.contains(r#""known":"value""#));
11877 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
11878 Ok(())
11879 }
11880
11881 #[test]
11882 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
11883 let mut fragments = HashMap::new();
11884 fragments.insert(
11885 "index.html".to_string(),
11886 FragmentList {
11887 fragments: vec![
11888 WebUIFragment::raw("<html><body>"),
11889 WebUIFragment::component("app-shell"),
11890 structural_fragment("body_end"),
11891 WebUIFragment::raw("</body></html>"),
11892 ],
11893 contains_boundary: false,
11894 },
11895 );
11896 fragments.insert(
11897 "app-shell".to_string(),
11898 FragmentList {
11899 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11900 contains_boundary: false,
11901 },
11902 );
11903 let mut protocol = WebUIProtocol::new(fragments);
11904 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11905 let state = test_json!({
11906 "known": "value",
11907 "serverOnly": "must not be dropped",
11908 });
11909 let handler = WebUIHandler::with_plugin(|| {
11910 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11911 });
11912 let mut writer = TestWriter::new();
11913 handler.handle(
11914 &protocol,
11915 &state,
11916 &RenderOptions::new("index.html", "/"),
11917 &mut writer,
11918 )?;
11919 let output = writer.get_content();
11920 assert!(output.contains(r#""known":"value""#));
11921 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11922 Ok(())
11923 }
11924
11925 #[test]
11926 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
11927 let mut fragments = HashMap::new();
11928 fragments.insert(
11929 "index.html".to_string(),
11930 FragmentList {
11931 fragments: vec![
11932 WebUIFragment::raw("<html><body>"),
11933 WebUIFragment::component("app-shell"),
11934 structural_fragment("body_end"),
11935 WebUIFragment::raw("</body></html>"),
11936 ],
11937 contains_boundary: false,
11938 },
11939 );
11940 fragments.insert(
11941 "app-shell".to_string(),
11942 FragmentList {
11943 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11944 contains_boundary: false,
11945 },
11946 );
11947 let mut protocol = WebUIProtocol::new(fragments);
11948 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11949 protocol.components.insert(
11950 "app-shell".to_string(),
11951 webui_protocol::ComponentData {
11952 hydration_mode: i32::MAX,
11953 ..Default::default()
11954 },
11955 );
11956 let state = test_json!({
11957 "known": "value",
11958 "serverOnly": "must not be dropped",
11959 });
11960 let handler = WebUIHandler::with_plugin(|| {
11961 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11962 });
11963 let mut writer = TestWriter::new();
11964 handler.handle(
11965 &protocol,
11966 &state,
11967 &RenderOptions::new("index.html", "/"),
11968 &mut writer,
11969 )?;
11970 let output = writer.get_content();
11971 assert!(output.contains(r#""known":"value""#));
11972 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11973 Ok(())
11974 }
11975
11976 #[test]
11977 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
11978 let mut protocol = WebUIProtocol::new(HashMap::new());
11979 protocol.components.insert(
11980 "app-shell".to_string(),
11981 webui_protocol::ComponentData {
11982 navigation_keys: vec!["selected".to_string()],
11983 ..Default::default()
11984 },
11985 );
11986
11987 match collect_navigation_state(&protocol, ["app-shell"]) {
11988 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
11989 StateSelection::Full | StateSelection::KeyIds(_) => {
11990 panic!("legacy navigation keys should remain owned and projected")
11991 }
11992 }
11993 }
11994
11995 #[test]
11996 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
11997 let mut protocol = WebUIProtocol::new(HashMap::new());
11998 protocol.components.insert(
11999 "app-shell".to_string(),
12000 webui_protocol::ComponentData::default(),
12001 );
12002
12003 assert!(matches!(
12004 collect_navigation_state(&protocol, ["app-shell"]),
12005 StateSelection::Full
12006 ));
12007 }
12008
12009 #[test]
12010 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12011 let mut fragments = HashMap::new();
12012 fragments.insert(
12013 "index.html".to_string(),
12014 FragmentList {
12015 fragments: vec![
12016 WebUIFragment::raw("<html><body>".to_string()),
12017 structural_fragment("body_end"),
12018 WebUIFragment::raw("</body></html>".to_string()),
12019 ],
12020 contains_boundary: false,
12021 },
12022 );
12023 let mut protocol = WebUIProtocol::new(fragments);
12024 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12025 let state = test_json!({
12026 "title": "Legacy state",
12027 "serverOnly": "preserved",
12028 });
12029 let handler = WebUIHandler::with_plugin(|| {
12030 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12031 });
12032
12033 let mut writer = TestWriter::new();
12034 handler.handle(
12035 &protocol,
12036 &state,
12037 &RenderOptions::new("index.html", "/"),
12038 &mut writer,
12039 )?;
12040 assert!(writer.get_content().contains(r#""state":{}"#));
12041 assert!(!writer.get_content().contains("Legacy state"));
12042 assert!(!writer.get_content().contains("preserved"));
12043 Ok(())
12044 }
12045
12046 #[test]
12047 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12048 let mut fragments = HashMap::new();
12049 fragments.insert(
12050 "index.html".to_string(),
12051 FragmentList {
12052 fragments: vec![
12053 WebUIFragment::raw("<html><body>"),
12054 WebUIFragment::component("items-page"),
12055 structural_fragment("body_end"),
12056 WebUIFragment::raw("</body></html>"),
12057 ],
12058 contains_boundary: false,
12059 },
12060 );
12061 fragments.insert(
12062 "items-page".to_string(),
12063 FragmentList {
12064 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12065 contains_boundary: false,
12066 },
12067 );
12068 let mut protocol = WebUIProtocol::new(fragments);
12069 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12070 protocol.components.insert(
12071 "items-page".to_string(),
12072 webui_protocol::ComponentData {
12073 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12074 navigation_mode: Some(StateProjectionMode::Keys as i32),
12075 navigation_keys: vec!["items".into()],
12076 ..Default::default()
12077 },
12078 );
12079 let state = test_json!({
12080 "items": ["STATE_SENTINEL"],
12081 "serverOnly": "SECRET_SENTINEL",
12082 });
12083 let handler = WebUIHandler::with_plugin(|| {
12084 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12085 });
12086 let mut writer = TestWriter::new();
12087
12088 handler.handle(
12089 &protocol,
12090 &state,
12091 &RenderOptions::new("index.html", "/"),
12092 &mut writer,
12093 )?;
12094
12095 let output = writer.get_content();
12096 assert!(output.contains(r#""state":{}"#));
12097 assert!(!output.contains("STATE_SENTINEL"));
12098 assert!(!output.contains("SECRET_SENTINEL"));
12099 Ok(())
12100 }
12101
12102 #[test]
12103 fn write_selected_state_projects_and_escapes() {
12104 let state = test_json!({
12107 "drop": "secret",
12108 "keep": "</script><b>"
12109 });
12110 let keys = ["keep"];
12111 let selection = StateSelection::Keys(keys.to_vec());
12112 let mut sink = TestWriter::new();
12113 let mut scratch = Vec::new();
12114 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12115 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12116 }
12117
12118 #[test]
12119 fn write_selected_state_non_object_projection_emits_empty_object() {
12120 let state = test_json!("scalar state has nothing hydratable");
12121 let keys: [&str; 0] = [];
12122 let selection = StateSelection::Keys(keys.to_vec());
12123 let mut sink = TestWriter::new();
12124 let mut scratch = Vec::new();
12125 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12126 assert_eq!(sink.get_content(), "{}");
12127 }
12128
12129 #[test]
12130 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12131 let state = test_json!({
12132 "keptA": 1,
12133 "keptB": 2,
12134 "serverOnlyA": 3,
12135 "serverOnlyB": 4,
12136 });
12137 let keys = ["keptA", "keptA", "keptB", "missing"];
12138 let selection = StateSelection::Keys(keys.to_vec());
12139 let mut sink = TestWriter::new();
12140 let mut scratch = Vec::new();
12141 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12142 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12143 }
12144
12145 #[test]
12146 fn write_selected_state_map_first_matches_schema_first_output() {
12147 let state = test_json!({
12148 "keptA": 1,
12149 "keptB": 2,
12150 });
12151 let keys = ["keptA", "keptB", "missingA", "missingB"];
12152 let selection = StateSelection::Keys(keys.to_vec());
12153 let mut sink = TestWriter::new();
12154 let mut scratch = Vec::new();
12155 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12156 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12157 }
12158
12159 #[test]
12160 fn write_selected_state_full_preserves_and_escapes_state() {
12161 let state = test_json!({
12162 "serverOnly": "</script><b>",
12163 "value": 42,
12164 });
12165 let mut sink = TestWriter::new();
12166 let mut scratch = Vec::new();
12167 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12168 assert_eq!(
12169 sink.get_content(),
12170 r#"{"serverOnly":"<\/script><b>","value":42}"#
12171 );
12172 }
12173
12174 #[test]
12175 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12176 let preloads = vec![ComponentAssetStylePreload {
12177 root: "lazy-panel".to_string(),
12178 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12179 }];
12180
12181 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12182
12183 assert_eq!(
12184 manifest,
12185 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12186 );
12187 }
12188
12189 #[test]
12190 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12191 let mut sink = TestWriter::new();
12197 let mut scratch: Vec<u8> = Vec::new();
12198 assert_eq!(scratch.capacity(), 0);
12200
12201 let mut large = serde_json::Map::new();
12203 for index in 0..64 {
12204 large.insert(
12205 format!("component_{index:04}"),
12206 Value::String("x".repeat(48)),
12207 );
12208 }
12209 let large = Value::Object(large);
12210 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12211 let high_water = scratch.capacity();
12212 assert!(high_water > 0);
12213
12214 for seq in 0..8 {
12217 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12218 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12219 assert_eq!(
12220 scratch.capacity(),
12221 high_water,
12222 "scratch reallocated on checkpoint {seq}"
12223 );
12224 }
12225 }
12226
12227 #[test]
12228 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12229 let mut fragments = HashMap::new();
12230 fragments.insert(
12231 "index.html".to_string(),
12232 FragmentList {
12233 fragments: vec![
12234 WebUIFragment::raw("<html><body>"),
12235 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12236 path: "/".to_string(),
12237 fragment_id: "home-page".to_string(),
12238 exact: true,
12239 ..Default::default()
12240 }),
12241 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12242 path: "/admin".to_string(),
12243 fragment_id: "admin-page".to_string(),
12244 exact: true,
12245 ..Default::default()
12246 }),
12247 structural_fragment("body_end"),
12248 WebUIFragment::raw("</body></html>"),
12249 ],
12250
12251 contains_boundary: false,
12252 },
12253 );
12254 fragments.insert(
12255 "home-page".to_string(),
12256 FragmentList {
12257 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12258 contains_boundary: false,
12259 },
12260 );
12261 fragments.insert(
12262 "admin-page".to_string(),
12263 FragmentList {
12264 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12265 contains_boundary: false,
12266 },
12267 );
12268
12269 let mut protocol = WebUIProtocol::new(fragments);
12270 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12271 protocol.components.insert(
12272 "home-page".to_string(),
12273 webui_protocol::ComponentData {
12274 template_json: "{}".to_string(),
12275 hydration_mode: StateProjectionMode::Keys as i32,
12276 hydration_keys: vec!["homeTitle".to_string()],
12277 ..Default::default()
12278 },
12279 );
12280 protocol.components.insert(
12281 "admin-page".to_string(),
12282 webui_protocol::ComponentData {
12283 template_json: "{}".to_string(),
12284 hydration_mode: StateProjectionMode::Keys as i32,
12285 hydration_keys: vec!["adminToken".to_string()],
12286 ..Default::default()
12287 },
12288 );
12289 let state = test_json!({
12290 "homeTitle": "Welcome",
12291 "adminToken": "TOP_SECRET_SENTINEL",
12292 });
12293 let handler = WebUIHandler::with_plugin(|| {
12294 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12295 });
12296 let mut writer = TestWriter::new();
12297 handler.handle(
12298 &protocol,
12299 &state,
12300 &RenderOptions::new("index.html", "/"),
12301 &mut writer,
12302 )?;
12303 let output = writer.get_content();
12304 assert!(output.contains(r#""homeTitle":"Welcome""#));
12305 assert!(!output.contains("TOP_SECRET_SENTINEL"));
12306 assert!(!output.contains(r#""adminToken""#));
12307 Ok(())
12308 }
12309
12310 #[test]
12311 fn test_component_attr_name_aria() {
12312 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12314 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12315 assert_eq!(
12316 component_attr_name("aria-activedescendant"),
12317 "ariaActiveDescendant"
12318 );
12319 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12320 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12321 }
12322
12323 #[test]
12324 fn test_component_attr_name_html_global() {
12325 assert_eq!(component_attr_name("readonly"), "readOnly");
12326 assert_eq!(component_attr_name("tabindex"), "tabIndex");
12327 assert_eq!(component_attr_name("accesskey"), "accessKey");
12328 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12329 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12330 assert_eq!(component_attr_name("inputmode"), "inputMode");
12331 assert_eq!(component_attr_name("maxlength"), "maxLength");
12332 assert_eq!(component_attr_name("minlength"), "minLength");
12333 assert_eq!(component_attr_name("novalidate"), "noValidate");
12334 assert_eq!(component_attr_name("formaction"), "formAction");
12335 assert_eq!(component_attr_name("ismap"), "isMap");
12336 assert_eq!(component_attr_name("usemap"), "useMap");
12337 }
12338
12339 #[test]
12340 fn test_component_attr_name_strips_colon() {
12341 assert_eq!(component_attr_name(":readonly"), "readOnly");
12342 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12343 assert_eq!(component_attr_name(":data-title"), "dataTitle");
12344 }
12345
12346 #[test]
12347 fn test_component_attr_name_regular() {
12348 assert_eq!(component_attr_name("data-title"), "dataTitle");
12349 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12350 assert_eq!(component_attr_name("simple"), "simple");
12351 }
12352
12353 fn make_query_route_protocol() -> WebUIProtocol {
12356 use webui_protocol::WebUiFragmentRoute;
12357
12358 let mut fragments = HashMap::new();
12359
12360 fragments.insert(
12361 "index.html".to_string(),
12362 FragmentList {
12363 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12364 path: "/".into(),
12365 fragment_id: "app-shell".into(),
12366 exact: false,
12367 children: vec![
12368 WebUiFragmentRoute {
12369 path: "compose".into(),
12370 fragment_id: "compose-page".into(),
12371 exact: true,
12372 allowed_query: "action,to,subject".into(),
12373 keep_alive: false,
12374 ..Default::default()
12375 },
12376 WebUiFragmentRoute {
12377 path: "settings".into(),
12378 fragment_id: "settings-page".into(),
12379 exact: true,
12380 keep_alive: false,
12381 ..Default::default()
12382 },
12383 ],
12384 keep_alive: false,
12385 ..Default::default()
12386 })],
12387
12388 contains_boundary: false,
12389 },
12390 );
12391
12392 fragments.insert(
12393 "app-shell".to_string(),
12394 FragmentList {
12395 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12396 contains_boundary: false,
12397 },
12398 );
12399 fragments.insert(
12400 "compose-page".to_string(),
12401 FragmentList {
12402 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12403 contains_boundary: false,
12404 },
12405 );
12406 fragments.insert(
12407 "settings-page".to_string(),
12408 FragmentList {
12409 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12410 contains_boundary: false,
12411 },
12412 );
12413
12414 WebUIProtocol::new(fragments)
12415 }
12416
12417 #[test]
12418 fn test_matched_route_omits_query_attr_from_dom() {
12419 let protocol = make_query_route_protocol();
12420 let state = test_json!({});
12421 let handler = WebUIHandler::new();
12422 let mut writer = TestWriter::new();
12423
12424 handler
12425 .handle(
12426 &protocol,
12427 &state,
12428 &RenderOptions::new("index.html", "/compose"),
12429 &mut writer,
12430 )
12431 .expect("render failed");
12432
12433 let html = writer.get_content();
12434 assert!(
12436 !html.contains(r#"query="action,to,subject""#),
12437 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12438 );
12439 }
12440
12441 #[test]
12442 fn test_nonmatched_route_omits_query_attr_from_dom() {
12443 let protocol = make_query_route_protocol();
12444 let state = test_json!({});
12445 let handler = WebUIHandler::new();
12446 let mut writer = TestWriter::new();
12447
12448 handler
12449 .handle(
12450 &protocol,
12451 &state,
12452 &RenderOptions::new("index.html", "/settings"),
12453 &mut writer,
12454 )
12455 .expect("render failed");
12456
12457 let html = writer.get_content();
12458 assert!(
12460 !html.contains(r#"query="#),
12461 "hidden route should not have query attr: {html}"
12462 );
12463 }
12464
12465 #[test]
12466 fn test_route_without_query_has_no_query_attr() {
12467 let protocol = make_query_route_protocol();
12468 let state = test_json!({});
12469 let handler = WebUIHandler::new();
12470 let mut writer = TestWriter::new();
12471
12472 handler
12473 .handle(
12474 &protocol,
12475 &state,
12476 &RenderOptions::new("index.html", "/settings"),
12477 &mut writer,
12478 )
12479 .expect("render failed");
12480
12481 let html = writer.get_content();
12482 let settings_idx = html
12484 .find(r#"component="settings-page""#)
12485 .expect("settings route should exist");
12486 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12487 assert!(
12488 !settings_tag.contains("query="),
12489 "route without allowed_query should not emit query attr: {settings_tag}"
12490 );
12491 }
12492
12493 fn build_head_body_protocol() -> WebUIProtocol {
12498 let mut fragments = HashMap::new();
12499 fragments.insert(
12500 "index.html".to_string(),
12501 FragmentList {
12502 fragments: vec![
12503 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12504 structural_fragment("head_end"),
12505 WebUIFragment::raw("</head><body>hello".to_string()),
12506 structural_fragment("body_end"),
12507 WebUIFragment::raw("</body></html>".to_string()),
12508 ],
12509 contains_boundary: false,
12510 },
12511 );
12512 WebUIProtocol::new(fragments)
12513 }
12514
12515 #[test]
12516 fn head_inject_emits_at_head_end_boundary() {
12517 let protocol = build_head_body_protocol();
12518 let state = test_json!({});
12519 let mut writer = TestWriter::new();
12520 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12521 handle(&protocol, &state, &opts, &mut writer).unwrap();
12522 let html = writer.get_content();
12523 let inject_idx = html
12525 .find("<link rel=preload>")
12526 .expect("inject HTML missing");
12527 let head_close_idx = html.find("</head>").expect("</head> missing");
12528 assert!(
12529 inject_idx < head_close_idx,
12530 "head_inject must appear before </head>: {html}"
12531 );
12532 assert_eq!(html.matches("<link rel=preload>").count(), 1);
12534 }
12535
12536 #[test]
12537 fn component_render_policy_css_emits_once_in_head_with_nonce() {
12538 let mut protocol = build_head_body_protocol();
12539 protocol.component_render_css = concat!(
12540 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12541 "contain-intrinsic-block-size:auto 18rem;}"
12542 )
12543 .to_string();
12544 let state = test_json!({});
12545 let mut writer = TestWriter::new();
12546 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12547 handle(&protocol, &state, &opts, &mut writer).unwrap();
12548 let html = writer.get_content();
12549
12550 let style = concat!(
12551 r#"<style data-webui-render-policy nonce="test-nonce">"#,
12552 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12553 "contain-intrinsic-block-size:auto 18rem;}</style>"
12554 );
12555 let style_index = html.find(style).expect("render policy style missing");
12556 let head_close = html.find("</head>").expect("</head> missing");
12557 assert!(style_index < head_close);
12558 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12559 }
12560
12561 #[test]
12562 fn body_inject_emits_at_body_end_boundary() {
12563 let protocol = build_head_body_protocol();
12564 let state = test_json!({});
12565 let mut writer = TestWriter::new();
12566 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12567 handle(&protocol, &state, &opts, &mut writer).unwrap();
12568 let html = writer.get_content();
12569 let inject_idx = html
12570 .find("<script>lr</script>")
12571 .expect("inject HTML missing");
12572 let body_close_idx = html.find("</body>").expect("</body> missing");
12573 assert!(
12574 inject_idx < body_close_idx,
12575 "body_inject must appear before </body>: {html}"
12576 );
12577 assert_eq!(html.matches("<script>lr</script>").count(), 1);
12578 }
12579
12580 fn build_all_boundaries_protocol() -> WebUIProtocol {
12585 let mut fragments = HashMap::new();
12586 fragments.insert(
12587 "index.html".to_string(),
12588 FragmentList {
12589 fragments: vec![
12590 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12591 structural_fragment("head_end"),
12592 WebUIFragment::raw("</head><body>".to_string()),
12593 structural_fragment("body_start"),
12594 WebUIFragment::raw("hello".to_string()),
12595 structural_fragment("body_end"),
12596 WebUIFragment::raw("</body></html>".to_string()),
12597 ],
12598 contains_boundary: false,
12599 },
12600 );
12601 WebUIProtocol::new(fragments)
12602 }
12603
12604 fn state_inject_options<'a>() -> RenderOptions<'a> {
12605 RenderOptions::new("index.html", "/")
12606 }
12607
12608 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12609 let mut writer = TestWriter::new();
12610 handle(protocol, state, options, &mut writer).unwrap();
12611 writer.get_content().to_string()
12612 }
12613
12614 #[test]
12615 fn state_inject_emits_at_every_structural_boundary() {
12616 let protocol = build_all_boundaries_protocol();
12617 let state = test_json!({
12618 "$webui": {
12619 "headEnd": "<meta name=he>",
12620 "bodyStart": "<span id=bs></span>",
12621 "bodyEnd": "<script>be</script>",
12622 }
12623 });
12624 let html = render_with(&protocol, &state, &state_inject_options());
12625
12626 let head_end = html.find("<meta name=he>").expect("headEnd missing");
12627 let head_close = html.find("</head>").expect("</head> missing");
12628 let body_open = html.find("<body>").expect("<body> missing");
12629 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12630 let hello = html.find("hello").expect("body content missing");
12631 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12632 let body_close = html.find("</body>").expect("</body> missing");
12633
12634 assert!(
12635 head_end < head_close,
12636 "headEnd must precede </head>: {html}"
12637 );
12638 assert!(
12639 body_open < body_start && body_start < hello,
12640 "bodyStart must sit immediately after <body>: {html}"
12641 );
12642 assert!(
12643 hello < body_end && body_end < body_close,
12644 "bodyEnd must precede </body>: {html}"
12645 );
12646
12647 for needle in [
12648 "<meta name=he>",
12649 "<span id=bs></span>",
12650 "<script>be</script>",
12651 ] {
12652 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12653 }
12654 }
12655
12656 #[test]
12657 fn state_inject_follows_render_options_inject() {
12658 let protocol = build_all_boundaries_protocol();
12659 let state = test_json!({
12660 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12661 });
12662 let options = RenderOptions::new("index.html", "/")
12663 .with_head_inject("<!--opt-he-->")
12664 .with_body_inject("<!--opt-be-->");
12665 let html = render_with(&protocol, &state, &options);
12666
12667 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12668 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12669 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12670 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12671
12672 assert!(
12673 opt_he < state_he,
12674 "RenderOptions head_inject must precede the state-supplied value: {html}"
12675 );
12676 assert!(
12677 opt_be < state_be,
12678 "RenderOptions body_inject must precede the state-supplied value: {html}"
12679 );
12680 }
12681
12682 #[test]
12683 fn malformed_state_inject_values_are_inert() {
12684 let protocol = build_all_boundaries_protocol();
12685 for state in [
12688 test_json!({}),
12689 test_json!({ "$webui": "not-an-object" }),
12690 test_json!({ "$webui": [] }),
12691 test_json!({ "$webui": null }),
12692 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12693 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12694 ] {
12695 let html = render_with(&protocol, &state, &state_inject_options());
12696 assert!(
12697 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12698 "malformed reserved state must render the document unchanged: {html}"
12699 );
12700 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12701 assert!(!html.contains("42"), "non-string member leaked: {html}");
12702 }
12703 }
12704
12705 #[test]
12706 fn state_inject_never_reaches_the_hydration_payload() {
12707 let protocol = build_all_boundaries_protocol();
12708 let state = test_json!({
12709 "visible": "keep",
12710 "$webui": { "bodyEnd": "<script>secret</script>" }
12711 });
12712 let handler = WebUIHandler::with_plugin(|| {
12713 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12714 });
12715 let runtime = Protocol::new(protocol.clone());
12716 let mut writer = TestWriter::new();
12717 handler
12718 .render(&runtime, &state, &state_inject_options(), &mut writer)
12719 .unwrap();
12720 let html = writer.get_content();
12721
12722 let data_start = html
12723 .find(r#"<script type="application/json" id="webui-data""#)
12724 .expect("hydration block missing");
12725 let data_end = html[data_start..]
12726 .find("</script>")
12727 .map(|offset| data_start + offset)
12728 .expect("hydration block never closes");
12729 let payload = &html[data_start..data_end];
12730 assert!(
12731 !payload.contains("$webui"),
12732 "reserved key must be stripped from the hydration payload: {payload}"
12733 );
12734 assert!(
12735 payload.contains("visible"),
12736 "ordinary state must survive the filter: {payload}"
12737 );
12738 let inject = html
12741 .find("<script>secret</script>")
12742 .expect("inject missing");
12743 let body_close = html.find("</body>").expect("</body> missing");
12744 assert!(
12745 data_end < inject && inject < body_close,
12746 "state bodyEnd must follow the hydration block and precede </body>: {html}"
12747 );
12748 }
12749
12750 #[test]
12751 fn projected_hydration_strips_reserved_state_inject_key() {
12752 let mut fragments = HashMap::new();
12753 fragments.insert(
12754 "index.html".to_string(),
12755 FragmentList {
12756 fragments: vec![
12757 WebUIFragment::raw("<html><body>"),
12758 WebUIFragment::component("app-shell"),
12759 structural_fragment("body_end"),
12760 WebUIFragment::raw("</body></html>"),
12761 ],
12762 contains_boundary: false,
12763 },
12764 );
12765 fragments.insert(
12766 "app-shell".to_string(),
12767 FragmentList {
12768 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12769 contains_boundary: false,
12770 },
12771 );
12772 let mut document = WebUIProtocol::new(fragments);
12773 document.initial_state_strategy = InitialStateStrategy::Components as i32;
12774 document.components.insert(
12775 "app-shell".to_string(),
12776 webui_protocol::ComponentData {
12777 hydration_mode: StateProjectionMode::Keys as i32,
12778 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12779 ..Default::default()
12780 },
12781 );
12782 let state = test_json!({
12783 "$webui": { "bodyEnd": "<script>secret</script>" },
12784 "serverOnly": "drop",
12785 "visible": "keep",
12786 });
12787 let handler = WebUIHandler::with_plugin(|| {
12788 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12789 });
12790 let mut writer = TestWriter::new();
12791
12792 handler
12793 .render(
12794 &Protocol::new(document),
12795 &state,
12796 &RenderOptions::new("index.html", "/"),
12797 &mut writer,
12798 )
12799 .unwrap();
12800 let html = writer.get_content();
12801 let data_start = html
12802 .find(r#"<script type="application/json" id="webui-data""#)
12803 .expect("hydration block missing");
12804 let data_end = html[data_start..]
12805 .find("</script>")
12806 .map(|offset| data_start + offset)
12807 .expect("hydration block never closes");
12808 let payload = &html[data_start..data_end];
12809
12810 assert!(
12811 !payload.contains(STATE_INJECT_KEY),
12812 "reserved key leaked: {payload}"
12813 );
12814 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12815 assert!(!payload.contains("serverOnly"), "{payload}");
12816 assert!(html.contains("<script>secret</script>"), "{html}");
12817 }
12818
12819 #[test]
12820 fn write_selected_state_strips_reserved_key_from_full_state() {
12821 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12822 let mut sink = TestWriter::new();
12823 let mut scratch = Vec::new();
12824 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12825 let json = sink.get_content();
12826 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12827 assert!(
12828 json.contains("\"a\":1") && json.contains("\"z\":2"),
12829 "{json}"
12830 );
12831 }
12832
12833 #[test]
12834 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12835 let mut protocol = WebUIProtocol::new(HashMap::new());
12838 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12839 protocol.fragments.insert(
12840 "keep-card".to_string(),
12841 webui_protocol::FragmentList::default(),
12842 );
12843 protocol.components.insert(
12844 "keep-card".to_string(),
12845 webui_protocol::ComponentData {
12846 hydration_mode: StateProjectionMode::Keys as i32,
12847 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12848 ..Default::default()
12849 },
12850 );
12851 let protocol = Protocol::new(protocol);
12852 let index = protocol.component_reachability();
12853 let component = protocol.component_index()["keep-card"];
12854 let mut ids = Vec::new();
12855 assert!(!collect_hydration_key_ids_into(
12856 protocol.protocol(),
12857 index,
12858 [component],
12859 &mut ids
12860 ));
12861
12862 let state = test_json!({
12863 "$webui": { "bodyEnd": "<b>x</b>" },
12864 "keep": 1,
12865 });
12866 let mut sink = TestWriter::new();
12867 let mut scratch = Vec::new();
12868
12869 write_selected_state(
12870 &mut sink,
12871 &mut scratch,
12872 &state,
12873 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
12874 )
12875 .unwrap();
12876
12877 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
12878 }
12879
12880 #[test]
12881 fn write_selected_state_full_is_unchanged_without_reserved_key() {
12882 let state = test_json!({ "a": 1, "z": 2 });
12883 let mut sink = TestWriter::new();
12884 let mut scratch = Vec::new();
12885 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12886 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
12887 }
12888
12889 #[test]
12890 fn body_start_hook_dedupes_on_malformed_protocol() {
12891 let mut fragments = HashMap::new();
12892 fragments.insert(
12893 "index.html".to_string(),
12894 FragmentList {
12895 fragments: vec![
12896 WebUIFragment::raw("<html><head></head><body>".to_string()),
12897 structural_fragment("body_start"),
12898 structural_fragment("body_start"),
12899 WebUIFragment::raw("</body></html>".to_string()),
12900 ],
12901 contains_boundary: false,
12902 },
12903 );
12904 let protocol = WebUIProtocol::new(fragments);
12905 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
12906 let html = render_with(&protocol, &state, &state_inject_options());
12907 assert_eq!(
12908 html.matches("<i>once</i>").count(),
12909 1,
12910 "duplicate body_start signals must not duplicate the inject: {html}"
12911 );
12912 }
12913
12914 #[test]
12915 fn injects_are_no_op_when_unset() {
12916 let protocol = build_head_body_protocol();
12917 let state = test_json!({});
12918 let mut writer = TestWriter::new();
12919 handle(
12920 &protocol,
12921 &state,
12922 &RenderOptions::new("index.html", "/"),
12923 &mut writer,
12924 )
12925 .unwrap();
12926 let html = writer.get_content();
12927 assert!(!html.contains("<link rel=preload>"));
12928 assert!(!html.contains("<script>lr</script>"));
12929 }
12930
12931 #[test]
12932 fn empty_inject_string_treated_as_unset() {
12933 let protocol = build_head_body_protocol();
12934 let state = test_json!({});
12935 let mut writer = TestWriter::new();
12936 let opts = RenderOptions::new("index.html", "/")
12937 .with_head_inject("")
12938 .with_body_inject("");
12939 handle(&protocol, &state, &opts, &mut writer).unwrap();
12940 let html = writer.get_content();
12943 assert!(html.contains("</head>"));
12944 assert!(html.contains("</body>"));
12945 }
12946
12947 #[test]
12948 fn inject_html_is_passed_through_verbatim() {
12949 let protocol = build_head_body_protocol();
12953 let state = test_json!({});
12954 let mut writer = TestWriter::new();
12955 let opts =
12956 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
12957 handle(&protocol, &state, &opts, &mut writer).unwrap();
12958 assert!(writer.get_content().contains("<script>var x=1;</script>"));
12959 }
12960
12961 #[test]
12968 fn injects_robust_against_marker_literals_in_content() {
12969 let mut fragments = HashMap::new();
12970 fragments.insert(
12974 "index.html".to_string(),
12975 FragmentList {
12976 fragments: vec![
12977 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12978 structural_fragment("head_end"),
12979 WebUIFragment::raw(
12980 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
12981 ),
12982 structural_fragment("body_end"),
12983 WebUIFragment::raw("</body></html>".to_string()),
12984 ],
12985 contains_boundary: false,
12986 },
12987 );
12988 let protocol = WebUIProtocol::new(fragments);
12989 let state = test_json!({});
12990 let mut writer = TestWriter::new();
12991 let opts = RenderOptions::new("index.html", "/")
12992 .with_head_inject("<HEAD-INJ>")
12993 .with_body_inject("<BODY-INJ>");
12994 handle(&protocol, &state, &opts, &mut writer).unwrap();
12995 let html = writer.get_content();
12996 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
12999 let head_close_idx = html.find("</head>").expect("</head> missing");
13000 assert!(head_inj_idx < head_close_idx);
13001 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13005 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13007 assert!(
13008 body_inj_idx < body_close_idx,
13009 "body_inject must precede the structural </body>: {html}"
13010 );
13011 assert!(html.contains("<!-- </body> </head> -->"));
13013 }
13014
13015 #[test]
13019 fn both_injects_fire_at_correct_boundaries() {
13020 let protocol = build_head_body_protocol();
13021 let state = test_json!({});
13022 let mut writer = TestWriter::new();
13023 let opts = RenderOptions::new("index.html", "/")
13024 .with_head_inject("<META-HEAD>")
13025 .with_body_inject("<SCRIPT-BODY>");
13026 handle(&protocol, &state, &opts, &mut writer).unwrap();
13027 let html = writer.get_content();
13028 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13029 let head_close = html.find("</head>").expect("</head> missing");
13030 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13031 let body_close = html.find("</body>").expect("</body> missing");
13032 assert!(head_idx < head_close, "head_inject before </head>");
13033 assert!(head_close < body_idx, "body_inject after </head>");
13034 assert!(body_idx < body_close, "body_inject before </body>");
13035 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13036 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13037 }
13038
13039 #[test]
13045 fn injects_dedupe_against_duplicate_signals() {
13046 let mut fragments = HashMap::new();
13047 fragments.insert(
13048 "index.html".to_string(),
13049 FragmentList {
13050 fragments: vec![
13051 WebUIFragment::raw("<html><head>".to_string()),
13052 structural_fragment("head_end"),
13053 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13056 structural_fragment("body_end"),
13057 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13059 ],
13060 contains_boundary: false,
13061 },
13062 );
13063 let protocol = WebUIProtocol::new(fragments);
13064 let state = test_json!({});
13065 let mut writer = TestWriter::new();
13066 let opts = RenderOptions::new("index.html", "/")
13067 .with_head_inject("<HINJ>")
13068 .with_body_inject("<BINJ>");
13069 handle(&protocol, &state, &opts, &mut writer).unwrap();
13070 let html = writer.get_content();
13071 assert_eq!(
13072 html.matches("<HINJ>").count(),
13073 1,
13074 "head_inject must emit exactly once even with duplicate head_end signals"
13075 );
13076 assert_eq!(
13077 html.matches("<BINJ>").count(),
13078 1,
13079 "body_inject must emit exactly once even with duplicate body_end signals"
13080 );
13081 }
13082
13083 #[test]
13087 fn injects_no_op_when_no_head_or_body_signals() {
13088 let mut fragments = HashMap::new();
13089 fragments.insert(
13090 "index.html".to_string(),
13091 FragmentList {
13092 fragments: vec![WebUIFragment::raw(
13093 "<my-component>hi</my-component>".to_string(),
13094 )],
13095 contains_boundary: false,
13096 },
13097 );
13098 let protocol = WebUIProtocol::new(fragments);
13099 let state = test_json!({});
13100 let mut writer = TestWriter::new();
13101 let opts = RenderOptions::new("index.html", "/")
13102 .with_head_inject("<HINJ>")
13103 .with_body_inject("<BINJ>");
13104 handle(&protocol, &state, &opts, &mut writer).unwrap();
13105 let html = writer.get_content();
13106 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13107 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13108 assert!(html.contains("<my-component>"));
13109 }
13110
13111 #[test]
13117 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13118 let protocol = std::sync::Arc::new(build_head_body_protocol());
13119 let state = std::sync::Arc::new(test_json!({}));
13120 let handler = std::sync::Arc::new(WebUIHandler::new());
13121
13122 const N_THREADS: usize = 16;
13123 let mut handles = Vec::with_capacity(N_THREADS);
13124 for tid in 0..N_THREADS {
13125 let h = std::sync::Arc::clone(&handler);
13126 let p = std::sync::Arc::clone(&protocol);
13127 let s = std::sync::Arc::clone(&state);
13128 handles.push(std::thread::spawn(move || {
13129 let head = format!("<HEAD-T{tid}>");
13130 let body = format!("<BODY-T{tid}>");
13131 let mut writer = TestWriter::new();
13132 let opts = RenderOptions::new("index.html", "/")
13133 .with_head_inject(&head)
13134 .with_body_inject(&body);
13135 h.handle(&p, &s, &opts, &mut writer).unwrap();
13136 let html = writer.get_content();
13137 assert_eq!(html.matches(&head).count(), 1);
13139 assert_eq!(html.matches(&body).count(), 1);
13140 for other in 0..N_THREADS {
13142 if other == tid {
13143 continue;
13144 }
13145 let other_head = format!("<HEAD-T{other}>");
13146 let other_body = format!("<BODY-T{other}>");
13147 assert!(
13148 !html.contains(&other_head),
13149 "tid {tid} saw {other}'s head_inject"
13150 );
13151 assert!(
13152 !html.contains(&other_body),
13153 "tid {tid} saw {other}'s body_inject"
13154 );
13155 }
13156 }));
13157 }
13158 for h in handles {
13159 h.join().expect("worker panicked");
13160 }
13161 }
13162
13163 #[test]
13168 fn large_inject_roundtrips_without_truncation() {
13169 let protocol = build_head_body_protocol();
13170 let state = test_json!({});
13171 let mut writer = TestWriter::new();
13172 let big = "x".repeat(1024 * 1024);
13173 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13174 handle(&protocol, &state, &opts, &mut writer).unwrap();
13175 let html = writer.get_content();
13176 assert!(
13177 html.contains(&big),
13178 "large head_inject must be present verbatim ({} bytes)",
13179 big.len()
13180 );
13181 assert_eq!(html.matches(&big).count(), 1);
13183 }
13184
13185 #[test]
13190 fn empty_nonce_treated_as_unset() {
13191 let protocol = build_head_body_protocol();
13192 let state = test_json!({});
13193 let mut writer = TestWriter::new();
13194 let opts = RenderOptions::new("index.html", "/").with_nonce("");
13195 handle(&protocol, &state, &opts, &mut writer).unwrap();
13196 assert!(
13197 !writer.get_content().contains("webui-nonce"),
13198 "empty nonce must not emit <meta name=\"webui-nonce\">"
13199 );
13200 }
13201
13202 #[test]
13220 fn empty_field_bypass_is_normalised_at_handler_init() {
13221 let protocol = build_head_body_protocol();
13222 let state = test_json!({});
13223
13224 let opts_with_empty_nonce = RenderOptions {
13226 nonce: Some(""),
13227 ..RenderOptions::new("index.html", "/")
13228 };
13229 let mut writer = TestWriter::new();
13230 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13231 let html = writer.get_content();
13232 assert!(
13233 !html.contains("webui-nonce"),
13234 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13235 );
13236 assert!(
13237 !html.contains("nonce=\"\""),
13238 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13239 );
13240
13241 let opts_with_empty_injects = RenderOptions {
13243 head_inject: Some(""),
13244 body_inject: Some(""),
13245 ..RenderOptions::new("index.html", "/")
13246 };
13247 let mut writer = TestWriter::new();
13248 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13249 }
13253
13254 #[test]
13287 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13288 let mut fragments = HashMap::new();
13289 fragments.insert(
13290 "index.html".to_string(),
13291 FragmentList {
13292 fragments: vec![
13293 WebUIFragment::raw("["),
13294 WebUIFragment::for_loop("item", "outer", "outer_body"),
13295 WebUIFragment::raw("]"),
13296 ],
13297 contains_boundary: false,
13298 },
13299 );
13300 fragments.insert(
13301 "outer_body".to_string(),
13302 FragmentList {
13303 fragments: vec![
13304 WebUIFragment::raw("(O="),
13305 WebUIFragment::signal("item.tag", false),
13306 WebUIFragment::for_loop("item", "inner", "inner_body"),
13307 WebUIFragment::raw(",O="),
13308 WebUIFragment::signal("item.tag", false),
13309 WebUIFragment::raw(")"),
13310 ],
13311 contains_boundary: false,
13312 },
13313 );
13314 fragments.insert(
13315 "inner_body".to_string(),
13316 FragmentList {
13317 fragments: vec![
13318 WebUIFragment::raw("[I="),
13319 WebUIFragment::signal("item.tag", false),
13320 WebUIFragment::raw("]"),
13321 ],
13322 contains_boundary: false,
13323 },
13324 );
13325 let protocol = WebUIProtocol::new(fragments);
13326 let state = test_json!({
13327 "outer": [{"tag": "A"}, {"tag": "B"}],
13328 "inner": [{"tag": "X"}, {"tag": "Y"}],
13329 });
13330 let mut writer = TestWriter::new();
13331 handle(
13332 &protocol,
13333 &state,
13334 &RenderOptions::new("index.html", "/"),
13335 &mut writer,
13336 )
13337 .unwrap();
13338 assert_eq!(
13350 writer.get_content(),
13351 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13352 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13353 );
13354 }
13355 #[derive(Default)]
13356 struct FlushTestWriter {
13357 output: String,
13358 flushes: Vec<usize>,
13359 fail_flush: bool,
13360 fail_flush_at: Option<usize>,
13361 flush_attempts: usize,
13362 ended: bool,
13363 }
13364
13365 impl ResponseWriter for FlushTestWriter {
13366 fn write(&mut self, content: &str) -> Result<()> {
13367 self.output.push_str(content);
13368 Ok(())
13369 }
13370
13371 fn end(&mut self) -> Result<()> {
13372 self.ended = true;
13373 Ok(())
13374 }
13375 }
13376
13377 impl FlushWriter for FlushTestWriter {
13378 fn flush(&mut self) -> Result<()> {
13379 let attempt = self.flush_attempts;
13380 self.flush_attempts += 1;
13381 if self.fail_flush || self.fail_flush_at == Some(attempt) {
13382 return Err(HandlerError::ClientDisconnected);
13383 }
13384 self.flushes.push(self.output.len());
13385 Ok(())
13386 }
13387 }
13388
13389 fn streaming_protocol(with_boundaries: bool) -> Protocol {
13390 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13391 }
13392
13393 fn streaming_protocol_with_state_strategy(
13394 with_boundaries: bool,
13395 state_strategy: InitialStateStrategy,
13396 ) -> Protocol {
13397 let mut fragments = HashMap::new();
13398 let mut entry = vec![
13399 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13400 structural_fragment("head_start"),
13401 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13402 structural_fragment("head_end"),
13403 WebUIFragment::raw("</HEAD><body>"),
13404 structural_fragment("body_start"),
13405 ];
13406 if with_boundaries {
13407 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13408 }
13409 entry.extend([
13410 WebUIFragment::raw("<my-counter"),
13411 structural_fragment("streaming_root:my-counter"),
13412 WebUIFragment::raw(">"),
13413 WebUIFragment::component("my-counter"),
13414 WebUIFragment::raw("</my-counter>"),
13415 ]);
13416 if with_boundaries {
13417 entry.push(WebUIFragment::boundary_end(0));
13418 }
13419 entry.extend([
13420 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13421 structural_fragment("body_end"),
13422 WebUIFragment::raw("</body></html>"),
13423 ]);
13424 fragments.insert(
13425 "index.html".to_string(),
13426 FragmentList {
13427 fragments: entry,
13428 contains_boundary: with_boundaries,
13429 },
13430 );
13431 fragments.insert(
13432 "my-counter".to_string(),
13433 FragmentList {
13434 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13435 contains_boundary: false,
13436 },
13437 );
13438
13439 let mut document = WebUIProtocol::new(fragments);
13440 document.initial_state_strategy = state_strategy as i32;
13441 document.components.insert(
13442 "my-counter".to_string(),
13443 webui_protocol::ComponentData {
13444 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13445 template_functions: "[function(){return true}]".to_string(),
13446 hydration_mode: StateProjectionMode::Keys as i32,
13447 hydration_keys: vec!["count".to_string()],
13448 ..Default::default()
13449 },
13450 );
13451 Protocol::new(document)
13452 }
13453
13454 #[test]
13455 fn handler_error_stays_small() {
13456 assert!(
13463 std::mem::size_of::<HandlerError>()
13464 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13465 "HandlerError grew to {} bytes",
13466 std::mem::size_of::<HandlerError>()
13467 );
13468 }
13469
13470 #[test]
13471 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13472 let protocol = streaming_protocol(true);
13473 let handler = WebUIHandler::with_plugin(|| {
13474 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13475 });
13476 let state = test_json!({ "count": 1, "serverOnly": "secret" });
13477 let mut writer = FlushTestWriter::default();
13478
13479 handler
13480 .render_streaming(
13481 &protocol,
13482 &state,
13483 &RenderOptions::new("index.html", "/"),
13484 &mut writer,
13485 )
13486 .unwrap();
13487
13488 assert!(writer.ended);
13489 assert_eq!(
13490 writer.flushes.len(),
13491 3,
13492 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13493 );
13494 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13495 assert!(boundary_flush.contains("<!--wb:0-->"));
13496 assert!(boundary_flush.contains("<!--/wb:0-->"));
13497 assert!(
13498 boundary_flush.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
13499 "boundary flush: {boundary_flush}"
13500 );
13501 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13502 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13503 assert!(!boundary_flush.contains("slow tail"));
13504 assert!(!writer.output.contains("id=\"webui-data\""));
13505 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13508 assert!(terminal_flush.contains("slow tail"));
13509 assert!(writer.output.contains("[2,1,4,0,{}]"));
13510 assert!(!writer.output.contains("[2,1,0,1,"));
13511 assert!(!writer.output.contains("[2,2,4,0,{}]"));
13512
13513 let marker = writer
13514 .output
13515 .find(STREAMING_MARKER)
13516 .expect("streaming marker");
13517 let attributed_head = writer
13518 .output
13519 .find("<HEAD data-shell=\"main\">")
13520 .expect("attributed mixed-case head");
13521 let authored_script = writer
13522 .output
13523 .find("src=\"/index.js\"")
13524 .expect("entry script");
13525 assert!(attributed_head < marker && marker < authored_script);
13526 }
13527
13528 #[test]
13529 fn streaming_terminal_tail_never_resends_full_state() {
13530 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13531 let handler = WebUIHandler::with_plugin(|| {
13532 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13533 });
13534 let mut writer = FlushTestWriter::default();
13535
13536 handler
13537 .render_streaming(
13538 &protocol,
13539 &test_json!({ "count": 1, "serverOnly": "secret" }),
13540 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13541 &mut writer,
13542 )
13543 .unwrap();
13544
13545 assert_eq!(
13546 writer.output.matches("serverOnly").count(),
13547 1,
13548 "full state belongs only to the interactive boundary"
13549 );
13550 assert!(writer.output.contains("[2,1,4,0,{}]"));
13551 }
13552
13553 #[test]
13557 fn state_inject_placement_matches_between_render_modes() {
13558 let entry = vec![
13559 WebUIFragment::raw("<html><head>"),
13560 structural_fragment("head_start"),
13561 structural_fragment("head_end"),
13562 WebUIFragment::raw("</head><body>"),
13563 structural_fragment("body_start"),
13564 WebUIFragment::raw("<main>static</main>"),
13565 structural_fragment("body_end"),
13566 WebUIFragment::raw("</body></html>"),
13567 ];
13568 let fragments = HashMap::from([(
13569 "index.html".to_string(),
13570 FragmentList {
13571 fragments: entry,
13572 contains_boundary: false,
13573 },
13574 )]);
13575 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13576 let state = test_json!({
13577 "$webui": {
13578 "headEnd": "<meta name=he>",
13579 "bodyStart": "<span id=bs></span>",
13580 "bodyEnd": "<script>be</script>",
13581 }
13582 });
13583 let options = RenderOptions::new("index.html", "/");
13584
13585 let mut ordinary = TestWriter::new();
13586 WebUIHandler::new()
13587 .render(&protocol, &state, &options, &mut ordinary)
13588 .unwrap();
13589 let ordinary_html = ordinary.get_content().to_string();
13590
13591 let mut streamed = FlushTestWriter::default();
13592 WebUIHandler::new()
13593 .render_streaming(&protocol, &state, &options, &mut streamed)
13594 .unwrap();
13595 let streamed_html = &streamed.output;
13596
13597 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13598 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13599 let head_close = html.find("</head>").expect("</head> missing");
13600 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13601 let main = html.find("<main>static</main>").expect("content missing");
13602 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13603 let body_close = html.find("</body>").expect("</body> missing");
13604 assert!(head_end < head_close, "headEnd misplaced: {html}");
13605 assert!(body_start < main, "bodyStart misplaced: {html}");
13606 assert!(
13607 main < body_end && body_end < body_close,
13608 "bodyEnd misplaced: {html}"
13609 );
13610 }
13611
13612 assert!(
13615 streamed_html.contains(",4,0,{}]"),
13616 "streaming must still end in one empty terminal record: {streamed_html}"
13617 );
13618 }
13619
13620 #[test]
13621 fn streaming_state_inject_emits_and_strips_reserved_key() {
13622 let entry = vec![
13623 WebUIFragment::raw("<html><head>"),
13624 structural_fragment("head_start"),
13625 structural_fragment("head_end"),
13626 WebUIFragment::raw("</head><body>"),
13627 structural_fragment("body_start"),
13628 structural_fragment("body_end"),
13629 WebUIFragment::raw("</body></html>"),
13630 ];
13631 let fragments = HashMap::from([(
13632 "index.html".to_string(),
13633 FragmentList {
13634 fragments: entry,
13635 contains_boundary: false,
13636 },
13637 )]);
13638 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13639 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13640
13641 let mut writer = FlushTestWriter::default();
13642 WebUIHandler::new()
13643 .render_streaming(
13644 &protocol,
13645 &state,
13646 &RenderOptions::new("index.html", "/"),
13647 &mut writer,
13648 )
13649 .unwrap();
13650 assert!(writer.output.contains("<script>be</script>"));
13651 assert!(!writer.output.contains("$webui"));
13652 }
13653
13654 #[test]
13655 fn static_streaming_document_uses_one_empty_terminal_record() {
13656 let fragments = HashMap::from([(
13657 "index.html".to_string(),
13658 FragmentList {
13659 fragments: vec![
13660 WebUIFragment::raw("<html><head>"),
13661 structural_fragment("head_start"),
13662 structural_fragment("head_end"),
13663 WebUIFragment::raw("</head><body>"),
13664 structural_fragment("body_start"),
13665 WebUIFragment::raw("<main>static</main>"),
13666 structural_fragment("body_end"),
13667 WebUIFragment::raw("</body></html>"),
13668 ],
13669 contains_boundary: false,
13670 },
13671 )]);
13672 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13673 let mut writer = FlushTestWriter::default();
13674
13675 WebUIHandler::new()
13676 .render_streaming(
13677 &protocol,
13678 &test_json!({ "serverOnly": "secret" }),
13679 &RenderOptions::new("index.html", "/"),
13680 &mut writer,
13681 )
13682 .unwrap();
13683
13684 assert_eq!(writer.flushes.len(), 1);
13685 assert!(writer.output.contains(STREAMING_MARKER));
13686 assert!(writer.output.contains("[2,0,4,0,{}]"));
13687 assert!(!writer.output.contains("serverOnly"));
13688 assert!(!writer.output.contains("id=\"webui-data\""));
13689 assert!(!writer.output.contains("<!--wb:"));
13690 }
13691
13692 #[test]
13693 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13694 let protocol = streaming_protocol(true);
13695 let handler = WebUIHandler::with_plugin(|| {
13696 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13697 });
13698 let mut writer = FlushTestWriter::default();
13699 handler
13700 .render_streaming(
13701 &protocol,
13702 &test_json!({ "count": 1 }),
13703 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13704 &mut writer,
13705 )
13706 .unwrap();
13707
13708 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13709 let envelope = writer.output[boundary_end..]
13710 .find("data-webui-boundary nonce=\"test-nonce-123\"")
13711 .map(|index| index + boundary_end)
13712 .expect("nonce-bearing envelope");
13713 let functions = writer
13714 .output
13715 .find("templateFns")
13716 .expect("function side channel");
13717 let sentinel = writer
13718 .output
13719 .find("<webui-hydrate>")
13720 .expect("hydration sentinel");
13721 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13722 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13723 }
13724
13725 #[test]
13726 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13727 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13728 let mut fragments = vec![structural_fragment("head_start")];
13729 fragments.extend(boundaries);
13730 let fragments = HashMap::from([(
13731 "index.html".to_string(),
13732 FragmentList {
13733 fragments,
13734 contains_boundary: true,
13735 },
13736 )]);
13737 Protocol::new(WebUIProtocol::new(fragments))
13738 }
13739
13740 let handler = WebUIHandler::new();
13741 let mut writer = FlushTestWriter::default();
13742 let result = handler.render_streaming(
13743 &protocol(vec![WebUIFragment::boundary_end(0)]),
13744 &test_json!({}),
13745 &RenderOptions::new("index.html", "/"),
13746 &mut writer,
13747 );
13748 match result {
13749 Err(HandlerError::StreamingBoundary(err)) => {
13750 assert_eq!(err.signal, "boundary");
13751 assert!(err.reason.contains("no active occurrence"));
13752 }
13753 other => panic!("expected ordered-boundary error, got {other:?}"),
13754 }
13755
13756 let mut writer = FlushTestWriter::default();
13757 let result = handler.render_streaming(
13758 &protocol(vec![
13759 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13760 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13761 ]),
13762 &test_json!({}),
13763 &RenderOptions::new("index.html", "/"),
13764 &mut writer,
13765 );
13766 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13767 }
13768
13769 #[test]
13770 fn streaming_render_propagates_boundary_flush_disconnect() {
13771 let protocol = streaming_protocol(true);
13772 let handler = WebUIHandler::new();
13773 let mut writer = FlushTestWriter {
13774 fail_flush: true,
13775 ..FlushTestWriter::default()
13776 };
13777 let result = handler.render_streaming(
13778 &protocol,
13779 &test_json!({}),
13780 &RenderOptions::new("index.html", "/"),
13781 &mut writer,
13782 );
13783 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13784 assert!(!writer.ended);
13785 }
13786
13787 #[test]
13788 fn streaming_render_requires_structural_head_start_before_writing() {
13789 let fragments = HashMap::from([(
13790 "index.html".to_string(),
13791 FragmentList {
13792 fragments: vec![
13793 WebUIFragment::raw(
13794 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13795 ),
13796 structural_fragment("head_end"),
13797 WebUIFragment::raw("</HEAD><body>"),
13798 structural_fragment("body_start"),
13799 structural_fragment("body_end"),
13800 ],
13801 contains_boundary: false,
13802 },
13803 )]);
13804 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13805 let mut writer = FlushTestWriter::default();
13806
13807 let result = WebUIHandler::new().render_streaming(
13808 &protocol,
13809 &test_json!({}),
13810 &RenderOptions::new("index.html", "/"),
13811 &mut writer,
13812 );
13813
13814 assert!(matches!(
13815 result,
13816 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13817 ));
13818 assert_eq!(
13819 writer.output,
13820 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13821 );
13822 assert!(writer.flushes.is_empty());
13823 assert!(!writer.ended);
13824 }
13825
13826 #[test]
13827 fn streaming_render_rejects_duplicate_head_start() {
13828 let fragments = HashMap::from([(
13829 "index.html".to_string(),
13830 FragmentList {
13831 fragments: vec![
13832 WebUIFragment::raw("<html><head>"),
13833 structural_fragment("head_start"),
13834 structural_fragment("head_start"),
13835 structural_fragment("head_end"),
13836 WebUIFragment::raw("</head><body>"),
13837 structural_fragment("body_start"),
13838 structural_fragment("body_end"),
13839 ],
13840 contains_boundary: false,
13841 },
13842 )]);
13843 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13844 let mut writer = FlushTestWriter::default();
13845
13846 let result = WebUIHandler::new().render_streaming(
13847 &protocol,
13848 &test_json!({}),
13849 &RenderOptions::new("index.html", "/"),
13850 &mut writer,
13851 );
13852
13853 assert!(matches!(
13854 result,
13855 Err(HandlerError::DuplicateStreamingHeadStart)
13856 ));
13857 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
13858 assert!(writer.flushes.is_empty());
13859 }
13860
13861 #[test]
13862 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
13863 let handler = WebUIHandler::with_plugin(|| {
13864 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13865 });
13866 let state = test_json!({
13867 "count": 1,
13868 "head_start": "must not render",
13869 "boundary_start:0": "must not render",
13870 "boundary_end:0": "must not render",
13871 });
13872 let mut with_boundaries = TestWriter::new();
13873 let mut without_boundaries = TestWriter::new();
13874 handler
13875 .render(
13876 &streaming_protocol(true),
13877 &state,
13878 &RenderOptions::new("index.html", "/"),
13879 &mut with_boundaries,
13880 )
13881 .unwrap();
13882 handler
13883 .render(
13884 &streaming_protocol(false),
13885 &state,
13886 &RenderOptions::new("index.html", "/"),
13887 &mut without_boundaries,
13888 )
13889 .unwrap();
13890 assert_eq!(
13891 with_boundaries.get_content(),
13892 without_boundaries.get_content()
13893 );
13894 }
13895
13896 #[test]
13897 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
13898 for source in [
13899 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
13900 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
13901 ] {
13902 let mut parser = HtmlParser::new();
13903 parser
13904 .parse("index.html", source)
13905 .expect("parse head fixture");
13906 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13907 let mut writer = TestWriter::new();
13908 WebUIHandler::new()
13909 .render(
13910 &protocol,
13911 &test_json!({ "theme": "light" }),
13912 &RenderOptions::new("index.html", "/"),
13913 &mut writer,
13914 )
13915 .unwrap();
13916
13917 assert_eq!(
13918 writer.get_content(),
13919 "<html><head><title>T</title></head><body>x</body></html>"
13920 );
13921 }
13922 }
13923
13924 #[test]
13925 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
13926 let source =
13927 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
13928 let mut parser = HtmlParser::new();
13929 parser.parse("index.html", source).expect("parse fixture");
13930 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13931
13932 let mut ordinary = TestWriter::new();
13933 WebUIHandler::new()
13934 .render(
13935 &protocol,
13936 &test_json!({}),
13937 &RenderOptions::new("index.html", "/"),
13938 &mut ordinary,
13939 )
13940 .unwrap();
13941 assert_eq!(ordinary.get_content(), source);
13942
13943 let mut streaming = FlushTestWriter::default();
13944 WebUIHandler::new()
13945 .render_streaming(
13946 &protocol,
13947 &test_json!({}),
13948 &RenderOptions::new("index.html", "/"),
13949 &mut streaming,
13950 )
13951 .unwrap();
13952 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
13953 assert!(streaming.output.contains("</HEAD><BODY>x"));
13954 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
13955 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
13956 let title = streaming.output.find("<title>").expect("title");
13957 assert!(opening < marker && marker < title);
13958 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
13959 }
13960
13961 #[test]
13964 fn unnamespaced_signal_is_ordinary_content() {
13965 let fragments = HashMap::from([(
13966 "index.html".to_string(),
13967 FragmentList {
13968 fragments: vec![
13969 WebUIFragment::raw("<html><head>"),
13970 structural_fragment("head_end"),
13971 WebUIFragment::raw("</head><body>"),
13972 WebUIFragment::signal("body_end".to_string(), false),
13973 structural_fragment("body_end"),
13974 WebUIFragment::raw("</body></html>"),
13975 ],
13976 contains_boundary: false,
13977 },
13978 )]);
13979 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13980 let mut writer = TestWriter::new();
13981 WebUIHandler::new()
13982 .render(
13983 &protocol,
13984 &test_json!({ "body_end": "content" }),
13985 &RenderOptions::new("index.html", "/"),
13986 &mut writer,
13987 )
13988 .expect("current protocol must render");
13989 assert!(writer.get_content().contains("content"));
13990 }
13991
13992 #[test]
13993 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
13994 let source = concat!(
13995 "<html><head></head><body>",
13996 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
13997 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
13998 "</body></html>",
13999 );
14000 let state = test_json!({
14001 "head_start": "hs",
14002 "head_end": "he",
14003 "body_start": "bs",
14004 "body_end": "be",
14005 "boundary_start:0": "b0s",
14006 "boundary_end:0": "b0e",
14007 "streaming_root:forged": "root",
14008 });
14009 let mut parser = HtmlParser::new();
14010 parser.parse("index.html", source).expect("parse fixture");
14011 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14012 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14013
14014 let mut ordinary = TestWriter::new();
14015 WebUIHandler::new()
14016 .render(
14017 &protocol,
14018 &state,
14019 &RenderOptions::new("index.html", "/"),
14020 &mut ordinary,
14021 )
14022 .unwrap();
14023 assert_eq!(ordinary.get_content(), expected);
14024
14025 let mut streaming = FlushTestWriter::default();
14026 WebUIHandler::new()
14027 .render_streaming(
14028 &protocol,
14029 &state,
14030 &RenderOptions::new("index.html", "/"),
14031 &mut streaming,
14032 )
14033 .unwrap();
14034 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14035 assert!(!streaming.output.contains("<!--wb:"));
14036 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14037 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14038 }
14039
14040 #[test]
14041 fn streaming_rejects_structural_signals_after_terminal() {
14042 let fragments = HashMap::from([(
14043 "index.html".to_string(),
14044 FragmentList {
14045 fragments: vec![
14046 WebUIFragment::raw("<html><head>"),
14047 structural_fragment("head_start"),
14048 structural_fragment("head_end"),
14049 WebUIFragment::raw("</head><body>"),
14050 structural_fragment("body_start"),
14051 structural_fragment("body_end"),
14052 WebUIFragment::raw("</body>"),
14053 structural_fragment("boundary_start:0"),
14054 WebUIFragment::raw("</html>"),
14055 ],
14056 contains_boundary: false,
14057 },
14058 )]);
14059 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14060 let mut streaming = FlushTestWriter::default();
14061 let result = WebUIHandler::new().render_streaming(
14062 &protocol,
14063 &test_json!({}),
14064 &RenderOptions::new("index.html", "/"),
14065 &mut streaming,
14066 );
14067
14068 match result {
14069 Err(HandlerError::StreamingBoundary(error)) => {
14070 assert_eq!(error.signal, "boundary_start:0");
14071 assert!(
14072 error
14073 .reason
14074 .contains("structural signal arrived after body_end"),
14075 "reason: {}",
14076 error.reason
14077 );
14078 }
14079 other => panic!("expected post-terminal rejection, got {other:?}"),
14080 }
14081 assert_eq!(
14082 streaming.output,
14083 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14084 );
14085 assert!(!streaming.output.contains("<!--wb:0-->"));
14086
14087 let mut ordinary = TestWriter::new();
14088 WebUIHandler::new()
14089 .render(
14090 &protocol,
14091 &test_json!({}),
14092 &RenderOptions::new("index.html", "/"),
14093 &mut ordinary,
14094 )
14095 .unwrap();
14096 assert_eq!(
14097 ordinary.get_content(),
14098 "<html><head></head><body></body></html>"
14099 );
14100 }
14101
14102 fn disjoint_streaming_protocol_ext(
14103 hosts: &[&str],
14104 emit_root_signal: bool,
14105 include_styles: bool,
14106 ) -> Protocol {
14107 let mut fragments = HashMap::new();
14108 let mut entry = vec![
14109 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14110 structural_fragment("head_start"),
14111 structural_fragment("head_end"),
14112 WebUIFragment::raw("</head><body>"),
14113 structural_fragment("body_start"),
14114 ];
14115 for (sequence, host) in hosts.iter().enumerate() {
14116 entry.push(WebUIFragment::boundary(
14117 sequence as u32,
14118 "index.html",
14119 format!("boundary-{sequence}"),
14120 None,
14121 ));
14122 entry.push(WebUIFragment::raw(format!("<{host}")));
14123 if emit_root_signal {
14124 entry.push(structural_fragment(format!("streaming_root:{host}")));
14125 }
14126 entry.push(WebUIFragment::raw(">"));
14127 entry.push(WebUIFragment::component(*host));
14128 entry.push(WebUIFragment::raw(format!("</{host}>")));
14129 entry.push(WebUIFragment::boundary_end(sequence as u32));
14130 }
14131 entry.push(structural_fragment("body_end"));
14132 entry.push(WebUIFragment::raw("</body></html>"));
14133 fragments.insert(
14134 "index.html".to_string(),
14135 FragmentList {
14136 fragments: entry,
14137 contains_boundary: true,
14138 },
14139 );
14140 fragments.insert(
14141 "comp-a".to_string(),
14142 FragmentList {
14143 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14144 contains_boundary: false,
14145 },
14146 );
14147 fragments.insert(
14148 "comp-b".to_string(),
14149 FragmentList {
14150 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14151 contains_boundary: false,
14152 },
14153 );
14154
14155 let mut document = WebUIProtocol::new(fragments);
14156 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14157 document.components.insert(
14158 "comp-a".to_string(),
14159 webui_protocol::ComponentData {
14160 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14161 hydration_mode: StateProjectionMode::Keys as i32,
14162 hydration_keys: vec!["a_count".to_string()],
14163 ..Default::default()
14164 },
14165 );
14166 document.components.insert(
14167 "comp-b".to_string(),
14168 webui_protocol::ComponentData {
14169 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14170 hydration_mode: StateProjectionMode::Keys as i32,
14171 hydration_keys: vec!["b_count".to_string()],
14172 ..Default::default()
14173 },
14174 );
14175 if include_styles {
14176 document.set_css_strategy(webui_protocol::CssStrategy::Style);
14177 document
14178 .components
14179 .get_mut("comp-a")
14180 .expect("comp-a component")
14181 .css = ".comp-a{color:red}".to_string();
14182 document
14183 .components
14184 .get_mut("comp-b")
14185 .expect("comp-b component")
14186 .css = ".comp-b{color:blue}".to_string();
14187 document.populate_style_closures(&["index.html"]);
14188 }
14189 Protocol::new(document)
14190 }
14191
14192 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14193 disjoint_streaming_protocol_ext(hosts, true, false)
14194 }
14195
14196 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14197 disjoint_streaming_protocol_ext(hosts, true, true)
14198 }
14199
14200 fn streaming_root_validation_protocol(
14201 mut host_fragments: Vec<WebUIFragment>,
14202 tail: Vec<WebUIFragment>,
14203 ) -> Protocol {
14204 let mut entry = vec![
14205 WebUIFragment::raw("<html><head>"),
14206 structural_fragment("head_start"),
14207 structural_fragment("head_end"),
14208 WebUIFragment::raw("</head><body>"),
14209 structural_fragment("body_start"),
14210 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14211 ];
14212 entry.append(&mut host_fragments);
14213 entry.extend(tail);
14214 let fragments = HashMap::from([
14215 (
14216 "index.html".to_string(),
14217 FragmentList {
14218 fragments: entry,
14219 contains_boundary: true,
14220 },
14221 ),
14222 (
14223 "comp-a".to_string(),
14224 FragmentList {
14225 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14226 contains_boundary: false,
14227 },
14228 ),
14229 (
14230 "comp-b".to_string(),
14231 FragmentList {
14232 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14233 contains_boundary: false,
14234 },
14235 ),
14236 ]);
14237 Protocol::new(WebUIProtocol::new(fragments))
14238 }
14239
14240 fn completed_streaming_tail() -> Vec<WebUIFragment> {
14241 vec![
14242 WebUIFragment::raw("</comp-a>"),
14243 WebUIFragment::boundary_end(0),
14244 structural_fragment("body_end"),
14245 WebUIFragment::raw("</body></html>"),
14246 ]
14247 }
14248
14249 fn assert_streaming_root_error(
14250 protocol: &Protocol,
14251 expected_signal: &str,
14252 expected_reason: &str,
14253 ) {
14254 let mut writer = FlushTestWriter::default();
14255 let result = WebUIHandler::new().render_streaming(
14256 protocol,
14257 &test_json!({}),
14258 &RenderOptions::new("index.html", "/"),
14259 &mut writer,
14260 );
14261 match result {
14262 Err(HandlerError::StreamingBoundary(error)) => {
14263 assert_eq!(error.signal, expected_signal);
14264 assert!(
14265 error.reason.contains(expected_reason),
14266 "reason: {}",
14267 error.reason
14268 );
14269 }
14270 other => panic!("expected streaming-root rejection, got {other:?}"),
14271 }
14272 }
14273
14274 #[test]
14275 fn streaming_root_signal_injects_data_ws_inside_boundary() {
14276 let protocol = disjoint_streaming_protocol(&["comp-a"]);
14277 let handler = WebUIHandler::with_plugin(|| {
14278 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14279 });
14280 let mut writer = FlushTestWriter::default();
14281 handler
14282 .render_streaming(
14283 &protocol,
14284 &test_json!({ "a_count": 1 }),
14285 &RenderOptions::new("index.html", "/"),
14286 &mut writer,
14287 )
14288 .unwrap();
14289 assert!(
14292 writer.output.contains("<comp-a data-ws>"),
14293 "streamed host must carry data-ws: {}",
14294 writer.output
14295 );
14296 }
14297
14298 #[test]
14299 fn streaming_root_signal_preserves_ordinary_output_bytes() {
14300 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14303 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14304 let state = test_json!({ "a_count": 1, "b_count": 2 });
14305 let plugin = || {
14306 WebUIHandler::with_plugin(
14307 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14308 )
14309 };
14310 let mut with_writer = TestWriter::new();
14311 let mut without_writer = TestWriter::new();
14312 plugin()
14313 .render(
14314 &with_signal,
14315 &state,
14316 &RenderOptions::new("index.html", "/"),
14317 &mut with_writer,
14318 )
14319 .unwrap();
14320 plugin()
14321 .render(
14322 &without_signal,
14323 &state,
14324 &RenderOptions::new("index.html", "/"),
14325 &mut without_writer,
14326 )
14327 .unwrap();
14328 let dom_prefix = |content: &str| -> String {
14333 content
14334 .split_once(r#"<script type="application/json" id="webui-data""#)
14335 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14336 };
14337 assert_eq!(
14338 dom_prefix(&with_writer.get_content()),
14339 dom_prefix(&without_writer.get_content())
14340 );
14341 assert!(!with_writer.get_content().contains("data-ws"));
14342 assert!(with_writer.get_content().contains("<comp-a>"));
14343 }
14344
14345 #[test]
14346 fn streaming_component_without_root_signal_is_rejected() {
14347 let protocol = streaming_root_validation_protocol(
14348 vec![
14349 WebUIFragment::raw("<comp-a>"),
14350 WebUIFragment::component("comp-a"),
14351 ],
14352 completed_streaming_tail(),
14353 );
14354
14355 assert_streaming_root_error(
14356 &protocol,
14357 "comp-a",
14358 "has no compiler-owned streaming_root signal",
14359 );
14360 }
14361
14362 #[test]
14363 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14364 let duplicate = streaming_root_validation_protocol(
14365 vec![
14366 WebUIFragment::raw("<comp-a"),
14367 structural_fragment("streaming_root:comp-a"),
14368 structural_fragment("streaming_root:comp-a"),
14369 WebUIFragment::raw(">"),
14370 WebUIFragment::component("comp-a"),
14371 ],
14372 completed_streaming_tail(),
14373 );
14374 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14375
14376 let after_tag_close = streaming_root_validation_protocol(
14377 vec![
14378 WebUIFragment::raw("<comp-a>"),
14379 structural_fragment("streaming_root:comp-a"),
14380 WebUIFragment::component("comp-a"),
14381 ],
14382 completed_streaming_tail(),
14383 );
14384 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14385
14386 let detached_from_opening = streaming_root_validation_protocol(
14387 vec![
14388 WebUIFragment::raw("not an opening tag"),
14389 structural_fragment("streaming_root:comp-a"),
14390 WebUIFragment::raw(">"),
14391 WebUIFragment::component("comp-a"),
14392 ],
14393 completed_streaming_tail(),
14394 );
14395 assert_streaming_root_error(
14396 &detached_from_opening,
14397 "comp-a",
14398 "unclosed component opening-tag close",
14399 );
14400 }
14401
14402 #[test]
14403 fn mismatched_streaming_root_signal_is_rejected() {
14404 let protocol = streaming_root_validation_protocol(
14405 vec![
14406 WebUIFragment::raw("<comp-a"),
14407 structural_fragment("streaming_root:comp-b"),
14408 WebUIFragment::raw(">"),
14409 WebUIFragment::component("comp-a"),
14410 ],
14411 completed_streaming_tail(),
14412 );
14413
14414 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14415 }
14416
14417 #[test]
14418 fn pending_streaming_root_is_rejected_at_structural_ends() {
14419 let protocol = streaming_root_validation_protocol(
14420 vec![
14421 WebUIFragment::raw("<comp-a"),
14422 structural_fragment("streaming_root:comp-a"),
14423 WebUIFragment::raw(">"),
14424 ],
14425 vec![structural_fragment("boundary_end:0")],
14426 );
14427 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14428 }
14429
14430 #[test]
14431 fn streaming_root_outside_boundary_is_rejected() {
14432 let fragments = HashMap::from([
14433 (
14434 "index.html".to_string(),
14435 FragmentList {
14436 fragments: vec![
14437 WebUIFragment::raw("<html><head>"),
14438 structural_fragment("head_start"),
14439 structural_fragment("head_end"),
14440 WebUIFragment::raw("</head><body>"),
14441 structural_fragment("body_start"),
14442 WebUIFragment::raw("<comp-a"),
14443 structural_fragment("streaming_root:comp-a"),
14444 WebUIFragment::raw(">"),
14445 WebUIFragment::component("comp-a"),
14446 WebUIFragment::raw("</comp-a>"),
14447 structural_fragment("body_end"),
14448 WebUIFragment::raw("</body></html>"),
14449 ],
14450 contains_boundary: false,
14451 },
14452 ),
14453 (
14454 "comp-a".to_string(),
14455 FragmentList {
14456 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14457 contains_boundary: false,
14458 },
14459 ),
14460 ]);
14461 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14462 let mut writer = FlushTestWriter::default();
14463 let result = WebUIHandler::new().render_streaming(
14464 &protocol,
14465 &test_json!({}),
14466 &RenderOptions::new("index.html", "/"),
14467 &mut writer,
14468 );
14469 match result {
14470 Err(HandlerError::StreamingBoundary(err)) => {
14471 assert_eq!(err.signal, "comp-a");
14472 assert!(
14473 err.reason
14474 .contains("no boundary or generated component span"),
14475 "reason: {}",
14476 err.reason
14477 );
14478 }
14479 other => panic!("expected outside-boundary rejection, got {other:?}"),
14480 }
14481 }
14482
14483 fn parser_route_protocol(with_boundary: bool) -> Protocol {
14484 let mut parser = HtmlParser::with_options(DomStrategy::Light);
14485 parser
14486 .component_registry_mut()
14487 .register_component(ComponentRegistration::new(
14488 "route-page",
14489 "<p>route content</p>",
14490 None,
14491 true,
14492 ))
14493 .expect("register route component");
14494 let route = r#"<route path="/" component="route-page" exact />"#;
14495 let html = if with_boundary {
14496 format!(
14497 "<html><head></head><body>\
14498 <boundary name=\"route\">{route}</boundary>\
14499 </body></html>"
14500 )
14501 } else {
14502 format!("<html><head></head><body>{route}</body></html>")
14503 };
14504 parser
14505 .parse("index.html", &html)
14506 .expect("parse route streaming fixture");
14507 let mut document = WebUIProtocol::new(parser.into_fragment_records());
14508 document.components.insert(
14509 "route-page".to_string(),
14510 webui_protocol::ComponentData::default(),
14511 );
14512 Protocol::new(document)
14513 }
14514
14515 #[test]
14516 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14517 let protocol = parser_route_protocol(true);
14518 let mut writer = FlushTestWriter::default();
14519 WebUIHandler::new()
14520 .render_streaming(
14521 &protocol,
14522 &test_json!({}),
14523 &RenderOptions::new("index.html", "/"),
14524 &mut writer,
14525 )
14526 .unwrap();
14527
14528 assert!(
14529 writer
14530 .output
14531 .contains("<route-page data-ws><p>route content</p></route-page>"),
14532 "matched route host must be deferred before upgrade: {}",
14533 writer.output
14534 );
14535 }
14536
14537 #[test]
14538 fn streaming_parser_route_host_outside_boundary_is_rejected() {
14539 let protocol = parser_route_protocol(false);
14540 let mut writer = FlushTestWriter::default();
14541 let result = WebUIHandler::new().render_streaming(
14542 &protocol,
14543 &test_json!({}),
14544 &RenderOptions::new("index.html", "/"),
14545 &mut writer,
14546 );
14547
14548 match result {
14549 Err(HandlerError::StreamingBoundary(error)) => {
14550 assert_eq!(error.signal, "route-page");
14551 assert!(
14552 error
14553 .reason
14554 .contains("no boundary or generated component span"),
14555 "reason: {}",
14556 error.reason
14557 );
14558 }
14559 other => panic!("expected route boundary rejection, got {other:?}"),
14560 }
14561 assert!(
14562 !writer.output.contains("<route-page>"),
14563 "an unmarked custom-element opening must never be completed"
14564 );
14565 }
14566
14567 #[test]
14568 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14569 let entry = vec![
14570 WebUIFragment::raw("<html><head>"),
14571 structural_fragment("head_start"),
14572 structural_fragment("head_end"),
14573 WebUIFragment::raw("</head><body>"),
14574 structural_fragment("body_start"),
14575 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14576 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14577 path: "/account".into(),
14578 fragment_id: "account-shell".into(),
14579 exact: false,
14580 ..Default::default()
14581 }),
14582 WebUIFragment::boundary_end(0),
14583 structural_fragment("body_end"),
14584 WebUIFragment::raw("</body></html>"),
14585 ];
14586 let fragments = HashMap::from([
14587 (
14588 "index.html".to_string(),
14589 FragmentList {
14590 fragments: entry,
14591 contains_boundary: true,
14592 },
14593 ),
14594 (
14595 "account-shell".to_string(),
14596 FragmentList {
14597 fragments: vec![WebUIFragment::route_from(
14598 webui_protocol::WebUiFragmentRoute {
14599 path: "./details".into(),
14600 fragment_id: "details-page".into(),
14601 exact: true,
14602 pending_component: "details-loading".into(),
14603 error_component: "details-error".into(),
14604 ..Default::default()
14605 },
14606 )],
14607 contains_boundary: false,
14608 },
14609 ),
14610 (
14611 "details-page".to_string(),
14612 FragmentList {
14613 fragments: vec![WebUIFragment::raw("<p>details</p>")],
14614 contains_boundary: false,
14615 },
14616 ),
14617 (
14618 "details-loading".to_string(),
14619 FragmentList {
14620 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14621 contains_boundary: false,
14622 },
14623 ),
14624 (
14625 "details-error".to_string(),
14626 FragmentList {
14627 fragments: vec![WebUIFragment::raw("<p>error</p>")],
14628 contains_boundary: false,
14629 },
14630 ),
14631 ]);
14632 let mut document = WebUIProtocol::new(fragments);
14633 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14634 for name in [
14635 "account-shell",
14636 "details-page",
14637 "details-loading",
14638 "details-error",
14639 ] {
14640 document.components.insert(
14641 name.to_string(),
14642 webui_protocol::ComponentData {
14643 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14644 ..Default::default()
14645 },
14646 );
14647 }
14648 let protocol = Protocol::new(document);
14649 let handler = WebUIHandler::with_plugin(|| {
14650 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14651 });
14652 let mut writer = FlushTestWriter::default();
14653
14654 handler
14655 .render_streaming(
14656 &protocol,
14657 &test_json!({}),
14658 &RenderOptions::new("index.html", "/account/details"),
14659 &mut writer,
14660 )
14661 .unwrap();
14662
14663 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14664 assert!(
14665 first.contains(r#""details-loading":"#),
14666 "relative-route pending metadata missing: {first}"
14667 );
14668 assert!(
14669 first.contains(r#""details-error":"#),
14670 "relative-route error metadata missing: {first}"
14671 );
14672 }
14673
14674 #[test]
14675 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14676 let entry = vec![
14677 WebUIFragment::raw("<html><head>"),
14678 structural_fragment("head_start"),
14679 structural_fragment("head_end"),
14680 WebUIFragment::raw("</head><body>"),
14681 structural_fragment("body_start"),
14682 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14683 WebUIFragment::raw("<static-shell"),
14684 structural_fragment("streaming_root:static-shell"),
14685 WebUIFragment::raw(">"),
14686 WebUIFragment::component("static-shell"),
14687 WebUIFragment::raw("</static-shell>"),
14688 WebUIFragment::raw("<route-shell"),
14689 structural_fragment("streaming_root:route-shell"),
14690 WebUIFragment::raw(">"),
14691 WebUIFragment::component("route-shell"),
14692 WebUIFragment::raw("</route-shell>"),
14693 WebUIFragment::boundary_end(0),
14694 structural_fragment("body_end"),
14695 WebUIFragment::raw("</body></html>"),
14696 ];
14697 let fragments = HashMap::from([
14698 (
14699 "index.html".to_string(),
14700 FragmentList {
14701 fragments: entry,
14702 contains_boundary: true,
14703 },
14704 ),
14705 (
14706 "static-shell".to_string(),
14707 FragmentList {
14708 fragments: vec![WebUIFragment::if_cond(
14709 ConditionExpr::identifier("show_hidden"),
14710 "static-hidden-if",
14711 )],
14712 contains_boundary: false,
14713 },
14714 ),
14715 (
14716 "static-hidden-if".to_string(),
14717 FragmentList {
14718 fragments: vec![WebUIFragment::component("static-hidden")],
14719 contains_boundary: false,
14720 },
14721 ),
14722 (
14723 "static-hidden".to_string(),
14724 FragmentList {
14725 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14726 contains_boundary: false,
14727 },
14728 ),
14729 (
14730 "route-shell".to_string(),
14731 FragmentList {
14732 fragments: vec![WebUIFragment::route_from(
14733 webui_protocol::WebUiFragmentRoute {
14734 path: "/account".into(),
14735 fragment_id: "route-page".into(),
14736 exact: true,
14737 ..Default::default()
14738 },
14739 )],
14740 contains_boundary: false,
14741 },
14742 ),
14743 (
14744 "route-page".to_string(),
14745 FragmentList {
14746 fragments: vec![WebUIFragment::raw("<p>route</p>")],
14747 contains_boundary: false,
14748 },
14749 ),
14750 ]);
14751 let mut document = WebUIProtocol::new(fragments);
14752 document.set_css_strategy(webui_protocol::CssStrategy::Link);
14753 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14754 for (name, keys) in [
14755 ("static-shell", vec!["show_hidden", "static_count"]),
14756 ("static-hidden", vec!["hidden_count"]),
14757 ("route-shell", vec!["route_shell_count"]),
14758 ("route-page", vec!["route_count"]),
14759 ] {
14760 document.components.insert(
14761 name.to_string(),
14762 webui_protocol::ComponentData {
14763 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14764 hydration_mode: StateProjectionMode::Keys as i32,
14765 hydration_keys: keys.into_iter().map(str::to_string).collect(),
14766 css_href: format!("/{name}.css"),
14767 ..Default::default()
14768 },
14769 );
14770 }
14771 document.populate_style_closures(&["index.html"]);
14772 let protocol = Protocol::new(document);
14773 assert_eq!(
14774 protocol
14775 .component_reachability()
14776 .is_route_dependent(protocol.component_index()["route-shell"]),
14777 Some(true),
14778 "fixture must exercise request-aware checkpoint reachability"
14779 );
14780 let handler = WebUIHandler::with_plugin(|| {
14781 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14782 });
14783 let mut writer = FlushTestWriter::default();
14784
14785 handler
14786 .render_streaming(
14787 &protocol,
14788 &test_json!({
14789 "show_hidden": false,
14790 "static_count": 1,
14791 "hidden_count": 2,
14792 "route_shell_count": 3,
14793 "route_count": 4,
14794 "unrelated": "private",
14795 }),
14796 &RenderOptions::new("index.html", "/account"),
14797 &mut writer,
14798 )
14799 .unwrap();
14800
14801 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14802 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14803 assert!(
14804 checkpoint.contains(&format!(r#""{template}":"#)),
14805 "{template} metadata missing: {checkpoint}"
14806 );
14807 }
14808 for state in [
14809 r#""show_hidden":false"#,
14810 r#""static_count":1"#,
14811 r#""hidden_count":2"#,
14812 r#""route_shell_count":3"#,
14813 r#""route_count":4"#,
14814 ] {
14815 assert!(
14816 checkpoint.contains(state),
14817 "{state} state missing: {checkpoint}"
14818 );
14819 }
14820 assert!(
14821 !checkpoint.contains("unrelated"),
14822 "checkpoint leaked unrelated state: {checkpoint}"
14823 );
14824 assert!(
14825 checkpoint.contains(
14826 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14827 ),
14828 "checkpoint metadata lost source order: {checkpoint}"
14829 );
14830 assert!(
14831 checkpoint.contains(r#""componentStyles":"#)
14832 && checkpoint.contains(r#""strategy":"link""#)
14833 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14834 "checkpoint is missing tree-local style metadata: {checkpoint}"
14835 );
14836 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14837 assert_eq!(
14838 writer
14839 .output
14840 .matches(&format!(r#"data-webui-resource="{resource}""#))
14841 .count(),
14842 1,
14843 "Document delivery state must persist across streamed checkpoints"
14844 );
14845 }
14846 }
14847
14848 #[test]
14849 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
14850 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14855 let handler = WebUIHandler::with_plugin(|| {
14856 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14857 });
14858 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
14859 let mut writer = FlushTestWriter::default();
14860 handler
14861 .render_streaming(
14862 &protocol,
14863 &state,
14864 &RenderOptions::new("index.html", "/"),
14865 &mut writer,
14866 )
14867 .unwrap();
14868
14869 assert_eq!(
14870 writer.flushes.len(),
14871 5,
14872 "bootstrap, three boundary commits, and one terminal"
14873 );
14874 let segment = |index: usize| -> &str {
14875 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14876 };
14877 let b0 = segment(0);
14878 let b1 = segment(1);
14879 let b2 = segment(2);
14880
14881 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
14883 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
14884 assert!(
14885 b0.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
14886 "b0: {b0}"
14887 );
14888 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
14889 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
14890 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
14891
14892 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
14894 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
14895 assert!(
14896 b1.contains(r#"[2,1,0,1,{"declarationId":1,"componentStyles":"#),
14897 "b1: {b1}"
14898 );
14899 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
14900 assert!(
14901 !b1.contains(r#""templates":{"comp-a"#),
14902 "b1 re-sent comp-a: {b1}"
14903 );
14904 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
14905
14906 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
14908 assert!(
14909 b2.contains(r#"[2,2,0,2,{"declarationId":2,"componentStyles":"#),
14910 "b2: {b2}"
14911 );
14912 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
14913 assert!(
14914 !b2.contains(r#""templates""#),
14915 "b2 re-sent a template: {b2}"
14916 );
14917
14918 assert!(!writer.output.contains("serverOnly"));
14920 }
14921
14922 #[test]
14923 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
14924 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14925 let handler = WebUIHandler::with_plugin(|| {
14926 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14927 });
14928 let mut writer = FlushTestWriter::default();
14929 handler
14930 .render_streaming(
14931 &protocol,
14932 &test_json!({ "a_count": 1, "b_count": 2 }),
14933 &RenderOptions::new("index.html", "/"),
14934 &mut writer,
14935 )
14936 .unwrap();
14937
14938 let segment = |index: usize| -> &str {
14939 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14940 };
14941 let checkpoint_styles = |checkpoint: &str| -> Value {
14942 let script = checkpoint
14943 .rfind(r#"<script type="application/json" data-webui-boundary"#)
14944 .unwrap();
14945 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
14946 let payload_end =
14947 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
14948 let record: Value =
14949 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
14950 record[4]["componentStyles"].clone()
14951 };
14952
14953 let first = checkpoint_styles(segment(0));
14954 assert!(first["resources"]["comp-a"].is_object());
14955 assert!(first["resources"]["comp-b"].is_object());
14956 assert!(first["closures"]["index.html"].is_array());
14957 assert!(first["closures"]["comp-a"].is_array());
14958
14959 let second = checkpoint_styles(segment(1));
14960 assert_eq!(second["resources"], test_json!({}));
14961 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
14962
14963 let repeated = checkpoint_styles(segment(2));
14964 assert_eq!(repeated["resources"], test_json!({}));
14965 assert_eq!(repeated["closures"], test_json!({}));
14966 for resource in ["comp-a", "comp-b"] {
14967 assert_eq!(
14968 [&first, &second, &repeated]
14969 .iter()
14970 .filter(|styles| {
14971 styles["resources"]
14972 .as_object()
14973 .is_some_and(|resources| resources.contains_key(resource))
14974 })
14975 .count(),
14976 1,
14977 "{resource} style metadata was serialized more than once"
14978 );
14979 }
14980 }
14981
14982 #[test]
14983 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
14984 let entry = vec![
14985 WebUIFragment::raw("<html><head>"),
14986 structural_fragment("head_start"),
14987 structural_fragment("head_end"),
14988 WebUIFragment::raw("</head><body>"),
14989 structural_fragment("body_start"),
14990 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14991 WebUIFragment::raw("<comp-a"),
14992 structural_fragment("streaming_root:comp-a"),
14993 WebUIFragment::raw(">"),
14994 WebUIFragment::component("comp-a"),
14995 WebUIFragment::raw("</comp-a>"),
14996 WebUIFragment::boundary_end(0),
14997 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14998 WebUIFragment::raw("<comp-hidden"),
14999 structural_fragment("streaming_root:comp-hidden"),
15000 WebUIFragment::raw(">"),
15001 WebUIFragment::component("comp-hidden"),
15002 WebUIFragment::raw("</comp-hidden>"),
15003 WebUIFragment::boundary_end(1),
15004 structural_fragment("body_end"),
15005 WebUIFragment::raw("</body></html>"),
15006 ];
15007 let fragments = HashMap::from([
15008 (
15009 "index.html".to_string(),
15010 FragmentList {
15011 fragments: entry,
15012 contains_boundary: true,
15013 },
15014 ),
15015 (
15016 "comp-a".to_string(),
15017 FragmentList {
15018 fragments: vec![WebUIFragment::if_cond(
15019 ConditionExpr::identifier("show_hidden"),
15020 "hidden-if",
15021 )],
15022 contains_boundary: false,
15023 },
15024 ),
15025 (
15026 "hidden-if".to_string(),
15027 FragmentList {
15028 fragments: vec![WebUIFragment::component("comp-hidden")],
15029 contains_boundary: false,
15030 },
15031 ),
15032 (
15033 "comp-hidden".to_string(),
15034 FragmentList {
15035 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15036 contains_boundary: false,
15037 },
15038 ),
15039 ]);
15040 let mut document = WebUIProtocol::new(fragments);
15041 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15042 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15043 document.components.insert(
15044 "comp-a".to_string(),
15045 webui_protocol::ComponentData {
15046 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15047 hydration_mode: StateProjectionMode::Keys as i32,
15048 hydration_keys: vec!["show_hidden".to_string()],
15049 ..Default::default()
15050 },
15051 );
15052 document.components.insert(
15053 "comp-hidden".to_string(),
15054 webui_protocol::ComponentData {
15055 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15056 css: ".hidden{display:block}".to_string(),
15057 hydration_mode: StateProjectionMode::Keys as i32,
15058 hydration_keys: vec!["hidden_count".to_string()],
15059 ..Default::default()
15060 },
15061 );
15062 document.populate_style_closures(&["index.html"]);
15063 let protocol = Protocol::new(document);
15064 let handler = WebUIHandler::with_plugin(|| {
15065 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15066 });
15067 let mut writer = FlushTestWriter::default();
15068
15069 handler
15070 .render_streaming(
15071 &protocol,
15072 &test_json!({
15073 "show_hidden": false,
15074 "hidden_count": 7,
15075 "unrelated": "private",
15076 }),
15077 &RenderOptions::new("index.html", "/"),
15078 &mut writer,
15079 )
15080 .unwrap();
15081
15082 let boundary = |index: usize| -> &str {
15083 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15084 };
15085 let first = boundary(0);
15086 let second = boundary(1);
15087
15088 assert!(first.contains(r#""comp-a":"#), "first: {first}");
15089 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15090 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15091 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15092 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15093 assert!(
15094 first.contains(r#"<script type="importmap""#),
15095 "first: {first}"
15096 );
15097 assert!(
15098 !first.contains("unrelated"),
15099 "first leaked unrelated state: {first}"
15100 );
15101
15102 assert!(
15103 !second.contains(r#""templates""#),
15104 "second re-sent metadata: {second}"
15105 );
15106 assert!(
15107 !second.contains(r#"<script type="importmap""#),
15108 "second re-sent CSS: {second}"
15109 );
15110 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15111 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15112 assert_eq!(
15113 writer
15114 .output
15115 .matches(r#"<script type="importmap">"#)
15116 .count(),
15117 1,
15118 "import-map output: {}",
15119 writer.output
15120 );
15121 }
15122
15123 #[test]
15124 fn streaming_flush_count_is_boundaries_plus_one() {
15125 for &count in &[1usize, 3, 10, 100] {
15126 let hosts: Vec<&str> = (0..count)
15127 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15128 .collect();
15129 let protocol = disjoint_streaming_protocol(&hosts);
15130 let handler = WebUIHandler::with_plugin(|| {
15131 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15132 });
15133 let mut writer = FlushTestWriter::default();
15134 handler
15135 .render_streaming(
15136 &protocol,
15137 &test_json!({ "a_count": 1, "b_count": 2 }),
15138 &RenderOptions::new("index.html", "/"),
15139 &mut writer,
15140 )
15141 .unwrap();
15142 assert_eq!(
15143 writer.flushes.len(),
15144 count + 2,
15145 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15146 );
15147 }
15148 }
15149
15150 #[test]
15151 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15152 let mut fragments = HashMap::new();
15153 fragments.insert("comp-a".to_string(), FragmentList::default());
15154 fragments.insert("comp-b".to_string(), FragmentList::default());
15155 let mut protocol = WebUIProtocol::new(fragments);
15156 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15157 protocol.components.insert(
15158 "comp-a".to_string(),
15159 webui_protocol::ComponentData {
15160 hydration_mode: StateProjectionMode::Keys as i32,
15161 hydration_keys: vec!["a".to_string(), "shared".to_string()],
15162 ..Default::default()
15163 },
15164 );
15165 protocol.components.insert(
15166 "comp-b".to_string(),
15167 webui_protocol::ComponentData {
15168 hydration_mode: StateProjectionMode::Keys as i32,
15169 hydration_keys: vec!["b".to_string(), "shared".to_string()],
15170 ..Default::default()
15171 },
15172 );
15173
15174 let protocol = Protocol::new(protocol);
15175 let component_index = protocol.component_index();
15176 let reachability = protocol.component_reachability();
15177 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15178 assert!(!collect_hydration_key_ids_into(
15179 protocol.protocol(),
15180 reachability,
15181 [component_index["comp-a"]],
15182 &mut scratch,
15183 ));
15184 let keys = scratch
15185 .iter()
15186 .filter_map(|id| reachability.hydration_key(*id))
15187 .collect::<Vec<_>>();
15188 assert_eq!(keys, ["a", "shared"]);
15189 let pointer = scratch.as_ptr();
15190 let capacity = scratch.capacity();
15191
15192 assert!(!collect_hydration_key_ids_into(
15193 protocol.protocol(),
15194 reachability,
15195 [component_index["comp-b"]],
15196 &mut scratch,
15197 ));
15198 let keys = scratch
15199 .iter()
15200 .filter_map(|id| reachability.hydration_key(*id))
15201 .collect::<Vec<_>>();
15202 assert_eq!(keys, ["b", "shared"]);
15203 assert_eq!(scratch.as_ptr(), pointer);
15204 assert_eq!(scratch.capacity(), capacity);
15205 }
15206}