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_resources_requiring_escape: &'protocol HashSet<String>,
873 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
879 pub(crate) css_strategy: webui_protocol::CssStrategy,
881 pub(crate) head_inject: Option<&'protocol str>,
887 pub(crate) body_inject: Option<&'protocol str>,
891 pub(crate) state_inject: StateInject<'state>,
896 pub(crate) head_end_emitted: bool,
903 pub(crate) body_start_emitted: bool,
908 pub(crate) component_asset_styles_emitted: bool,
911 pub(crate) body_end_emitted: bool,
916 pub(crate) route_index: &'protocol CompiledRouteIndex,
918 pub(crate) route_chain_index: usize,
922 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
925 pub(crate) route_document_style_targets: Vec<bool>,
929 pub(crate) reachable_components: Option<Vec<String>>,
935 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
937 pub(crate) json_scratch: Vec<u8>,
944 pub(crate) scope_pool: Vec<HashMap<String, Value>>,
950 pub(crate) document_style_resources: HashSet<String>,
953 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
956 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
958}
959
960pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
963
964pub(crate) fn structural_signal_value(
965 signal: &webui_protocol::WebUIFragmentSignal,
966) -> Option<&str> {
967 if !signal.raw {
968 return None;
969 }
970 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
971}
972
973pub(crate) fn write_interaction_marker(
974 tag: &str,
975 context: &mut WebUIProcessContext<'_, '_, '_>,
976) -> Result<()> {
977 if matches!(
978 context
979 .protocol
980 .components
981 .get(tag)
982 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
983 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
984 ) {
985 context.writer.write(" data-webui-interaction")?;
986 }
987 Ok(())
988}
989
990fn doctype_prefix_end(raw: &str) -> Option<usize> {
993 const PREFIX: &[u8] = b"<!doctype";
994
995 let bytes = raw.as_bytes();
996 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
997 while bytes
998 .get(cursor)
999 .is_some_and(|byte| byte.is_ascii_whitespace())
1000 {
1001 cursor += 1;
1002 }
1003
1004 let prefix_end = cursor.checked_add(PREFIX.len())?;
1005 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1006 return None;
1007 }
1008 let separator = *bytes.get(prefix_end)?;
1009 if separator != b'>' && !separator.is_ascii_whitespace() {
1010 return None;
1011 }
1012
1013 let mut quote = None;
1014 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1015 match quote {
1016 Some(active) if byte == active => quote = None,
1017 Some(_) => {}
1018 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1019 None if byte == b'>' => return Some(prefix_end + offset + 1),
1020 None => {}
1021 }
1022 }
1023 None
1024}
1025
1026const SCOPE_POOL_CAP: usize = 8;
1029const INLINE_SCOPE_SLOTS: usize = 4;
1031const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1042const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1043
1044struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1045
1046impl Serialize for ComponentAssetStyleManifest<'_> {
1047 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1048 where
1049 S: serde::Serializer,
1050 {
1051 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1052 for entry in self.0 {
1053 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1054 }
1055 map.end()
1056 }
1057}
1058
1059fn serialize_component_asset_style_manifest(
1060 preloads: &[ComponentAssetStylePreload],
1061) -> std::result::Result<String, serde_json::Error> {
1062 if preloads.is_empty() {
1063 return Ok(String::new());
1064 }
1065 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1066 if json.contains("</") {
1067 Ok(json.replace("</", "<\\/"))
1068 } else {
1069 Ok(json)
1070 }
1071}
1072
1073fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1074 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1075 let mut seen = HashSet::with_capacity(href_count);
1076 let mut links = String::with_capacity(href_count.saturating_mul(48));
1077 for entry in preloads {
1078 for href in &entry.style_hrefs {
1079 if !seen.insert(href.as_str()) {
1080 continue;
1081 }
1082 links.push_str("<link rel=\"stylesheet\" href=\"");
1083 push_escaped_html_attribute(&mut links, href);
1084 links.push_str("\">");
1085 }
1086 }
1087 links
1088}
1089
1090fn push_escaped_html_attribute(output: &mut String, value: &str) {
1091 let mut start = 0;
1092 for (index, ch) in value.char_indices() {
1093 let escaped = match ch {
1094 '&' => Some("&"),
1095 '"' => Some("""),
1096 '<' => Some("<"),
1097 '>' => Some(">"),
1098 _ => None,
1099 };
1100 let Some(entity) = escaped else {
1101 continue;
1102 };
1103 if start < index {
1104 output.push_str(&value[start..index]);
1105 }
1106 output.push_str(entity);
1107 start = index + ch.len_utf8();
1108 }
1109 if start < value.len() {
1110 output.push_str(&value[start..]);
1111 }
1112}
1113
1114fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1117 pool.pop().unwrap_or_default()
1118}
1119
1120fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1124 if pool.len() < SCOPE_POOL_CAP {
1125 map.clear();
1126 pool.push(map);
1127 }
1128}
1129
1130fn take_borrowed_scope<'protocol, 'state>(
1131 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1132) -> BorrowedScope<'protocol, 'state> {
1133 pool.pop().unwrap_or_default()
1134}
1135
1136fn recycle_borrowed_scope<'protocol, 'state>(
1137 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1138 mut scope: BorrowedScope<'protocol, 'state>,
1139) {
1140 if pool.len() < SCOPE_POOL_CAP {
1141 scope.clear();
1142 pool.push(scope);
1143 }
1144}
1145
1146pub(crate) struct WebUiBootstrap<'a> {
1147 pub(crate) declaration_id: Option<u32>,
1148 pub(crate) enclosing_span_instance_id: Option<u32>,
1149 pub(crate) state: &'a Value,
1150 pub(crate) state_selection: StateSelection<'a>,
1151 pub(crate) chain: &'a [Value],
1152 pub(crate) inventory: &'a str,
1153 pub(crate) nonce: Option<&'a str>,
1154 pub(crate) css_hrefs: &'a [&'a str],
1155 pub(crate) style_specs: &'a [&'a str],
1156 pub(crate) component_styles: &'a Value,
1157 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1158}
1159
1160#[cfg(test)]
1170fn component_attr_name(name: &str) -> String {
1171 let mut buffer = String::new();
1172 push_component_attr_name(&mut buffer, name);
1173 buffer
1174}
1175
1176fn push_component_attr_name(buffer: &mut String, name: &str) {
1185 let stripped = name.strip_prefix(':').unwrap_or(name);
1186 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1191 buffer.push_str(property);
1192 return;
1193 }
1194 buffer.reserve(stripped.len());
1195 let mut capitalize_next = false;
1196 for ch in stripped.chars() {
1197 if ch == '-' {
1198 capitalize_next = true;
1199 } else if capitalize_next {
1200 buffer.extend(ch.to_uppercase());
1201 capitalize_next = false;
1202 } else {
1203 buffer.push(ch);
1204 }
1205 }
1206}
1207
1208#[cold]
1209#[inline(never)]
1210fn missing_component_attr_name_error() -> HandlerError {
1211 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1212}
1213
1214pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1216 if n == 0 {
1217 return writer.write("0");
1218 }
1219 let mut buf = [0u8; 20];
1221 let mut pos = buf.len();
1222 while n > 0 {
1223 pos -= 1;
1224 #[allow(clippy::cast_possible_truncation)]
1226 let digit = (n % 10) as u8;
1227 buf[pos] = b'0' + digit;
1228 n /= 10;
1229 }
1230 match std::str::from_utf8(&buf[pos..]) {
1232 Ok(s) => writer.write(s),
1233 Err(_) => writer.write("0"),
1234 }
1235}
1236
1237pub(crate) fn write_script_safe_json<T>(
1238 writer: &mut dyn ResponseWriter,
1239 scratch: &mut Vec<u8>,
1240 value: &T,
1241) -> Result<()>
1242where
1243 T: Serialize + ?Sized,
1244{
1245 scratch.clear();
1253 serde_json::to_writer(&mut *scratch, value)
1254 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1255 let json = std::str::from_utf8(scratch)
1256 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1257 write_script_safe_json_str(writer, json)
1258}
1259
1260fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1261 let mut start = 0;
1262 while start < json.len() {
1263 let rest = &json[start..];
1264 let Some(offset) = rest.find("</") else {
1265 writer.write(rest)?;
1266 return Ok(());
1267 };
1268
1269 if offset > 0 {
1270 writer.write(&rest[..offset])?;
1271 }
1272 writer.write("<\\/")?;
1273 start += offset + 2;
1274 }
1275 Ok(())
1276}
1277
1278fn write_json_field_name(
1279 writer: &mut dyn ResponseWriter,
1280 wrote_field: &mut bool,
1281 name: &str,
1282) -> Result<()> {
1283 if *wrote_field {
1284 writer.write(",")?;
1285 }
1286 *wrote_field = true;
1287 writer.write("\"")?;
1288 writer.write(name)?;
1289 writer.write("\":")
1290}
1291
1292fn write_json_field<T>(
1293 writer: &mut dyn ResponseWriter,
1294 scratch: &mut Vec<u8>,
1295 wrote_field: &mut bool,
1296 name: &str,
1297 value: &T,
1298) -> Result<()>
1299where
1300 T: Serialize + ?Sized,
1301{
1302 write_json_field_name(writer, wrote_field, name)?;
1303 write_script_safe_json(writer, scratch, value)
1304}
1305
1306struct ProjectedState<'a> {
1324 value: &'a Value,
1325 keys: KeyView<'a>,
1326}
1327
1328impl Serialize for ProjectedState<'_> {
1329 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1330 where
1331 S: serde::Serializer,
1332 {
1333 let Value::Object(map) = self.value else {
1334 return serializer.serialize_map(Some(0))?.end();
1335 };
1336
1337 let mut out = serializer.serialize_map(None)?;
1338 if self.keys.len() < map.len() {
1339 let mut previous = None;
1340 for key in self.keys.iter() {
1341 if key == STATE_INJECT_KEY {
1342 continue;
1343 }
1344 if previous == Some(key) {
1345 continue;
1346 }
1347 previous = Some(key);
1348 if let Some(value) = map.get(key) {
1349 out.serialize_entry(key, value)?;
1350 }
1351 }
1352 } else {
1353 for (key, value) in map {
1354 if key == STATE_INJECT_KEY {
1355 continue;
1356 }
1357 if self.keys.contains(key.as_str()) {
1358 out.serialize_entry(key, value)?;
1359 }
1360 }
1361 }
1362 out.end()
1363 }
1364}
1365
1366struct StateWithoutReservedKey<'a> {
1387 value: &'a Value,
1388}
1389
1390impl Serialize for StateWithoutReservedKey<'_> {
1391 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1392 where
1393 S: serde::Serializer,
1394 {
1395 let Value::Object(map) = self.value else {
1396 return self.value.serialize(serializer);
1397 };
1398 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1399 for (key, value) in map {
1400 if key == STATE_INJECT_KEY {
1401 continue;
1402 }
1403 out.serialize_entry(key, value)?;
1404 }
1405 out.end()
1406 }
1407}
1408
1409fn write_full_state(
1414 writer: &mut dyn ResponseWriter,
1415 scratch: &mut Vec<u8>,
1416 state: &Value,
1417) -> Result<()> {
1418 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1419 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1420 }
1421 write_script_safe_json(writer, scratch, state)
1422}
1423
1424pub(crate) fn write_selected_state(
1425 writer: &mut dyn ResponseWriter,
1426 scratch: &mut Vec<u8>,
1427 state: &Value,
1428 selection: &StateSelection<'_>,
1429) -> Result<()> {
1430 let keys = match selection {
1431 StateSelection::Full => return write_full_state(writer, scratch, state),
1432 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1433 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1434 };
1435 if keys.is_empty() {
1436 return writer.write("{}");
1437 }
1438
1439 debug_assert!(
1444 keys.iter()
1445 .zip(keys.iter().skip(1))
1446 .all(|(left, right)| left <= right),
1447 "hydration keys must be sorted for binary-search projection"
1448 );
1449 if let Value::Object(map) = state {
1450 let selects_entire_map =
1451 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1452 if selects_entire_map {
1453 return write_full_state(writer, scratch, state);
1454 }
1455 }
1456 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1457}
1458
1459pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1462
1463pub(crate) enum StateSelection<'a> {
1465 Full,
1467 Keys(Vec<&'a str>),
1469 KeyIds(HydrationKeySelection<'a>),
1476}
1477
1478#[derive(Clone, Copy)]
1480pub(crate) struct HydrationKeySelection<'a> {
1481 pub(crate) ids: &'a [u32],
1482 pub(crate) index: &'a ComponentReachabilityIndex,
1483}
1484
1485impl<'a> HydrationKeySelection<'a> {
1486 fn key(self, position: usize) -> Option<&'a str> {
1487 let id = self.ids.get(position).copied()?;
1488 self.index.hydration_key(id)
1489 }
1490}
1491
1492#[derive(Clone, Copy)]
1494enum KeyView<'a> {
1495 Borrowed(&'a [&'a str]),
1496 Ids(HydrationKeySelection<'a>),
1497}
1498
1499impl<'a> KeyView<'a> {
1500 fn len(self) -> usize {
1501 match self {
1502 Self::Borrowed(keys) => keys.len(),
1503 Self::Ids(selection) => selection.ids.len(),
1504 }
1505 }
1506
1507 fn is_empty(self) -> bool {
1508 self.len() == 0
1509 }
1510
1511 fn get(self, position: usize) -> Option<&'a str> {
1512 match self {
1513 Self::Borrowed(keys) => keys.get(position).copied(),
1514 Self::Ids(selection) => selection.key(position),
1515 }
1516 }
1517
1518 fn iter(self) -> impl Iterator<Item = &'a str> {
1519 (0..self.len()).map_while(move |position| self.get(position))
1520 }
1521
1522 fn contains(self, key: &str) -> bool {
1525 match self {
1526 Self::Borrowed(keys) => keys
1527 .binary_search_by(|candidate| str::cmp(candidate, key))
1528 .is_ok(),
1529 Self::Ids(selection) => selection
1530 .ids
1531 .binary_search_by(|id| {
1532 selection
1533 .index
1534 .hydration_key(*id)
1535 .map_or(std::cmp::Ordering::Less, |candidate| {
1536 str::cmp(candidate, key)
1537 })
1538 })
1539 .is_ok(),
1540 }
1541 }
1542}
1543
1544#[derive(Clone, Copy)]
1545enum ComponentStateSurface {
1546 Hydration,
1547 Navigation,
1548}
1549
1550pub(crate) fn collect_hydration_state<'a, 'b>(
1556 protocol: &'a WebUIProtocol,
1557 components: impl IntoIterator<Item = &'b str>,
1558) -> StateSelection<'a> {
1559 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1560 return StateSelection::Full;
1561 }
1562 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1563}
1564
1565pub(crate) fn collect_hydration_key_ids_into(
1573 protocol: &WebUIProtocol,
1574 index: &ComponentReachabilityIndex,
1575 components: impl IntoIterator<Item = u32>,
1576 ids: &mut Vec<u32>,
1577) -> bool {
1578 ids.clear();
1579 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1580 return true;
1581 }
1582 for component in components {
1583 if index.extend_hydration_keys(component, ids) {
1584 ids.clear();
1585 return true;
1586 }
1587 }
1588 ids.sort_unstable();
1589 ids.dedup();
1590 false
1591}
1592
1593pub(crate) fn collect_navigation_state<'a, 'b>(
1595 protocol: &'a WebUIProtocol,
1596 components: impl IntoIterator<Item = &'b str>,
1597) -> StateSelection<'a> {
1598 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1599}
1600
1601fn collect_component_state<'a, 'b>(
1602 protocol: &'a WebUIProtocol,
1603 components: impl IntoIterator<Item = &'b str>,
1604 surface: ComponentStateSurface,
1605) -> StateSelection<'a> {
1606 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1607 if collect_component_state_into(protocol, components, surface, &mut keys) {
1608 StateSelection::Full
1609 } else {
1610 StateSelection::Keys(keys)
1611 }
1612}
1613
1614fn collect_component_state_into<'a, 'b>(
1617 protocol: &'a WebUIProtocol,
1618 components: impl IntoIterator<Item = &'b str>,
1619 surface: ComponentStateSurface,
1620 keys: &mut Vec<&'a str>,
1621) -> bool {
1622 keys.clear();
1623 for name in components {
1624 let Some(component) = protocol.components.get(name) else {
1625 return true;
1626 };
1627 let (mode, component_keys) = match surface {
1628 ComponentStateSurface::Hydration => {
1629 (component.hydration_mode, &component.hydration_keys)
1630 }
1631 ComponentStateSurface::Navigation => (
1632 match component.navigation_mode {
1633 Some(mode) => mode,
1634 None if !component.navigation_keys.is_empty() => {
1635 StateProjectionMode::Keys as i32
1636 }
1637 None => return true,
1638 },
1639 &component.navigation_keys,
1640 ),
1641 };
1642 if mode == StateProjectionMode::All as i32 {
1643 return true;
1644 }
1645 if mode == StateProjectionMode::Keys as i32
1646 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1647 {
1648 keys.extend(component_keys.iter().map(String::as_str));
1649 } else if mode != StateProjectionMode::None as i32 {
1650 return true;
1651 }
1652 }
1653 keys.sort_unstable();
1654 keys.dedup();
1655 false
1656}
1657
1658pub(crate) fn write_webui_bootstrap(
1659 writer: &mut dyn ResponseWriter,
1660 scratch: &mut Vec<u8>,
1661 bootstrap: WebUiBootstrap<'_>,
1662) -> Result<()> {
1663 let mut wrote_field = false;
1664
1665 writer.write("{")?;
1666 if let Some(declaration_id) = bootstrap.declaration_id {
1667 write_json_field(
1668 writer,
1669 scratch,
1670 &mut wrote_field,
1671 "declarationId",
1672 &declaration_id,
1673 )?;
1674 }
1675 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1676 write_json_field(
1677 writer,
1678 scratch,
1679 &mut wrote_field,
1680 "enclosingSpanInstanceId",
1681 &span_id,
1682 )?;
1683 }
1684 if !bootstrap.chain.is_empty() {
1685 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1686 }
1687 write_json_field(
1690 writer,
1691 scratch,
1692 &mut wrote_field,
1693 "componentStyles",
1694 bootstrap.component_styles,
1695 )?;
1696 if !bootstrap.css_hrefs.is_empty() {
1697 write_json_field(
1698 writer,
1699 scratch,
1700 &mut wrote_field,
1701 "css",
1702 bootstrap.css_hrefs,
1703 )?;
1704 }
1705 write_json_field(
1706 writer,
1707 scratch,
1708 &mut wrote_field,
1709 "inventory",
1710 bootstrap.inventory,
1711 )?;
1712 if let Some(nonce) = bootstrap.nonce {
1713 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1714 }
1715 write_json_field_name(writer, &mut wrote_field, "state")?;
1716 write_selected_state(writer, scratch, bootstrap.state, &bootstrap.state_selection)?;
1717 if !bootstrap.style_specs.is_empty() {
1718 write_json_field(
1719 writer,
1720 scratch,
1721 &mut wrote_field,
1722 "styles",
1723 bootstrap.style_specs,
1724 )?;
1725 }
1726 if bootstrap
1727 .templates
1728 .iter()
1729 .any(|template| !template.template_json.is_empty())
1730 {
1731 write_json_field_name(writer, &mut wrote_field, "templates")?;
1732 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1733 }
1734 writer.write("}")
1735}
1736
1737fn write_webui_data_block(
1738 writer: &mut dyn ResponseWriter,
1739 scratch: &mut Vec<u8>,
1740 bootstrap: WebUiBootstrap<'_>,
1741) -> Result<()> {
1742 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1743 if let Some(nonce) = bootstrap.nonce {
1744 writer.write(" nonce=\"")?;
1745 writer.write(nonce)?;
1746 writer.write("\"")?;
1747 }
1748
1749 writer.write(">")?;
1750 write_webui_bootstrap(writer, scratch, bootstrap)?;
1751 writer.write("</script>\n")
1752}
1753
1754fn write_component_asset_style_manifest(
1755 writer: &mut dyn ResponseWriter,
1756 manifest: &str,
1757 nonce: Option<&str>,
1758) -> Result<()> {
1759 if manifest.is_empty() {
1760 return Ok(());
1761 }
1762 writer.write("<script type=\"application/json\" id=\"")?;
1763 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1764 writer.write("\"")?;
1765 if let Some(nonce) = nonce {
1766 writer.write(" nonce=\"")?;
1767 writer.write(&crate::html_encode::encode_safe(nonce))?;
1768 writer.write("\"")?;
1769 }
1770 writer.write(">")?;
1771 writer.write(manifest)?;
1772 writer.write("</script>")
1773}
1774
1775fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1776 if context.component_asset_styles_emitted {
1777 return Ok(());
1778 }
1779 context.component_asset_styles_emitted = true;
1780 context.writer.write(context.component_asset_style_links)?;
1781 write_component_asset_style_manifest(
1782 context.writer,
1783 context.component_asset_style_manifest,
1784 context.nonce,
1785 )
1786}
1787
1788fn write_webui_template_json_map(
1789 writer: &mut dyn ResponseWriter,
1790 scratch: &mut Vec<u8>,
1791 templates: &[WebUiTemplatePayload<'_>],
1792) -> Result<()> {
1793 writer.write("{")?;
1794 let mut wrote = false;
1795 for template in templates {
1796 if template.template_json.is_empty() {
1797 continue;
1798 }
1799 if wrote {
1800 writer.write(",")?;
1801 }
1802 wrote = true;
1803 write_script_safe_json(writer, scratch, template.tag_name)?;
1804 writer.write(":")?;
1805 write_script_safe_json_str(writer, template.template_json)?;
1806 }
1807 writer.write("}")
1808}
1809
1810fn resolve_value_from_sources<'ctx, 'state>(
1811 path: &str,
1812 loop_vars: &'ctx [LoopBinding<'_, 'state>],
1813 visible_loop_scope: VisibleLoopScope,
1814 local_values: LocalValueSources<'ctx, '_, 'state>,
1815 state: &'state Value,
1816) -> Option<Cow<'ctx, Value>>
1817where
1818 'state: 'ctx,
1819{
1820 if let Some(binding) = loop_vars
1821 .get(visible_loop_scope.start..visible_loop_scope.end)
1822 .and_then(|bindings| bindings.last())
1823 {
1824 let name = binding.name;
1825 if path.len() == name.len() && path == name {
1826 return Some(Cow::Borrowed(binding.value));
1827 }
1828 if path.len() > name.len()
1829 && path.as_bytes().get(name.len()) == Some(&b'.')
1830 && path.starts_with(name)
1831 {
1832 if let Some(value) =
1833 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
1834 {
1835 return Some(value);
1836 }
1837 return find_value_by_dotted_path_ref(path, state);
1838 }
1839 }
1840
1841 if let Some(first_part) = path.split('.').next() {
1842 if let Some(local_value) = local_values.borrowed.get(first_part) {
1843 if first_part.len() == path.len() {
1844 return Some(Cow::Borrowed(local_value));
1845 }
1846 let remaining = &path[first_part.len() + 1..];
1847 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1848 return Some(value);
1849 }
1850 return find_value_by_dotted_path_ref(path, state);
1851 }
1852 if let Some(local_value) = local_values.owned.get(first_part) {
1853 if first_part.len() == path.len() {
1854 return Some(Cow::Borrowed(local_value));
1855 }
1856 let remaining = &path[first_part.len() + 1..];
1857 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
1858 return Some(value);
1859 }
1860 return find_value_by_dotted_path_ref(path, state);
1861 }
1862
1863 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1864 .iter()
1865 .rev()
1866 {
1867 if binding.name != first_part {
1868 continue;
1869 }
1870 if first_part.len() == path.len() {
1871 return Some(Cow::Borrowed(binding.value));
1872 }
1873 let remaining = &path[first_part.len() + 1..];
1874 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
1875 return Some(value);
1876 }
1877 return find_value_by_dotted_path_ref(path, state);
1878 }
1879 }
1880
1881 find_value_by_dotted_path_ref(path, state)
1882}
1883
1884fn resolve_borrowed_collection<'state>(
1885 path: &str,
1886 loop_vars: &[LoopBinding<'_, 'state>],
1887 visible_loop_scope: VisibleLoopScope,
1888 local_values: LocalValueSources<'_, '_, 'state>,
1889 state: &'state Value,
1890) -> Option<&'state [Value]> {
1891 let first_part = path.split('.').next()?;
1892 if let Some(binding) = loop_vars
1893 .get(visible_loop_scope.start..visible_loop_scope.end)
1894 .and_then(|bindings| bindings.last())
1895 .filter(|binding| binding.name == first_part)
1896 {
1897 if first_part.len() == path.len() {
1898 return binding.value.as_array().map(Vec::as_slice);
1899 }
1900 let remaining = &path[first_part.len() + 1..];
1901 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1902 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1903 Some(Cow::Owned(_)) => None,
1904 None => borrowed_state_array(path, state),
1905 };
1906 }
1907 if let Some(local_value) = local_values.borrowed.get(first_part) {
1908 let local_match = if first_part.len() == path.len() {
1909 Some(Cow::Borrowed(local_value))
1910 } else {
1911 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1912 };
1913 return match local_match {
1914 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1915 Some(Cow::Owned(_)) => None,
1916 None => borrowed_state_array(path, state),
1917 };
1918 }
1919 if let Some(local_value) = local_values.owned.get(first_part) {
1920 let local_match = if first_part.len() == path.len() {
1921 Some(Cow::Borrowed(local_value))
1922 } else {
1923 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
1924 };
1925 if local_match.is_some() {
1926 return None;
1927 }
1928 return borrowed_state_array(path, state);
1929 }
1930
1931 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1932 .iter()
1933 .rev()
1934 {
1935 if binding.name != first_part {
1936 continue;
1937 }
1938 if first_part.len() == path.len() {
1939 return binding.value.as_array().map(Vec::as_slice);
1940 }
1941 let remaining = &path[first_part.len() + 1..];
1942 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1943 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
1944 Some(Cow::Owned(_)) => None,
1945 None => borrowed_state_array(path, state),
1946 };
1947 }
1948
1949 borrowed_state_array(path, state)
1950}
1951
1952fn resolve_state_backed_value<'state>(
1953 path: &str,
1954 loop_vars: &[LoopBinding<'_, 'state>],
1955 visible_loop_scope: VisibleLoopScope,
1956 local_values: LocalValueSources<'_, '_, 'state>,
1957 state: &'state Value,
1958) -> Option<&'state Value> {
1959 let first_part = path.split('.').next()?;
1960 if let Some(binding) = loop_vars
1961 .get(visible_loop_scope.start..visible_loop_scope.end)
1962 .and_then(|bindings| bindings.last())
1963 .filter(|binding| binding.name == first_part)
1964 {
1965 if first_part.len() == path.len() {
1966 return Some(binding.value);
1967 }
1968 let remaining = &path[first_part.len() + 1..];
1969 return match find_value_by_dotted_path_ref(remaining, binding.value) {
1970 Some(Cow::Borrowed(value)) => Some(value),
1971 Some(Cow::Owned(_)) => None,
1972 None => borrowed_state_value(path, state),
1973 };
1974 }
1975 if let Some(local_value) = local_values.borrowed.get(first_part) {
1976 if first_part.len() == path.len() {
1977 return Some(local_value);
1978 }
1979 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1980 Some(Cow::Borrowed(value)) => Some(value),
1981 Some(Cow::Owned(_)) => None,
1982 None => borrowed_state_value(path, state),
1983 };
1984 }
1985 if let Some(local_value) = local_values.owned.get(first_part) {
1986 if first_part.len() == path.len() {
1987 return None;
1988 }
1989 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
1990 Some(_) => None,
1991 None => borrowed_state_value(path, state),
1992 };
1993 }
1994
1995 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
1996 .iter()
1997 .rev()
1998 {
1999 if binding.name != first_part {
2000 continue;
2001 }
2002 if first_part.len() == path.len() {
2003 return Some(binding.value);
2004 }
2005 let remaining = &path[first_part.len() + 1..];
2006 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2007 Some(Cow::Borrowed(value)) => Some(value),
2008 Some(Cow::Owned(_)) => None,
2009 None => borrowed_state_value(path, state),
2010 };
2011 }
2012
2013 borrowed_state_value(path, state)
2014}
2015
2016fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2017 match find_value_by_dotted_path_ref(path, state) {
2018 Some(Cow::Borrowed(value)) => Some(value),
2019 Some(Cow::Owned(_)) | None => None,
2020 }
2021}
2022
2023fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2024 match find_value_by_dotted_path_ref(path, state)? {
2025 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2026 Cow::Owned(_) => None,
2027 }
2028}
2029
2030impl WebUIHandler {
2031 pub fn new() -> Self {
2033 Self {
2034 plugin_factory: None,
2035 }
2036 }
2037
2038 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2043 Self {
2044 plugin_factory: Some(factory),
2045 }
2046 }
2047
2048 #[cfg(test)]
2049 fn handle(
2050 &self,
2051 document: &WebUIProtocol,
2052 state: &Value,
2053 options: &RenderOptions<'_>,
2054 writer: &mut dyn ResponseWriter,
2055 ) -> Result<()> {
2056 let protocol = Protocol::new(document.clone());
2057 self.render(&protocol, state, options, writer)
2058 }
2059
2060 fn process_fragment_target<'data>(
2066 &self,
2067 target: Option<usize>,
2068 fragment_id: &str,
2069 context: &mut WebUIProcessContext<'data, '_, '_>,
2070 ) -> Result<()> {
2071 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2072 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2073 };
2074 let Some(fragment_list) = context.render_fragments.list(index) else {
2075 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2076 };
2077 self.process_fragment(fragment_list, context)
2078 }
2079
2080 fn process_fragment<'data>(
2085 &self,
2086 fragment_list: RenderFragmentList<'data>,
2087 context: &mut WebUIProcessContext<'data, '_, '_>,
2088 ) -> Result<()> {
2089 self.process_fragment_from(fragment_list, 0, context)
2090 }
2091
2092 fn process_fragment_from<'data>(
2093 &self,
2094 fragment_list: RenderFragmentList<'data>,
2095 start: usize,
2096 context: &mut WebUIProcessContext<'data, '_, '_>,
2097 ) -> Result<()> {
2098 let fragments = fragment_list.fragments;
2099 let best_route = if fragment_list.has_routes {
2105 route_renderer::find_best_route_match(
2106 fragments,
2107 context.request_path,
2108 &context.route_base,
2109 context.route_index,
2110 )
2111 } else {
2112 None
2113 };
2114 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2115 }
2116
2117 fn process_fragment_range<'data>(
2118 &self,
2119 fragment_list: RenderFragmentList<'data>,
2120 range: std::ops::Range<usize>,
2121 best_route: &Option<(String, route_matcher::RouteMatch)>,
2122 context: &mut WebUIProcessContext<'data, '_, '_>,
2123 ) -> Result<()> {
2124 let fragments = fragment_list.fragments;
2125 let Some(selected) = fragments.get(range.clone()) else {
2126 return Err(invalid_fragment_range_error(&range, fragments.len()));
2127 };
2128 for (offset, item) in selected.iter().enumerate() {
2129 let index = range.start + offset;
2130 if context.streaming.is_some() {
2131 validate_pending_streaming_root(item, context)?;
2132 validate_streaming_root_opening(&fragments[..index], item)?;
2133 }
2134 match item.fragment.as_ref() {
2138 Some(Fragment::Raw(raw)) => {
2139 context.writer.write(&raw.value)?;
2140 }
2141 Some(Fragment::Component(component)) => {
2142 self.process_component(
2143 component,
2144 fragment_list.target(index),
2145 ComponentHostOrigin::ParserProduced,
2146 context,
2147 )?;
2148 }
2149 Some(Fragment::ForLoop(for_loop)) => {
2150 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2151 }
2152 Some(Fragment::Signal(signal)) => {
2153 self.process_signal(signal, context)?;
2154 }
2155 Some(Fragment::IfCond(if_cond)) => {
2156 self.process_if(if_cond, fragment_list.target(index), context)?;
2157 }
2158 Some(Fragment::Attribute(attr)) => {
2159 self.process_attribute(
2160 attr,
2161 fragment_list.target(index),
2162 fragment_list.component_attr_name(index),
2163 context,
2164 )?;
2165 }
2166 Some(Fragment::Plugin(plugin_frag)) => {
2167 if let Some(p) = &mut context.plugin {
2168 p.on_element_data(&plugin_frag.data, context.writer)?;
2169 }
2170 }
2171 Some(Fragment::Route(route_frag)) => {
2172 self.process_route(route_frag, best_route, context)?;
2173 }
2174 Some(Fragment::Outlet(_)) => {
2175 self.process_outlet(context)?;
2176 }
2177 Some(Fragment::Boundary(_)) => {}
2178 None => {}
2179 }
2180 }
2181 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2182 }
2183
2184 fn process_outlet(&self, context: &mut WebUIProcessContext) -> Result<()> {
2190 let mut children = std::mem::take(&mut context.route_children);
2191 if children.is_empty() {
2192 return Ok(());
2193 }
2194
2195 let request_segments = route_matcher::split_request_path(context.request_path);
2197 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2198 for (idx, child) in children.iter().enumerate() {
2199 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2200 context.route_index,
2201 &child.path,
2202 &context.route_base,
2203 &request_segments,
2204 child.exact,
2205 ) {
2206 let is_better = best
2207 .as_ref()
2208 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2209 if is_better {
2210 best = Some((idx, m));
2211 }
2212 }
2213 }
2214
2215 let grandchildren = if let Some((idx, _)) = &best {
2219 std::mem::take(&mut children[*idx].children)
2220 } else {
2221 Vec::new()
2222 };
2223
2224 if let Some((idx, ref rm)) = best {
2225 let matched_child = &children[idx];
2226 let comp = &matched_child.fragment_id;
2227
2228 if !comp.is_empty() {
2229 let saved_route_base = context.route_base.clone();
2230 let saved_route_children = std::mem::take(&mut context.route_children);
2231
2232 if rm.consumed_segments > 0 {
2233 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2234 context.request_path,
2235 rm.consumed_segments,
2236 ));
2237 }
2238
2239 context.route_children = grandchildren;
2240
2241 context.writer.write("<webui-route")?;
2243 context.writer.write(" path=\"")?;
2244 context.writer.write(&matched_child.path)?;
2245 context.writer.write("\"")?;
2246 context.writer.write(" component=\"")?;
2247 context.writer.write(comp)?;
2248 context.writer.write("\"")?;
2249 if matched_child.exact {
2250 context.writer.write(" exact")?;
2251 }
2252 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2253 let ri = context.route_chain_index;
2255 context.route_chain_index += 1;
2256 context.writer.write(" data-ri=\"")?;
2257 write_usize(context.writer, ri)?;
2258 context.writer.write("\" active>")?;
2259
2260 if !Self::component_owns_css_tree(comp, context.protocol) {
2261 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2262 }
2263 if !matched_child.content_fragment_id.is_empty() {
2264 self.process_fragment_target(
2265 None,
2266 &matched_child.content_fragment_id,
2267 context,
2268 )?;
2269 }
2270
2271 context.writer.write("<")?;
2272 context.writer.write(comp)?;
2273 if let Some(p) = &context.plugin {
2274 p.write_route_component_state(context.state, context.writer)?;
2275 }
2276 write_interaction_marker(comp, context)?;
2277 prepare_generated_streaming_root(comp, context)?;
2278 context.writer.write(">")?;
2279
2280 self.process_component(
2281 &webui_protocol::WebUIFragmentComponent {
2282 fragment_id: comp.clone(),
2283 },
2284 None,
2285 ComponentHostOrigin::HandlerGenerated,
2286 context,
2287 )?;
2288
2289 context.writer.write("</")?;
2290 context.writer.write(comp)?;
2291 context.writer.write(">")?;
2292 context.writer.write("</webui-route>")?;
2293
2294 context.route_base = saved_route_base;
2295 context.route_children = saved_route_children;
2296 }
2297 }
2298
2299 for (idx, child) in children.iter().enumerate() {
2301 let is_matched = best.as_ref().is_some_and(|(bi, _)| *bi == idx);
2302 if !is_matched && !child.fragment_id.is_empty() {
2303 context.writer.write("<webui-route")?;
2304 context.writer.write(" path=\"")?;
2305 context.writer.write(&child.path)?;
2306 context.writer.write("\"")?;
2307 context.writer.write(" component=\"")?;
2308 context.writer.write(&child.fragment_id)?;
2309 context.writer.write("\"")?;
2310 if child.exact {
2311 context.writer.write(" exact")?;
2312 }
2313 route_renderer::write_route_navigation_attrs(context.writer, child)?;
2314 context
2315 .writer
2316 .write(" style=\"display:none\"></webui-route>")?;
2317 }
2318 }
2319
2320 Ok(())
2321 }
2322
2323 fn emit_css_module_importmap(
2334 &self,
2335 specifier: &str,
2336 css: &str,
2337 context: &mut WebUIProcessContext,
2338 ) -> Result<()> {
2339 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2340 context.writer.write(&tag)?;
2341 Ok(())
2342 }
2343
2344 fn emit_component_style_closure(
2346 &self,
2347 root: &str,
2348 install: StyleClosureInstall,
2349 context: &mut WebUIProcessContext,
2350 ) -> Result<()> {
2351 if context.protocol.style_closures.is_empty() {
2352 return Ok(());
2353 }
2354 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2355 match context.shadow_style_roots.last() {
2356 Some(shadow_root) => {
2357 if !shadow_root.static_closure_emitted {
2358 return Err(HandlerError::Invariant(
2359 "routed styles reached a ShadowRoot before its static style hook"
2360 .to_string(),
2361 ));
2362 }
2363 let root_name = context
2364 .component_index
2365 .iter()
2366 .find_map(|(name, index)| {
2367 (*index == shadow_root.component_index).then_some(name.as_str())
2368 })
2369 .ok_or_else(|| {
2370 HandlerError::Invariant(
2371 "active Shadow style root lost its protocol index".to_string(),
2372 )
2373 })?;
2374 Some(
2375 context
2376 .protocol
2377 .style_closures
2378 .get(root_name)
2379 .ok_or_else(|| {
2380 HandlerError::Invariant(format!(
2381 "component style closure metadata is missing Shadow root `{root_name}`"
2382 ))
2383 })?,
2384 )
2385 }
2386 None => None,
2387 }
2388 } else {
2389 None
2390 };
2391 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2392 HandlerError::Invariant(format!(
2393 "component style closure metadata is missing root `{root}`"
2394 ))
2395 })?;
2396 let is_document_tree = context.shadow_style_roots.is_empty();
2397 let strategy = context.css_strategy;
2398
2399 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2404 let mut emitted_resources = HashSet::with_capacity(unit_count);
2405
2406 for position in 0..unit_count {
2407 let unit = context
2408 .protocol
2409 .style_closure_unit(closure, &context.style_chunk_index, position)
2410 .ok_or_else(|| {
2411 HandlerError::Invariant(format!(
2412 "component style closure `{root}` references out-of-range unit {position}"
2413 ))
2414 })?;
2415 let (name, chunk) = (unit.name, unit.chunk);
2416 if !emitted_resources.insert(name) {
2417 continue;
2418 }
2419 let resource = unit.resource.ok_or_else(|| match chunk {
2420 Some(index) => HandlerError::Invariant(format!(
2421 "component style closure `{root}` references missing style chunk {index}"
2422 )),
2423 None => HandlerError::Invariant(format!(
2424 "component style closure `{root}` references missing resource `{name}`"
2425 )),
2426 })?;
2427 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2428 continue;
2429 }
2430 if let Some(static_closure) = shadow_static_closure {
2431 let resource_index = match chunk {
2432 Some(index) => {
2433 if static_closure.style_chunks.contains(&index) {
2434 continue;
2435 }
2436 index
2437 }
2438 None => {
2439 if static_closure.component_tags.iter().any(|tag| tag == name) {
2440 continue;
2441 }
2442 context.component_index.get(name).copied().ok_or_else(|| {
2443 HandlerError::Invariant(format!(
2444 "component style resource `{name}` is missing its protocol index"
2445 ))
2446 })?
2447 }
2448 };
2449 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2450 HandlerError::Invariant(
2451 "active Shadow style root disappeared during routed style delivery"
2452 .to_string(),
2453 )
2454 })?;
2455 if shadow_root.routed_resources.contains(&resource_index) {
2456 continue;
2457 }
2458 shadow_root.routed_resources.push(resource_index);
2459 }
2460
2461 match strategy {
2462 webui_protocol::CssStrategy::Link => {
2463 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2464 context
2465 .writer
2466 .write(&crate::html_encode::encode_safe(resource))?;
2467 context.writer.write("\" data-webui-resource=\"")?;
2468 context
2469 .writer
2470 .write(&crate::html_encode::encode_safe(name))?;
2471 context.writer.write("\" data-webui-strategy=\"link\">")?;
2472 }
2473 strategy => {
2474 context.writer.write("<style")?;
2475 if let Some(nonce) = context.nonce {
2476 context.writer.write(" nonce=\"")?;
2477 context
2478 .writer
2479 .write(&crate::html_encode::encode_safe(nonce))?;
2480 context.writer.write("\"")?;
2481 }
2482 context.writer.write(" data-webui-resource=\"")?;
2483 context
2484 .writer
2485 .write(&crate::html_encode::encode_safe(name))?;
2486 context.writer.write("\" data-webui-strategy=\"")?;
2487 context
2488 .writer
2489 .write(if strategy == webui_protocol::CssStrategy::Module {
2490 "module"
2491 } else {
2492 "style"
2493 })?;
2494 context.writer.write("\">")?;
2495 if context.style_resources_requiring_escape.contains(name) {
2496 crate::html_encode::write_style_text(context.writer, resource)?;
2497 } else {
2498 context.writer.write(resource)?;
2499 }
2500 context.writer.write("</style>")?;
2501 }
2502 }
2503 }
2504 Ok(())
2505 }
2506
2507 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2508 if context.route_chain.is_none() {
2509 let plan = crate::route_handler::collect_route_chain_plan(
2510 context.protocol,
2511 context.entry_id,
2512 context.request_path,
2513 context.route_index,
2514 );
2515 context.route_document_style_targets = plan.document_style_targets;
2516 context.route_chain = Some(plan.entries);
2517 }
2518 }
2519
2520 fn emit_active_route_styles(
2521 &self,
2522 preloaded: &mut Vec<u32>,
2523 context: &mut WebUIProcessContext,
2524 ) -> Result<()> {
2525 Self::ensure_request_route_chain(context);
2526 let Some(chain) = context.route_chain.take() else {
2527 return Err(route_style_plan_missing_error());
2528 };
2529 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2530 if chain.len() != document_targets.len() {
2531 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2532 context.route_document_style_targets = document_targets;
2533 context.route_chain = Some(chain);
2534 return Err(error);
2535 }
2536
2537 let result = (|| {
2538 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2539 if *targets_document {
2540 self.emit_component_style_closure(
2541 &entry.component,
2542 StyleClosureInstall::Static,
2543 context,
2544 )?;
2545 continue;
2546 }
2547
2548 if context.css_strategy != webui_protocol::CssStrategy::Link {
2549 continue;
2550 }
2551 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2552 }
2553 Ok(())
2554 })();
2555
2556 context.route_document_style_targets = document_targets;
2557 context.route_chain = Some(chain);
2558 result
2559 }
2560
2561 fn emit_component_style_preloads(
2562 &self,
2563 root: &str,
2564 preloaded: &mut Vec<u32>,
2565 context: &mut WebUIProcessContext,
2566 ) -> Result<()> {
2567 let Some(closure) = context.protocol.style_closures.get(root) else {
2568 return Ok(());
2569 };
2570 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2572 for position in 0..unit_count {
2573 let Some(unit) =
2574 context
2575 .protocol
2576 .style_closure_unit(closure, &context.style_chunk_index, position)
2577 else {
2578 continue;
2579 };
2580 let Some(href) = unit.resource else {
2581 continue;
2582 };
2583 let name = unit.name;
2584 if context.document_style_resources.contains(name) {
2585 continue;
2586 }
2587 let Some(&resource_index) = context.style_resource_index.get(name) else {
2588 continue;
2589 };
2590 if preloaded.contains(&resource_index) {
2591 continue;
2592 }
2593 context
2594 .writer
2595 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2596 context
2597 .writer
2598 .write(&crate::html_encode::encode_safe(href))?;
2599 context.writer.write("\">")?;
2600 preloaded.push(resource_index);
2601 }
2602 Ok(())
2603 }
2604
2605 fn emit_reachable_shadow_preloads(
2606 &self,
2607 preloaded: &mut Vec<u32>,
2608 context: &mut WebUIProcessContext,
2609 ) -> Result<()> {
2610 if context.css_strategy != webui_protocol::CssStrategy::Link {
2611 return Ok(());
2612 }
2613 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2614 crate::route_handler::collect_reachable_component_order_for_request(
2615 context.protocol,
2616 context.entry_id,
2617 context.request_path,
2618 context.route_index,
2619 )
2620 });
2621 let result = (|| {
2622 for component in &reachable {
2623 if context.protocol.component_uses_shadow_dom(component) {
2624 self.emit_component_style_preloads(component, preloaded, context)?;
2625 }
2626 }
2627 Ok(())
2628 })();
2629 context.reachable_components = Some(reachable);
2630 result
2631 }
2632
2633 fn process_shadow_style_signal(
2634 &self,
2635 root: &str,
2636 context: &mut WebUIProcessContext,
2637 ) -> Result<()> {
2638 let Some(root_index) = context.component_index.get(root).copied() else {
2639 return Err(HandlerError::Invariant(format!(
2640 "Shadow style hook `{root}` references an unknown component"
2641 )));
2642 };
2643 if context
2644 .shadow_style_roots
2645 .last()
2646 .map(|shadow_root| shadow_root.component_index)
2647 != Some(root_index)
2648 {
2649 return Err(HandlerError::Invariant(format!(
2650 "Shadow style hook `{root}` does not match the active component root"
2651 )));
2652 }
2653 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2654 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2655 HandlerError::Invariant(
2656 "active Shadow style root disappeared after its static style hook".to_string(),
2657 )
2658 })?;
2659 shadow_root.static_closure_emitted = true;
2660 Ok(())
2661 }
2662
2663 fn emit_css_module(
2672 &self,
2673 component: &webui_protocol::WebUIFragmentComponent,
2674 context: &mut WebUIProcessContext,
2675 ) -> Result<()> {
2676 if context.css_strategy != webui_protocol::CssStrategy::Module {
2677 return Ok(());
2678 }
2679 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2680 streaming_template_already_sent(
2681 streaming,
2682 context.component_index,
2683 &component.fragment_id,
2684 )
2685 });
2686 if !metadata_already_streamed
2687 && !context.rendered_components.contains(&component.fragment_id)
2688 {
2689 if let Some(css) = context
2690 .protocol
2691 .components
2692 .get(&component.fragment_id)
2693 .map(|c| c.css.as_str())
2694 .filter(|s| !s.is_empty())
2695 {
2696 self.emit_css_module_importmap(&component.fragment_id, css, context)?;
2697 }
2698 }
2699 Ok(())
2700 }
2701
2702 fn process_route(
2704 &self,
2705 route_frag: &webui_protocol::WebUiFragmentRoute,
2706 best_route: &Option<(String, route_matcher::RouteMatch)>,
2707 context: &mut WebUIProcessContext,
2708 ) -> Result<()> {
2709 let is_matched = best_route
2710 .as_ref()
2711 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2712
2713 context.writer.write("<webui-route")?;
2714 context.writer.write(" path=\"")?;
2715 context.writer.write(&route_frag.path)?;
2716 context.writer.write("\"")?;
2717 if !route_frag.fragment_id.is_empty() {
2718 context.writer.write(" component=\"")?;
2719 context.writer.write(&route_frag.fragment_id)?;
2720 context.writer.write("\"")?;
2721 }
2722 if route_frag.exact {
2723 context.writer.write(" exact")?;
2724 }
2725 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2726
2727 if is_matched {
2728 let ri = context.route_chain_index;
2730 context.route_chain_index += 1;
2731 context.writer.write(" data-ri=\"")?;
2732 write_usize(context.writer, ri)?;
2733 context.writer.write("\" active>")?;
2734
2735 let saved_route_base = context.route_base.clone();
2736 let saved_route_children = std::mem::take(&mut context.route_children);
2737 if let Some((_, ref rm)) = best_route {
2738 context.route_base = Cow::Owned(route_matcher::compute_route_base(
2739 context.request_path,
2740 rm.consumed_segments,
2741 ));
2742 }
2743 context.route_children = route_frag.children.clone();
2744
2745 if !route_frag.content_fragment_id.is_empty() {
2746 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2747 }
2748
2749 if !route_frag.fragment_id.is_empty() {
2750 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2751 self.emit_component_style_closure(
2752 &route_frag.fragment_id,
2753 StyleClosureInstall::Routed,
2754 context,
2755 )?;
2756 }
2757 let comp = webui_protocol::WebUIFragmentComponent {
2758 fragment_id: route_frag.fragment_id.clone(),
2759 };
2760
2761 context.writer.write("<")?;
2762 context.writer.write(&route_frag.fragment_id)?;
2763 if let Some(p) = &context.plugin {
2764 p.write_route_component_state(context.state, context.writer)?;
2765 }
2766 write_interaction_marker(&route_frag.fragment_id, context)?;
2767 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2768 context.writer.write(">")?;
2769
2770 self.process_component(
2771 &comp,
2772 None,
2773 ComponentHostOrigin::HandlerGenerated,
2774 context,
2775 )?;
2776
2777 context.writer.write("</")?;
2778 context.writer.write(&route_frag.fragment_id)?;
2779 context.writer.write(">")?;
2780 }
2781 context.route_base = saved_route_base;
2782 context.route_children = saved_route_children;
2783 } else {
2784 context.writer.write(" style=\"display:none\">")?;
2785 }
2786
2787 context.writer.write("</webui-route>")?;
2788 Ok(())
2789 }
2790
2791 fn process_component(
2793 &self,
2794 component: &webui_protocol::WebUIFragmentComponent,
2795 target: Option<usize>,
2796 origin: ComponentHostOrigin,
2797 context: &mut WebUIProcessContext,
2798 ) -> Result<()> {
2799 if context.streaming.is_some() {
2800 consume_streaming_component_root(&component.fragment_id, origin, context)?;
2801 record_checkpoint_tag(context, &component.fragment_id);
2804 }
2805
2806 if !context.rendered_components.contains(&component.fragment_id) {
2812 self.emit_css_module(component, context)?;
2813 context
2814 .rendered_components
2815 .insert(component.fragment_id.clone());
2816 }
2817
2818 let owns_css_tree = Self::component_owns_css_tree(&component.fragment_id, context.protocol);
2819 if owns_css_tree {
2820 Self::push_shadow_style_root(&component.fragment_id, context)?;
2821 }
2822
2823 let saved_local_vars = std::mem::take(&mut context.local_vars);
2825 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2826 let saved_component_attrs = std::mem::replace(
2830 &mut context.component_attrs,
2831 take_scope_map(&mut context.scope_pool),
2832 );
2833 let saved_component_borrowed_attrs = std::mem::replace(
2834 &mut context.component_borrowed_attrs,
2835 take_borrowed_scope(&mut context.borrowed_scope_pool),
2836 );
2837 context.local_vars = saved_component_attrs;
2838 context.local_borrowed_vars = saved_component_borrowed_attrs;
2839 context.collecting_component_attrs = false;
2840 let saved_loop_scope = context.visible_loop_scope;
2841 context.visible_loop_scope = VisibleLoopScope {
2842 start: context.loop_vars.len(),
2843 end: context.loop_vars.len(),
2844 };
2845
2846 if let Some(p) = &mut context.plugin {
2847 p.push_scope();
2848 }
2849
2850 let render_result = self.process_fragment_target(target, &component.fragment_id, context);
2851 context.visible_loop_scope = saved_loop_scope;
2852
2853 if owns_css_tree {
2854 Self::pop_shadow_style_root(&component.fragment_id, context)?;
2855 }
2856 render_result?;
2857
2858 if let Some(p) = &mut context.plugin {
2859 p.pop_scope();
2860 }
2861
2862 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
2865 recycle_scope_map(&mut context.scope_pool, used_locals);
2866 let used_borrowed_locals =
2867 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
2868 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
2869 context.component_attrs.clear();
2872 context.component_borrowed_attrs.clear();
2873
2874 Ok(())
2875 }
2876
2877 #[inline]
2878 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
2879 !protocol.style_closures.is_empty()
2880 && protocol
2881 .components
2882 .get(component)
2883 .is_some_and(|data| data.uses_shadow_dom)
2884 }
2885
2886 pub(crate) fn push_shadow_style_root(
2887 component: &str,
2888 context: &mut WebUIProcessContext,
2889 ) -> Result<()> {
2890 if !context.protocol.style_closures.contains_key(component) {
2891 return Err(HandlerError::Invariant(format!(
2892 "component style closure metadata is missing Shadow root `{component}`"
2893 )));
2894 }
2895 let root_index = context
2896 .component_index
2897 .get(component)
2898 .copied()
2899 .ok_or_else(|| {
2900 HandlerError::Invariant(format!(
2901 "Shadow component `{component}` is missing its protocol index"
2902 ))
2903 })?;
2904 context.shadow_style_roots.push(ShadowStyleRoot {
2905 component_index: root_index,
2906 static_closure_emitted: false,
2907 routed_resources: Vec::new(),
2908 });
2909 Ok(())
2910 }
2911
2912 pub(crate) fn pop_shadow_style_root(
2913 component: &str,
2914 context: &mut WebUIProcessContext,
2915 ) -> Result<()> {
2916 context.shadow_style_roots.pop().ok_or_else(|| {
2917 HandlerError::Invariant(format!(
2918 "Shadow component `{component}` lost its active style root"
2919 ))
2920 })?;
2921 Ok(())
2922 }
2923
2924 fn resolve_value_owned(
2926 &self,
2927 path: &str,
2928 context: &WebUIProcessContext<'_, '_, '_>,
2929 ) -> Option<Value> {
2930 resolve_value_from_sources(
2931 path,
2932 &context.loop_vars,
2933 context.visible_loop_scope,
2934 LocalValueSources {
2935 owned: &context.local_vars,
2936 borrowed: &context.local_borrowed_vars,
2937 },
2938 context.state,
2939 )
2940 .map(Cow::into_owned)
2941 }
2942
2943 fn evaluate_condition(
2950 &self,
2951 condition: &webui_protocol::ConditionExpr,
2952 context: &WebUIProcessContext,
2953 ) -> Result<bool> {
2954 let loop_vars = &context.loop_vars;
2955 let visible_loop_scope = context.visible_loop_scope;
2956 let local_values = LocalValueSources {
2957 owned: &context.local_vars,
2958 borrowed: &context.local_borrowed_vars,
2959 };
2960 let state = context.state;
2961 match evaluate_with_resolver(condition, |path| {
2962 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
2963 }) {
2964 Ok(result) => Ok(result),
2965 Err(ExpressionError::MissingValue(_)) => Ok(false),
2966 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
2967 }
2968 }
2969
2970 fn process_for_loop<'protocol, 'state>(
2976 &self,
2977 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2978 target: Option<usize>,
2979 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
2980 ) -> Result<()> {
2981 if let Some(items) = resolve_borrowed_collection(
2982 &for_loop.collection,
2983 &context.loop_vars,
2984 context.visible_loop_scope,
2985 LocalValueSources {
2986 owned: &context.local_vars,
2987 borrowed: &context.local_borrowed_vars,
2988 },
2989 context.state,
2990 ) {
2991 return self.process_borrowed_for_loop(for_loop, target, items, context);
2992 }
2993 self.process_owned_for_loop(for_loop, target, context)
2994 }
2995
2996 fn process_borrowed_for_loop<'protocol, 'state>(
2997 &self,
2998 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
2999 target: Option<usize>,
3000 items: &'state [Value],
3001 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3002 ) -> Result<()> {
3003 if let Some(plugin) = &mut context.plugin {
3004 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3005 }
3006
3007 let item_name = for_loop.item.as_str();
3008 let saved_value = context.local_vars.remove(item_name);
3009 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3010 let saved_scope = context.visible_loop_scope;
3011 for (index, item) in items.iter().enumerate() {
3012 if let Some(plugin) = &mut context.plugin {
3013 plugin.on_repeat_item_start(index, context.writer)?;
3014 plugin.push_scope();
3015 }
3016
3017 context.loop_vars.push(LoopBinding {
3018 name: item_name,
3019 value: item,
3020 });
3021 context.visible_loop_scope.end = context.loop_vars.len();
3022 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3023 context.loop_vars.pop();
3024 context.visible_loop_scope = saved_scope;
3025
3026 if let Some(plugin) = &mut context.plugin {
3027 plugin.pop_scope();
3028 plugin.on_repeat_item_end(index, context.writer)?;
3029 }
3030 }
3031 if let Some(value) = saved_value {
3032 context.local_vars.insert(item_name.to_string(), value);
3033 }
3034 if let Some(value) = saved_borrowed_value {
3035 context.local_borrowed_vars.insert(item_name, value);
3036 }
3037
3038 if let Some(plugin) = &mut context.plugin {
3039 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3040 }
3041 Ok(())
3042 }
3043
3044 fn process_owned_for_loop<'protocol, 'state>(
3045 &self,
3046 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3047 target: Option<usize>,
3048 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3049 ) -> Result<()> {
3050 let collection_name = &for_loop.collection;
3051
3052 let items = match self.resolve_value_owned(collection_name, context) {
3055 Some(Value::Array(arr)) => arr,
3056 Some(_) => {
3057 return Err(HandlerError::TypeError(format!(
3058 "Collection '{}' is not an array",
3059 collection_name
3060 )))
3061 }
3062 None => Vec::new(),
3063 };
3064
3065 if let Some(p) = &mut context.plugin {
3066 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3067 }
3068
3069 let item_name = for_loop.item.as_str();
3077 let saved_value = context.local_vars.remove(item_name);
3078 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3079 if !items.is_empty() {
3083 context
3084 .local_vars
3085 .insert(item_name.to_string(), Value::Null);
3086 }
3087 for (i, item) in items.into_iter().enumerate() {
3088 if let Some(p) = &mut context.plugin {
3089 p.on_repeat_item_start(i, context.writer)?;
3090 p.push_scope();
3091 }
3092
3093 if let Some(slot) = context.local_vars.get_mut(item_name) {
3095 *slot = item;
3096 }
3097 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3098
3099 if let Some(p) = &mut context.plugin {
3100 p.pop_scope();
3101 p.on_repeat_item_end(i, context.writer)?;
3102 }
3103 }
3104 match saved_value {
3106 Some(value) => {
3107 context.local_vars.insert(item_name.to_string(), value);
3108 }
3109 None => {
3110 context.local_vars.remove(item_name);
3111 }
3112 }
3113 if let Some(value) = saved_borrowed_value {
3114 context.local_borrowed_vars.insert(item_name, value);
3115 }
3116
3117 if let Some(p) = &mut context.plugin {
3118 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3119 }
3120
3121 Ok(())
3122 }
3123
3124 #[inline]
3130 fn process_signal<'data>(
3131 &self,
3132 signal: &'data webui_protocol::WebUIFragmentSignal,
3133 context: &mut WebUIProcessContext<'data, '_, '_>,
3134 ) -> Result<()> {
3135 if signal.raw {
3136 self.process_raw_signal(signal, context)
3137 } else {
3138 self.process_state_signal(signal, false, context)
3139 }
3140 }
3141
3142 #[inline(never)]
3143 fn process_raw_signal<'data>(
3144 &self,
3145 signal: &'data webui_protocol::WebUIFragmentSignal,
3146 context: &mut WebUIProcessContext<'data, '_, '_>,
3147 ) -> Result<()> {
3148 let Some(structural_value) = structural_signal_value(signal) else {
3149 return self.process_state_signal(signal, !signal.raw_text_context, context);
3150 };
3151
3152 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3153 return self.process_shadow_style_signal(root, context);
3154 }
3155
3156 if context.streaming.is_some()
3157 && self.process_streaming_signal(structural_value, context)?
3158 {
3159 return Ok(());
3160 }
3161
3162 if structural_value == "head_end" && !context.head_end_emitted {
3166 context.head_end_emitted = true;
3167 if let Some(nonce) = context.nonce {
3168 context
3169 .writer
3170 .write("<meta name=\"webui-nonce\" content=\"")?;
3171 context
3172 .writer
3173 .write(&crate::html_encode::encode_safe(nonce))?;
3174 context.writer.write("\">")?;
3175 }
3176
3177 if !context.protocol.component_render_css.is_empty() {
3180 context.writer.write("<style data-webui-render-policy")?;
3181 if let Some(nonce) = context.nonce {
3182 context.writer.write(" nonce=\"")?;
3183 context
3184 .writer
3185 .write(&crate::html_encode::encode_safe(nonce))?;
3186 context.writer.write("\"")?;
3187 }
3188 context.writer.write(">")?;
3189 context
3190 .writer
3191 .write(&context.protocol.component_render_css)?;
3192 context.writer.write("</style>")?;
3193 }
3194
3195 if !context.protocol.style_closures.is_empty() {
3196 let entry_id = context.entry_id;
3197 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3198 let mut preloaded = Vec::new();
3199 self.emit_active_route_styles(&mut preloaded, context)?;
3200 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3201 }
3202
3203 if !context.protocol.module_preloads.is_empty() {
3215 for href in &context.protocol.module_preloads {
3216 context
3217 .writer
3218 .write("<link rel=\"modulepreload\" href=\"")?;
3219 context.writer.write(href)?;
3220 context.writer.write("\">")?;
3221 }
3222 }
3223
3224 write_component_asset_styles(context)?;
3225
3226 if let Some(html) = context.head_inject {
3232 context.writer.write(html)?;
3233 }
3234
3235 if let Some(html) = context.state_inject.head_end {
3239 context.writer.write(html)?;
3240 }
3241 }
3242
3243 if structural_value == "body_start" && !context.body_start_emitted {
3246 context.body_start_emitted = true;
3247 write_component_asset_styles(context)?;
3248 if let Some(html) = context.state_inject.body_start {
3249 context.writer.write(html)?;
3250 }
3251 }
3252
3253 if structural_value == "body_end" && !context.body_end_emitted {
3258 context.body_end_emitted = true;
3259 if context.plugin.is_some() {
3260 let reachable = context
3268 .reachable_components
3269 .take()
3270 .unwrap_or_else(|| {
3271 crate::route_handler::collect_reachable_component_order_for_request(
3272 context.protocol,
3273 context.entry_id,
3274 context.request_path,
3275 context.route_index,
3276 )
3277 })
3278 .into_iter()
3279 .collect::<HashSet<_>>();
3280 let state_selection =
3281 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3282
3283 if context.css_strategy == webui_protocol::CssStrategy::Module {
3287 for name in &reachable {
3288 if !context.rendered_components.contains(name) {
3289 if let Some(css) = context
3290 .protocol
3291 .components
3292 .get(name)
3293 .map(|c| c.css.as_str())
3294 .filter(|s| !s.is_empty())
3295 {
3296 self.emit_css_module_importmap(name, css, context)?;
3297 }
3298 }
3299 }
3300 }
3301
3302 let template_payloads = context
3306 .plugin
3307 .as_ref()
3308 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3309
3310 if template_payloads.is_none() {
3311 if let Some(ref p) = context.plugin {
3313 p.emit_templates(
3314 context.protocol,
3315 &reachable,
3316 context.nonce,
3317 context.writer,
3318 )?;
3319 }
3320 }
3321
3322 let inventory_hex = crate::route_handler::encode_component_inventory(
3324 &context.rendered_components,
3325 context.component_index,
3326 );
3327
3328 Self::ensure_request_route_chain(context);
3330 let chain = context.route_chain.as_deref().ok_or_else(|| {
3331 HandlerError::Invariant(
3332 "request route chain disappeared after collection".to_string(),
3333 )
3334 })?;
3335 let chain_json: Vec<Value> = chain
3336 .iter()
3337 .map(crate::route_handler::RouteChainEntry::to_json)
3338 .collect();
3339
3340 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3342 let mut css_hrefs: Vec<&str> = Vec::new();
3343 if is_link {
3344 for name in &reachable {
3345 if let Some(href) = context
3346 .protocol
3347 .components
3348 .get(name)
3349 .map(|c| c.css_href.as_str())
3350 .filter(|h| !h.is_empty())
3351 {
3352 css_hrefs.push(href);
3353 }
3354 }
3355 }
3356
3357 let mut style_specs: Vec<&str> = Vec::new();
3359 if context.css_strategy == webui_protocol::CssStrategy::Module {
3360 for name in &reachable {
3361 if context
3362 .protocol
3363 .components
3364 .get(name)
3365 .map(|c| !c.css.is_empty())
3366 .unwrap_or(false)
3367 {
3368 style_specs.push(name);
3369 }
3370 }
3371 }
3372
3373 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3374 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3375 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3376 style_roots.push(context.entry_id);
3377 for entry in chain {
3378 style_roots.push(entry.component.as_str());
3379 if !entry.pending_component.is_empty() {
3380 style_roots.push(entry.pending_component.as_str());
3381 }
3382 if !entry.error_component.is_empty() {
3383 style_roots.push(entry.error_component.as_str());
3384 }
3385 }
3386 style_roots.extend(reachable.iter().map(String::as_str));
3387 let component_styles =
3388 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3389 write_webui_data_block(
3390 context.writer,
3391 &mut context.json_scratch,
3392 WebUiBootstrap {
3393 declaration_id: None,
3394 enclosing_span_instance_id: None,
3395 state: context.state,
3396 state_selection,
3397 chain: &chain_json,
3398 inventory: &inventory_hex,
3399 nonce: context.nonce,
3400 css_hrefs: &css_hrefs,
3401 style_specs: &style_specs,
3402 component_styles: &component_styles,
3403 templates: payloads,
3404 },
3405 )?;
3406
3407 if let Some(ref plugin) = context.plugin {
3411 plugin.emit_bootstrap_extension(
3412 BootstrapExtensionContext {
3413 protocol: context.protocol,
3414 components: &reachable,
3415 payloads,
3416 nonce: context.nonce,
3417 },
3418 context.writer,
3419 )?;
3420 }
3421 }
3422
3423 if let Some(html) = context.body_inject {
3430 context.writer.write(html)?;
3431 }
3432
3433 if let Some(html) = context.state_inject.body_end {
3436 context.writer.write(html)?;
3437 }
3438 }
3439
3440 Ok(())
3444 }
3445
3446 #[inline]
3458 fn process_state_signal(
3459 &self,
3460 signal: &webui_protocol::WebUIFragmentSignal,
3461 owns_html_range: bool,
3462 context: &mut WebUIProcessContext,
3463 ) -> Result<()> {
3464 if let Some(p) = &mut context.plugin {
3465 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3466 }
3467
3468 if let Some(value) = resolve_value_from_sources(
3469 &signal.value,
3470 &context.loop_vars,
3471 context.visible_loop_scope,
3472 LocalValueSources {
3473 owned: &context.local_vars,
3474 borrowed: &context.local_borrowed_vars,
3475 },
3476 context.state,
3477 ) {
3478 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3479 }
3480
3481 if let Some(p) = &mut context.plugin {
3482 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3483 }
3484 Ok(())
3485 }
3486
3487 fn write_signal_value(
3496 &self,
3497 value: &Value,
3498 raw: bool,
3499 writer: &mut dyn ResponseWriter,
3500 ) -> Result<()> {
3501 if raw {
3502 match value {
3503 Value::String(s) => writer.write(s),
3504 _ => writer.write(&value.to_string()),
3505 }
3506 } else {
3507 match value {
3508 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3509 _ => {
3510 let s = value.to_string();
3511 writer.write(&crate::html_encode::encode_safe(&s))
3512 }
3513 }
3514 }
3515 }
3516
3517 fn process_if(
3519 &self,
3520 if_cond: &webui_protocol::WebUIFragmentIf,
3521 target: Option<usize>,
3522 context: &mut WebUIProcessContext,
3523 ) -> Result<()> {
3524 let condition = if_cond
3525 .condition
3526 .as_ref()
3527 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3528 let condition_met = self.evaluate_condition(condition, context)?;
3529
3530 if let Some(p) = &mut context.plugin {
3531 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3532 }
3533
3534 if condition_met {
3535 if let Some(p) = &mut context.plugin {
3536 p.push_scope();
3537 }
3538
3539 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3540
3541 if let Some(p) = &mut context.plugin {
3542 p.pop_scope();
3543 }
3544 }
3545
3546 if let Some(p) = &mut context.plugin {
3547 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3548 }
3549
3550 Ok(())
3551 }
3552
3553 fn process_attribute<'protocol, 'state>(
3559 &self,
3560 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3561 template_target: Option<usize>,
3562 component_name: Option<&'protocol str>,
3563 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3564 ) -> Result<()> {
3565 if attr.attr_start {
3568 context.component_attrs.clear();
3569 context.component_borrowed_attrs.clear();
3570 context.collecting_component_attrs = true;
3571 }
3572
3573 if let Some(condition) = &attr.condition_tree {
3575 let condition_met = self.evaluate_condition(condition, context)?;
3576
3577 if context.collecting_component_attrs && !attr.attr_skip {
3578 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3579 context.component_borrowed_attrs.remove(name);
3580 context
3581 .component_attrs
3582 .insert(name.to_owned(), Value::Bool(condition_met));
3583 }
3584
3585 if condition_met {
3586 context.writer.write(" ")?;
3587 context.writer.write(&attr.name)?;
3588 }
3589 return Ok(());
3590 }
3591
3592 if !attr.template.is_empty() {
3594 let raw_value =
3595 self.render_template_attr_value(&attr.template, template_target, context)?;
3596 let escaped = crate::html_encode::encode_safe(&raw_value);
3597 write_attr(context.writer, &attr.name, &escaped)?;
3598
3599 if context.collecting_component_attrs && !attr.attr_skip {
3600 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3601 context.component_borrowed_attrs.remove(name);
3602 context
3603 .component_attrs
3604 .insert(name.to_owned(), Value::String(raw_value));
3605 }
3606 return Ok(());
3607 }
3608
3609 if !attr.value.is_empty() {
3611 if attr.raw_value {
3612 write_attr(context.writer, &attr.name, &attr.value)?;
3614 if context.collecting_component_attrs && !attr.attr_skip {
3615 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3616 context.component_borrowed_attrs.remove(name);
3617 context
3618 .component_attrs
3619 .insert(name.to_owned(), Value::String(attr.value.clone()));
3620 }
3621 } else if attr.complex {
3622 if context.collecting_component_attrs && !attr.attr_skip {
3624 let state_backed_value = if context.streaming.is_none() {
3625 resolve_state_backed_value(
3626 &attr.value,
3627 &context.loop_vars,
3628 context.visible_loop_scope,
3629 LocalValueSources {
3630 owned: &context.local_vars,
3631 borrowed: &context.local_borrowed_vars,
3632 },
3633 context.state,
3634 )
3635 } else {
3636 None
3637 };
3638 if let Some(value) = state_backed_value {
3639 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3640 context.component_attrs.remove(name);
3641 context.component_borrowed_attrs.insert(name, value);
3642 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3643 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3644 context.component_borrowed_attrs.remove(name);
3645 context.component_attrs.insert(name.to_owned(), value);
3646 }
3647 }
3648 } else {
3649 let state_backed_value = if context.collecting_component_attrs
3654 && !attr.attr_skip
3655 && context.streaming.is_none()
3656 {
3657 resolve_state_backed_value(
3658 &attr.value,
3659 &context.loop_vars,
3660 context.visible_loop_scope,
3661 LocalValueSources {
3662 owned: &context.local_vars,
3663 borrowed: &context.local_borrowed_vars,
3664 },
3665 context.state,
3666 )
3667 } else {
3668 None
3669 };
3670 let value = match state_backed_value {
3673 Some(value) => Some(Cow::Borrowed(value)),
3674 None => resolve_value_from_sources(
3675 &attr.value,
3676 &context.loop_vars,
3677 context.visible_loop_scope,
3678 LocalValueSources {
3679 owned: &context.local_vars,
3680 borrowed: &context.local_borrowed_vars,
3681 },
3682 context.state,
3683 ),
3684 };
3685 match value.as_deref() {
3688 Some(Value::String(s)) => {
3689 write_attr(
3690 context.writer,
3691 &attr.name,
3692 &crate::html_encode::encode_safe(s),
3693 )?;
3694 }
3695 Some(Value::Null) | None => {
3696 write_attr(context.writer, &attr.name, "")?;
3697 }
3698 Some(other) => {
3699 let s = other.to_string();
3700 write_attr(
3701 context.writer,
3702 &attr.name,
3703 &crate::html_encode::encode_safe(&s),
3704 )?;
3705 }
3706 }
3707
3708 if context.collecting_component_attrs && !attr.attr_skip {
3709 if let Some(borrowed) = state_backed_value {
3710 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3711 context.component_attrs.remove(name);
3712 context.component_borrowed_attrs.insert(name, borrowed);
3713 } else {
3714 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3715 context.component_borrowed_attrs.remove(name);
3716 context.component_attrs.insert(
3717 name.to_owned(),
3718 value
3719 .map(Cow::into_owned)
3720 .unwrap_or(Value::String(String::new())),
3721 );
3722 }
3723 }
3724 }
3725 }
3726
3727 Ok(())
3728 }
3729
3730 fn render_template_attr_value(
3732 &self,
3733 template_id: &str,
3734 target: Option<usize>,
3735 context: &WebUIProcessContext,
3736 ) -> Result<String> {
3737 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3738 return Err(HandlerError::MissingFragment(template_id.to_string()));
3739 };
3740 let fragments = context
3741 .render_fragments
3742 .list(index)
3743 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3744 let mut raw_value = String::new();
3745 for frag in fragments.fragments {
3746 match frag.fragment.as_ref() {
3747 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3748 Some(Fragment::Signal(signal)) => {
3749 if let Some(value) = resolve_value_from_sources(
3750 &signal.value,
3751 &context.loop_vars,
3752 context.visible_loop_scope,
3753 LocalValueSources {
3754 owned: &context.local_vars,
3755 borrowed: &context.local_borrowed_vars,
3756 },
3757 context.state,
3758 ) {
3759 match value.as_ref() {
3760 Value::String(s) => raw_value.push_str(s),
3761 _ => raw_value.push_str(&value.to_string()),
3762 }
3763 }
3764 }
3765 _ => {}
3766 }
3767 }
3768 Ok(raw_value)
3769 }
3770
3771 pub fn render<'a>(
3773 &self,
3774 protocol: &'a Protocol,
3775 state: &'a Value,
3776 options: &RenderOptions<'a>,
3777 writer: &'a mut dyn ResponseWriter,
3778 ) -> Result<()> {
3779 protocol.ensure_style_metadata()?;
3780 let document = protocol.protocol();
3781 let render_fragments = protocol.render_fragments().resolve(document);
3782 let Some(entry_index) = render_fragments.index(options.entry_id) else {
3783 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3784 };
3785 let entry = render_fragments
3786 .list(entry_index)
3787 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3788 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3789 let has_document_head_boundary = !entry_owns_css_tree
3790 && !document.style_closures.is_empty()
3791 && entry.fragments.iter().any(|fragment| {
3792 matches!(
3793 fragment.fragment.as_ref(),
3794 Some(Fragment::Signal(signal))
3795 if structural_signal_value(signal) == Some("head_end")
3796 )
3797 });
3798 let doctype_split = (!entry_owns_css_tree
3799 && !has_document_head_boundary
3800 && !document.style_closures.is_empty())
3801 .then(|| {
3802 let Some(Fragment::Raw(raw)) = entry
3803 .fragments
3804 .first()
3805 .and_then(|fragment| fragment.fragment.as_ref())
3806 else {
3807 return None;
3808 };
3809 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3810 })
3811 .flatten();
3812 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3813 let mut context = WebUIProcessContext {
3814 protocol: document,
3815 render_fragments,
3816 component_asset_style_manifest,
3817 component_asset_style_links: protocol.component_asset_style_links(),
3818 state,
3819 writer,
3820 local_vars: HashMap::new(),
3821 local_borrowed_vars: BorrowedScope::default(),
3822 loop_vars: Vec::new(),
3823 visible_loop_scope: VisibleLoopScope::EMPTY,
3824 component_attrs: HashMap::new(),
3825 component_borrowed_attrs: BorrowedScope::default(),
3826 collecting_component_attrs: false,
3827 request_path: options.request_path,
3828 route_base: Cow::Borrowed("/"),
3829 rendered_components: HashSet::new(),
3830 plugin: self.plugin_factory.map(|f| f()),
3831 route_children: Vec::new(),
3832 entry_id: options.entry_id,
3833 nonce: options.nonce.filter(|s| !s.is_empty()),
3836 head_inject: options.head_inject.filter(|s| !s.is_empty()),
3837 body_inject: options.body_inject.filter(|s| !s.is_empty()),
3838 state_inject: StateInject::resolve(state),
3839 head_end_emitted: false,
3840 body_start_emitted: false,
3841 component_asset_styles_emitted: false,
3842 component_index: protocol.component_index(),
3843 style_resource_index: protocol.style_resource_index(),
3844 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
3845 style_chunk_index: protocol.protocol().style_chunk_index(),
3846 css_strategy: protocol.css_strategy(),
3847 body_end_emitted: false,
3848 route_index: protocol.route_index(),
3849 route_chain_index: 0,
3850 route_chain: None,
3851 route_document_style_targets: Vec::new(),
3852 reachable_components: None,
3853 streaming: None,
3854 json_scratch: Vec::new(),
3855 scope_pool: Vec::new(),
3856 document_style_resources: HashSet::new(),
3857 shadow_style_roots: Vec::new(),
3858 borrowed_scope_pool: Vec::new(),
3859 };
3860
3861 if entry_owns_css_tree {
3862 Self::push_shadow_style_root(options.entry_id, &mut context)?;
3863 }
3864
3865 let render_result = if let Some((first_raw, split)) = doctype_split {
3866 context.writer.write(&first_raw[..split])?;
3867 self.emit_component_style_closure(
3868 options.entry_id,
3869 StyleClosureInstall::Static,
3870 &mut context,
3871 )?;
3872 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3873 context.writer.write(&first_raw[split..])?;
3874 self.process_fragment_from(entry, 1, &mut context)
3875 } else {
3876 if !entry_owns_css_tree
3877 && !has_document_head_boundary
3878 && !document.style_closures.is_empty()
3879 {
3880 self.emit_component_style_closure(
3881 options.entry_id,
3882 StyleClosureInstall::Static,
3883 &mut context,
3884 )?;
3885 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
3886 }
3887 self.process_fragment(entry, &mut context)
3888 };
3889
3890 if entry_owns_css_tree {
3891 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
3892 }
3893 render_result?;
3894 writer.end()?;
3895
3896 Ok(())
3897 }
3898}
3899
3900impl Default for WebUIHandler {
3901 fn default() -> Self {
3902 Self::new()
3903 }
3904}
3905
3906fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
3908 writer.write_attribute(name, value)
3909}
3910
3911#[cfg(test)]
3912fn handle(
3913 protocol: &WebUIProtocol,
3914 state: &Value,
3915 options: &RenderOptions<'_>,
3916 writer: &mut dyn ResponseWriter,
3917) -> Result<()> {
3918 let handler = WebUIHandler::new();
3919 handler.handle(protocol, state, options, writer)
3920}
3921
3922#[cfg(test)]
3923mod tests {
3924 use super::*;
3925 use crate::streaming::STREAMING_MARKER;
3926 use std::cell::RefCell;
3927 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
3928 use webui_protocol::{
3929 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
3930 WebUIFragmentAttribute, WebUiFragmentRoute,
3931 };
3932 use webui_test_utils::test_json;
3933
3934 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
3935 WebUIFragment::signal(
3936 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
3937 true,
3938 )
3939 }
3940
3941 #[test]
3942 fn borrowed_collection_reuses_state_array() {
3943 let state = test_json!({
3944 "items": [
3945 {"name": "first"},
3946 {"name": "second"}
3947 ]
3948 });
3949 let local_vars = HashMap::new();
3950 let local_borrowed_vars = BorrowedScope::default();
3951 let loop_vars = Vec::new();
3952 let items = resolve_borrowed_collection(
3953 "items",
3954 &loop_vars,
3955 VisibleLoopScope::EMPTY,
3956 LocalValueSources {
3957 owned: &local_vars,
3958 borrowed: &local_borrowed_vars,
3959 },
3960 &state,
3961 )
3962 .unwrap_or_else(|| panic!("state array should be borrowed"));
3963 let state_items = state["items"]
3964 .as_array()
3965 .unwrap_or_else(|| panic!("fixture items should be an array"));
3966 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
3967 }
3968
3969 #[test]
3970 fn owned_local_collection_keeps_precedence() {
3971 let state = test_json!({"items": [{"name": "global"}]});
3972 let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
3973 let local_borrowed_vars = BorrowedScope::default();
3974 assert!(resolve_borrowed_collection(
3975 "items",
3976 &[],
3977 VisibleLoopScope::EMPTY,
3978 LocalValueSources {
3979 owned: &local_vars,
3980 borrowed: &local_borrowed_vars,
3981 },
3982 &state,
3983 )
3984 .is_none());
3985 }
3986
3987 #[test]
3988 fn borrowed_component_collection_reuses_state_array() {
3989 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
3990 let contacts = &state["teams"][0]["contacts"];
3991 let mut local_borrowed_vars = BorrowedScope::default();
3992 local_borrowed_vars.insert("contacts", contacts);
3993 let local_vars = HashMap::new();
3994 let items = resolve_borrowed_collection(
3995 "contacts",
3996 &[],
3997 VisibleLoopScope::EMPTY,
3998 LocalValueSources {
3999 owned: &local_vars,
4000 borrowed: &local_borrowed_vars,
4001 },
4002 &state,
4003 )
4004 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4005 let state_items = contacts
4006 .as_array()
4007 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4008 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4009 }
4010
4011 #[test]
4012 fn nested_borrowed_collection_reuses_child_array() {
4013 let state = test_json!({
4014 "items": [{
4015 "children": [
4016 {"name": "first"},
4017 {"name": "second"}
4018 ]
4019 }]
4020 });
4021 let item = &state["items"][0];
4022 let loop_vars = vec![LoopBinding {
4023 name: "item",
4024 value: item,
4025 }];
4026 let local_vars = HashMap::new();
4027 let local_borrowed_vars = BorrowedScope::default();
4028 let children = resolve_borrowed_collection(
4029 "item.children",
4030 &loop_vars,
4031 VisibleLoopScope { start: 0, end: 1 },
4032 LocalValueSources {
4033 owned: &local_vars,
4034 borrowed: &local_borrowed_vars,
4035 },
4036 &state,
4037 )
4038 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4039 let state_children = item["children"]
4040 .as_array()
4041 .unwrap_or_else(|| panic!("fixture children should be an array"));
4042 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4043 }
4044
4045 #[test]
4046 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4047 let state = test_json!({
4048 "item": {"name": "global", "fallback": "global fallback"},
4049 "outer": {"name": "outer"},
4050 "inner": {"name": "inner"}
4051 });
4052 let loop_vars = vec![
4053 LoopBinding {
4054 name: "item",
4055 value: &state["outer"],
4056 },
4057 LoopBinding {
4058 name: "item",
4059 value: &state["inner"],
4060 },
4061 ];
4062 let local_vars = HashMap::new();
4063 let local_borrowed_vars = BorrowedScope::default();
4064 let sources = LocalValueSources {
4065 owned: &local_vars,
4066 borrowed: &local_borrowed_vars,
4067 };
4068
4069 assert_eq!(
4070 resolve_value_from_sources(
4071 "item.name",
4072 &loop_vars,
4073 VisibleLoopScope { start: 0, end: 2 },
4074 sources,
4075 &state,
4076 )
4077 .as_deref()
4078 .and_then(Value::as_str),
4079 Some("inner")
4080 );
4081 assert_eq!(
4082 resolve_value_from_sources(
4083 "item.fallback",
4084 &loop_vars,
4085 VisibleLoopScope { start: 0, end: 2 },
4086 sources,
4087 &state,
4088 )
4089 .as_deref()
4090 .and_then(Value::as_str),
4091 Some("global fallback")
4092 );
4093 assert_eq!(
4094 resolve_value_from_sources(
4095 "item.name",
4096 &loop_vars,
4097 VisibleLoopScope { start: 2, end: 2 },
4098 sources,
4099 &state,
4100 )
4101 .as_deref()
4102 .and_then(Value::as_str),
4103 Some("global")
4104 );
4105 }
4106
4107 #[test]
4108 fn state_backed_resolver_matches_general_borrowed_resolution() {
4109 let state = test_json!({
4110 "global": {"name": "global"},
4111 "inner": {"fallback": "global fallback"},
4112 "borrowed": {"name": "borrowed"},
4113 "items": [1, 2, 3]
4114 });
4115 let loop_values = test_json!({
4116 "outer": {"name": "outer"},
4117 "inner": {"name": "inner"}
4118 });
4119 let loop_vars = vec![
4120 LoopBinding {
4121 name: "outer",
4122 value: &loop_values["outer"],
4123 },
4124 LoopBinding {
4125 name: "inner",
4126 value: &loop_values["inner"],
4127 },
4128 ];
4129 let mut local_borrowed_vars = BorrowedScope::default();
4130 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4131 let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4132 let sources = LocalValueSources {
4133 owned: &local_vars,
4134 borrowed: &local_borrowed_vars,
4135 };
4136 let scope = VisibleLoopScope { start: 0, end: 2 };
4137
4138 for path in [
4139 "global.name",
4140 "inner.name",
4141 "inner.fallback",
4142 "outer.name",
4143 "borrowed.name",
4144 ] {
4145 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4146 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4147 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4148 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4149 let Cow::Borrowed(resolved) = resolved else {
4150 panic!("{path} should remain borrowed");
4151 };
4152 assert!(
4153 std::ptr::eq(state_backed, resolved),
4154 "{path} resolved from different sources"
4155 );
4156 }
4157
4158 assert!(
4159 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4160 "owned local values must use the general resolver"
4161 );
4162 assert!(
4163 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4164 .is_none(),
4165 "synthetic values must use the general resolver"
4166 );
4167 }
4168
4169 #[test]
4170 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4171 let values = [
4172 Value::String("a".to_string()),
4173 Value::String("b".to_string()),
4174 Value::String("c".to_string()),
4175 Value::String("d".to_string()),
4176 Value::String("e".to_string()),
4177 Value::String("f".to_string()),
4178 Value::String("replacement".to_string()),
4179 ];
4180 let mut scope = BorrowedScope::default();
4181 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4182 assert!(scope.insert(name, &values[index]).is_none());
4183 }
4184 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4185 assert_eq!(scope.overflow.len(), 2);
4186 assert_eq!(scope.get("a"), Some(&values[0]));
4187 assert_eq!(scope.get("f"), Some(&values[5]));
4188 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4189 assert_eq!(scope.get("a"), Some(&values[6]));
4190 assert_eq!(scope.remove("b"), Some(&values[1]));
4191 assert!(scope.get("b").is_none());
4192 assert_eq!(scope.get("f"), Some(&values[5]));
4193 scope.clear();
4194 assert!(scope.get("a").is_none());
4195 assert!(scope.overflow.is_empty());
4196 }
4197
4198 struct TestWriter {
4200 content: RefCell<String>,
4201 ended: RefCell<bool>,
4202 }
4203
4204 impl TestWriter {
4205 fn new() -> Self {
4206 Self {
4207 content: RefCell::new(String::new()),
4208 ended: RefCell::new(false),
4209 }
4210 }
4211
4212 fn get_content(&self) -> String {
4213 self.content.borrow().clone()
4214 }
4215
4216 fn is_ended(&self) -> bool {
4217 *self.ended.borrow()
4218 }
4219 }
4220
4221 impl ResponseWriter for TestWriter {
4222 fn write(&mut self, content: &str) -> Result<()> {
4223 self.content.borrow_mut().push_str(content);
4224 Ok(())
4225 }
4226
4227 fn end(&mut self) -> Result<()> {
4228 *self.ended.borrow_mut() = true;
4229 Ok(())
4230 }
4231 }
4232
4233 #[test]
4234 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4235 struct AttributeWriter {
4236 output: String,
4237 }
4238
4239 impl ResponseWriter for AttributeWriter {
4240 fn write(&mut self, content: &str) -> Result<()> {
4241 self.output.push_str(content);
4242 Ok(())
4243 }
4244
4245 crate::string_response_writer_methods!(output);
4246
4247 fn end(&mut self) -> Result<()> {
4248 Ok(())
4249 }
4250 }
4251
4252 let mut writer = AttributeWriter {
4253 output: String::new(),
4254 };
4255 write_attr(&mut writer, "data-id", "42")
4256 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4257 writer
4258 .write_boolean_attribute("disabled")
4259 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4260 assert_eq!(writer.output, " data-id=\"42\" disabled");
4261 }
4262
4263 #[test]
4264 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4265 let mut fragments = HashMap::new();
4266 fragments.insert(
4267 "index.html".to_string(),
4268 FragmentList {
4269 fragments: vec![
4270 WebUIFragment {
4271 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4272 name: ":data-title".to_string(),
4273 value: "source".to_string(),
4274 complex: true,
4275 attr_start: true,
4276 ..Default::default()
4277 })),
4278 },
4279 WebUIFragment::component("prepared-child"),
4280 ],
4281 contains_boundary: false,
4282 },
4283 );
4284 fragments.insert(
4285 "prepared-child".to_string(),
4286 FragmentList {
4287 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4288 contains_boundary: false,
4289 },
4290 );
4291 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4292 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4293 let entry = resolved
4294 .list_by_id("index.html")
4295 .unwrap_or_else(|| panic!("entry render plan should exist"));
4296 let child_index = protocol
4297 .render_fragments()
4298 .index("prepared-child")
4299 .unwrap_or_else(|| panic!("component target should be indexed"));
4300 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4301 assert_eq!(entry.target(1), Some(child_index));
4302 assert!(!entry.has_routes);
4303 let source_entry = protocol
4304 .protocol()
4305 .fragments
4306 .get("index.html")
4307 .unwrap_or_else(|| panic!("source entry should exist"));
4308 assert!(std::ptr::eq(
4309 entry.fragments.as_ptr(),
4310 source_entry.fragments.as_ptr()
4311 ));
4312
4313 let state = test_json!({"source": "Prepared"});
4314 let options = RenderOptions::new("index.html", "/");
4315 for handler in [
4316 WebUIHandler::new(),
4317 WebUIHandler::with_plugin(|| {
4318 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4319 }),
4320 ] {
4321 let mut writer = TestWriter::new();
4322 assert!(handler
4323 .render(&protocol, &state, &options, &mut writer)
4324 .is_ok());
4325 assert!(writer.get_content().contains("Prepared"));
4326 }
4327 }
4328
4329 #[test]
4330 fn prepared_graph_records_route_presence_per_fragment_list() {
4331 let mut fragments = HashMap::new();
4332 fragments.insert(
4333 "index.html".to_string(),
4334 FragmentList {
4335 fragments: vec![WebUIFragment::route("/", "dash-page")],
4336 contains_boundary: false,
4337 },
4338 );
4339 fragments.insert(
4340 "dash-page".to_string(),
4341 FragmentList {
4342 fragments: vec![WebUIFragment::raw("dashboard")],
4343 contains_boundary: false,
4344 },
4345 );
4346 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4347 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4348 let entry = resolved
4349 .list_by_id("index.html")
4350 .unwrap_or_else(|| panic!("entry render plan should exist"));
4351 let page = resolved
4352 .list_by_id("dash-page")
4353 .unwrap_or_else(|| panic!("page render plan should exist"));
4354 assert!(entry.has_routes);
4356 assert!(!page.has_routes);
4357 }
4358
4359 #[test]
4360 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4361 let names = [
4365 "data-title",
4366 ":data-title",
4367 "aria-labelledby",
4368 "aria-valuemax",
4369 "readonly",
4370 "tabindex",
4371 "class",
4372 "for",
4373 "maxlength",
4374 "novalidate",
4375 "already-camel",
4376 "trailing-",
4377 "-leading",
4378 "multi-part-attribute-name",
4379 "single",
4380 "",
4381 ];
4382 for name in names {
4383 let stripped = name.strip_prefix(':').unwrap_or(name);
4384 assert_eq!(
4385 component_attr_name(name),
4386 webui_protocol::attrs::attribute_to_camel(stripped),
4387 "prepared name diverged for `{name}`"
4388 );
4389 }
4390 }
4391
4392 #[test]
4393 fn prepared_component_prop_names_match_per_branch_stripping() {
4394 let complex = WebUIFragmentAttribute {
4398 name: "::data-title".to_string(),
4399 value: "source".to_string(),
4400 complex: true,
4401 ..Default::default()
4402 };
4403 assert_eq!(component_attr_source(&complex), ":data-title");
4404 assert_eq!(
4405 component_attr_name(component_attr_source(&complex)),
4406 "dataTitle"
4407 );
4408
4409 let raw = WebUIFragmentAttribute {
4411 raw_value: true,
4412 ..complex.clone()
4413 };
4414 assert_eq!(component_attr_source(&raw), "::data-title");
4415
4416 let templated = WebUIFragmentAttribute {
4418 template: "tpl".to_string(),
4419 ..complex.clone()
4420 };
4421 assert_eq!(component_attr_source(&templated), "::data-title");
4422
4423 let empty_value = WebUIFragmentAttribute {
4425 value: String::new(),
4426 ..complex.clone()
4427 };
4428 assert_eq!(component_attr_source(&empty_value), "::data-title");
4429
4430 let ordinary = WebUIFragmentAttribute {
4432 name: ":data-title".to_string(),
4433 ..complex
4434 };
4435 assert_eq!(component_attr_source(&ordinary), "data-title");
4436 assert_eq!(
4437 component_attr_name(component_attr_source(&ordinary)),
4438 "dataTitle"
4439 );
4440 }
4441
4442 #[test]
4443 fn render_slots_match_continuation_slots() {
4444 let mut fragments = HashMap::new();
4449 for id in [
4450 "index.html",
4451 "zeta-last",
4452 "alpha-first",
4453 "middle-one",
4454 "middle-two",
4455 ] {
4456 fragments.insert(
4457 id.to_string(),
4458 FragmentList {
4459 fragments: vec![WebUIFragment::raw(id)],
4460 contains_boundary: false,
4461 },
4462 );
4463 }
4464 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4465 let index = protocol.render_fragments();
4466 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4467 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4468 {
4469 let id = protocol
4470 .fragment_id(slot)
4471 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4472 assert_eq!(
4473 index.index(id),
4474 Some(slot as usize),
4475 "render slot must equal continuation slot for `{id}`"
4476 );
4477 assert_eq!(protocol.fragment_slot(id), Some(slot));
4478 }
4479 }
4480
4481 #[test]
4482 fn prepared_graph_reports_missing_fragments_without_panicking() {
4483 let mut fragments = HashMap::new();
4484 fragments.insert(
4485 "index.html".to_string(),
4486 FragmentList {
4487 fragments: vec![WebUIFragment::component("absent-child")],
4488 contains_boundary: false,
4489 },
4490 );
4491 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4492 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4493 let entry = resolved
4494 .list_by_id("index.html")
4495 .unwrap_or_else(|| panic!("entry render plan should exist"));
4496 assert_eq!(entry.target(0), None);
4499 assert!(resolved.list_by_id("absent-child").is_none());
4500 assert!(resolved.list(usize::MAX).is_none());
4501
4502 let state = test_json!({});
4503 let options = RenderOptions::new("index.html", "/");
4504 let mut writer = TestWriter::new();
4505 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4506 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4507 other => panic!("expected a missing fragment error, got {other:?}"),
4508 }
4509
4510 let mut writer = TestWriter::new();
4511 match WebUIHandler::new().render(
4512 &protocol,
4513 &state,
4514 &RenderOptions::new("absent-entry", "/"),
4515 &mut writer,
4516 ) {
4517 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4518 other => panic!("expected a missing entry error, got {other:?}"),
4519 }
4520 }
4521
4522 #[test]
4523 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4524 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4525 let mut fragments = HashMap::with_capacity(list_count);
4526 for index in 0..list_count {
4527 fragments.insert(
4528 format!("fragment-{index}"),
4529 FragmentList {
4530 fragments: vec![WebUIFragment::raw("content")],
4531 contains_boundary: false,
4532 },
4533 );
4534 }
4535 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4536 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4537 assert!(resolved.cache[0].get().is_none());
4539 assert!(resolved.spilled.get().is_none());
4540 assert!(resolved.list(0).is_some());
4541 assert!(resolved.cache[0].get().is_some());
4542 assert!(resolved.spilled.get().is_none());
4544 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4547 let spilled = resolved
4548 .spilled
4549 .get()
4550 .expect("a slot past the inline cache should allocate the spill region");
4551 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4552 assert!(spilled[0].get().is_some());
4553 assert!(resolved.list(list_count - 1).is_some());
4554
4555 for (id, source) in &protocol.protocol().fragments {
4556 let prepared = resolved
4557 .list_by_id(id)
4558 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4559 assert!(std::ptr::eq(
4561 prepared.fragments.as_ptr(),
4562 source.fragments.as_ptr()
4563 ));
4564 }
4565 }
4566
4567 #[test]
4568 fn test_handle_raw() {
4569 let mut fragments = HashMap::new();
4571 fragments.insert(
4572 "index.html".to_string(),
4573 FragmentList {
4574 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4575 contains_boundary: false,
4576 },
4577 );
4578
4579 let protocol = WebUIProtocol::new(fragments);
4580 let state = test_json!({});
4581
4582 let mut writer = TestWriter::new();
4584
4585 assert!(
4587 handle(
4588 &protocol,
4589 &state,
4590 &RenderOptions::new("index.html", "/"),
4591 &mut writer
4592 )
4593 .is_ok(),
4594 "Failed to handle raw protocol"
4595 );
4596
4597 assert_eq!(writer.get_content(), "Hello, WebUI!");
4599 assert!(writer.is_ended());
4600 }
4601
4602 #[test]
4603 fn test_handle_signal() {
4604 let mut fragments = HashMap::new();
4606 fragments.insert(
4607 "index.html".to_string(),
4608 FragmentList {
4609 fragments: vec![
4610 WebUIFragment::raw("Hello, "),
4611 WebUIFragment::signal("name", false),
4612 WebUIFragment::raw("!"),
4613 ],
4614 contains_boundary: false,
4615 },
4616 );
4617
4618 let protocol = WebUIProtocol::new(fragments);
4619 let state = test_json!({"name": "WebUI"});
4620
4621 let mut writer = TestWriter::new();
4623
4624 assert!(
4626 handle(
4627 &protocol,
4628 &state,
4629 &RenderOptions::new("index.html", "/"),
4630 &mut writer
4631 )
4632 .is_ok(),
4633 "Failed to handle signal protocol"
4634 );
4635
4636 assert_eq!(writer.get_content(), "Hello, WebUI!");
4638 assert!(writer.is_ended());
4639 }
4640
4641 #[test]
4642 fn test_handle_for_loop() {
4643 let mut fragments = HashMap::new();
4645 fragments.insert(
4646 "index.html".to_string(),
4647 FragmentList {
4648 fragments: vec![
4649 WebUIFragment::raw("People: "),
4650 WebUIFragment::for_loop("person", "people", "person-item"),
4651 ],
4652 contains_boundary: false,
4653 },
4654 );
4655
4656 fragments.insert(
4657 "person-item".to_string(),
4658 FragmentList {
4659 fragments: vec![
4660 WebUIFragment::signal("person.name", false),
4661 WebUIFragment::raw(", "),
4662 ],
4663 contains_boundary: false,
4664 },
4665 );
4666
4667 let protocol = WebUIProtocol::new(fragments);
4668 let state = test_json!({
4669 "people": [
4670 {"name": "Alice"},
4671 {"name": "Bob"},
4672 {"name": "Charlie"}
4673 ]
4674 });
4675
4676 let mut writer = TestWriter::new();
4678
4679 assert!(
4681 handle(
4682 &protocol,
4683 &state,
4684 &RenderOptions::new("index.html", "/"),
4685 &mut writer
4686 )
4687 .is_ok(),
4688 "Failed to handle for loop protocol"
4689 );
4690
4691 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4693 assert!(writer.is_ended());
4694 }
4695
4696 #[test]
4697 fn test_handle_if_condition() {
4698 let mut fragments = HashMap::new();
4700 fragments.insert(
4701 "index.html".to_string(),
4702 FragmentList {
4703 fragments: vec![
4704 WebUIFragment::raw("Status: "),
4705 WebUIFragment::if_cond(
4706 webui_protocol::ConditionExpr::identifier("isActive"),
4707 "active-content",
4708 ),
4709 WebUIFragment::raw("End"),
4710 ],
4711 contains_boundary: false,
4712 },
4713 );
4714
4715 fragments.insert(
4716 "active-content".to_string(),
4717 FragmentList {
4718 fragments: vec![WebUIFragment::raw("Active")],
4719 contains_boundary: false,
4720 },
4721 );
4722
4723 let protocol = WebUIProtocol::new(fragments);
4724
4725 let state_true = test_json!({"isActive": true});
4727 let mut writer_true = TestWriter::new();
4728 assert!(
4729 handle(
4730 &protocol,
4731 &state_true,
4732 &RenderOptions::new("index.html", "/"),
4733 &mut writer_true
4734 )
4735 .is_ok(),
4736 "Failed to handle if condition (true case)"
4737 );
4738 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
4739 assert!(writer_true.is_ended());
4740
4741 let state_false = test_json!({"isActive": false});
4743 let mut writer_false = TestWriter::new();
4744 assert!(
4745 handle(
4746 &protocol,
4747 &state_false,
4748 &RenderOptions::new("index.html", "/"),
4749 &mut writer_false
4750 )
4751 .is_ok(),
4752 "Failed to handle if condition (false case)"
4753 );
4754 assert_eq!(writer_false.get_content(), "Status: End");
4755 assert!(writer_false.is_ended());
4756 }
4757
4758 #[test]
4759 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
4760 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>"#;
4761 let mut parser = HtmlParser::new();
4762 parser
4763 .parse("index.html", source)
4764 .expect("parse missing-path condition fixture");
4765 let protocol = WebUIProtocol::new(parser.into_fragment_records());
4766 let state = test_json!({
4767 "items": [
4768 {"label": "Normal"},
4769 {"label": "Search", "searchPresentation": true}
4770 ]
4771 });
4772 let mut writer = TestWriter::new();
4773
4774 handle(
4775 &protocol,
4776 &state,
4777 &RenderOptions::new("index.html", "/"),
4778 &mut writer,
4779 )
4780 .expect("render missing-path condition fixture");
4781
4782 assert_eq!(
4783 writer.get_content(),
4784 "top-negated<span>Normal</span><mark>Search</mark>"
4785 );
4786 }
4787
4788 #[test]
4789 fn native_dynamic_attribute_does_not_leak_into_next_component() {
4790 let mut fragments = HashMap::new();
4791 fragments.insert(
4792 "index.html".to_string(),
4793 FragmentList {
4794 fragments: vec![
4795 WebUIFragment::raw("<div"),
4796 WebUIFragment {
4797 fragment: Some(web_ui_fragment::Fragment::Attribute(
4798 WebUIFragmentAttribute {
4799 name: "title".into(),
4800 value: "nativeTitle".into(),
4801 ..Default::default()
4802 },
4803 )),
4804 },
4805 WebUIFragment::raw("></div><my-comp>"),
4806 WebUIFragment::component("my-comp"),
4807 WebUIFragment::raw("</my-comp>"),
4808 ],
4809 contains_boundary: false,
4810 },
4811 );
4812 fragments.insert(
4813 "my-comp".to_string(),
4814 FragmentList {
4815 fragments: vec![WebUIFragment::signal("title", false)],
4816 contains_boundary: false,
4817 },
4818 );
4819 let protocol = WebUIProtocol::new(fragments);
4820 let state = test_json!({
4821 "nativeTitle": "native",
4822 "title": "global"
4823 });
4824 let mut writer = TestWriter::new();
4825 handle(
4826 &protocol,
4827 &state,
4828 &RenderOptions::new("index.html", "/"),
4829 &mut writer,
4830 )
4831 .unwrap_or_else(|error| panic!("render failed: {error}"));
4832 assert_eq!(
4833 writer.get_content(),
4834 "<div title=\"native\"></div><my-comp>global</my-comp>"
4835 );
4836 }
4837
4838 #[test]
4839 fn test_handle_component() {
4840 let mut fragments = HashMap::new();
4842 fragments.insert(
4843 "index.html".to_string(),
4844 FragmentList {
4845 fragments: vec![
4846 WebUIFragment::raw("Component: "),
4847 WebUIFragment::component("my-component"),
4848 ],
4849 contains_boundary: false,
4850 },
4851 );
4852
4853 fragments.insert(
4854 "my-component".to_string(),
4855 FragmentList {
4856 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
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();
4866
4867 assert!(
4869 handle(
4870 &protocol,
4871 &state,
4872 &RenderOptions::new("index.html", "/"),
4873 &mut writer
4874 )
4875 .is_ok(),
4876 "Failed to handle component protocol"
4877 );
4878
4879 assert_eq!(
4881 writer.get_content(),
4882 "Component: <div>Component Content</div>"
4883 );
4884 assert!(writer.is_ended());
4885 }
4886
4887 #[test]
4888 fn test_missing_fragment() {
4889 let mut fragments = HashMap::new();
4891 fragments.insert(
4892 "index.html".to_string(),
4893 FragmentList {
4894 fragments: vec![WebUIFragment::component("missing-component")],
4895 contains_boundary: false,
4896 },
4897 );
4898
4899 let protocol = WebUIProtocol::new(fragments);
4900 let state = test_json!({});
4901
4902 let mut writer = TestWriter::new();
4904
4905 let result = handle(
4907 &protocol,
4908 &state,
4909 &RenderOptions::new("index.html", "/"),
4910 &mut writer,
4911 );
4912
4913 assert!(result.is_err());
4915 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
4916 assert_eq!(fragment_id, "missing-component");
4917 } else {
4918 panic!("Expected MissingFragment error");
4919 }
4920 }
4921
4922 #[test]
4923 fn test_missing_signal_renders_empty() {
4924 let mut fragments = HashMap::new();
4926 fragments.insert(
4927 "index.html".to_string(),
4928 FragmentList {
4929 fragments: vec![
4930 WebUIFragment::raw("Hello, "),
4931 WebUIFragment::signal("missing_field", false),
4932 WebUIFragment::raw("!"),
4933 ],
4934 contains_boundary: false,
4935 },
4936 );
4937
4938 let protocol = WebUIProtocol::new(fragments);
4939 let state = test_json!({});
4940
4941 let mut writer = TestWriter::new();
4942
4943 assert!(
4944 handle(
4945 &protocol,
4946 &state,
4947 &RenderOptions::new("index.html", "/"),
4948 &mut writer
4949 )
4950 .is_ok(),
4951 "Missing signal should not produce an error"
4952 );
4953
4954 assert_eq!(writer.get_content(), "Hello, !");
4955 assert!(writer.is_ended());
4956 }
4957
4958 #[test]
4961 fn test_boolean_attr_true() {
4962 let mut fragments = HashMap::new();
4963 fragments.insert(
4964 "index.html".to_string(),
4965 FragmentList {
4966 fragments: vec![
4967 WebUIFragment::raw("<button"),
4968 WebUIFragment::attribute_boolean(
4969 "disabled",
4970 ConditionExpr::identifier("isDisabled"),
4971 ),
4972 WebUIFragment::raw(">Click</button>"),
4973 ],
4974 contains_boundary: false,
4975 },
4976 );
4977 let protocol = WebUIProtocol::new(fragments);
4978 let state = test_json!({"isDisabled": true});
4979 let mut writer = TestWriter::new();
4980 handle(
4981 &protocol,
4982 &state,
4983 &RenderOptions::new("index.html", "/"),
4984 &mut writer,
4985 )
4986 .unwrap();
4987 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
4988 }
4989
4990 #[test]
4991 fn test_boolean_attr_false() {
4992 let mut fragments = HashMap::new();
4993 fragments.insert(
4994 "index.html".to_string(),
4995 FragmentList {
4996 fragments: vec![
4997 WebUIFragment::raw("<button"),
4998 WebUIFragment::attribute_boolean(
4999 "disabled",
5000 ConditionExpr::identifier("isDisabled"),
5001 ),
5002 WebUIFragment::raw(">Click</button>"),
5003 ],
5004 contains_boundary: false,
5005 },
5006 );
5007 let protocol = WebUIProtocol::new(fragments);
5008 let state = test_json!({"isDisabled": false});
5009 let mut writer = TestWriter::new();
5010 handle(
5011 &protocol,
5012 &state,
5013 &RenderOptions::new("index.html", "/"),
5014 &mut writer,
5015 )
5016 .unwrap();
5017 assert_eq!(writer.get_content(), "<button>Click</button>");
5018 }
5019
5020 #[test]
5021 fn test_boolean_attr_missing() {
5022 let mut fragments = HashMap::new();
5023 fragments.insert(
5024 "index.html".to_string(),
5025 FragmentList {
5026 fragments: vec![
5027 WebUIFragment::raw("<input type=\"checkbox\""),
5028 WebUIFragment::attribute_boolean(
5029 "checked",
5030 ConditionExpr::identifier("checked"),
5031 ),
5032 WebUIFragment::raw(">"),
5033 ],
5034 contains_boundary: false,
5035 },
5036 );
5037 let protocol = WebUIProtocol::new(fragments);
5038 let state = test_json!({});
5039 let mut writer = TestWriter::new();
5040 handle(
5041 &protocol,
5042 &state,
5043 &RenderOptions::new("index.html", "/"),
5044 &mut writer,
5045 )
5046 .unwrap();
5047 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5048 }
5049
5050 #[test]
5051 fn test_boolean_attr_multiple() {
5052 let mut fragments = HashMap::new();
5053 fragments.insert(
5054 "index.html".to_string(),
5055 FragmentList {
5056 fragments: vec![
5057 WebUIFragment::raw("<input type=\"checkbox\""),
5058 WebUIFragment::attribute_boolean(
5059 "checked",
5060 ConditionExpr::identifier("checked"),
5061 ),
5062 WebUIFragment::attribute_boolean(
5063 "disabled",
5064 ConditionExpr::identifier("disabled"),
5065 ),
5066 WebUIFragment::raw(">"),
5067 ],
5068 contains_boundary: false,
5069 },
5070 );
5071 let protocol = WebUIProtocol::new(fragments);
5072 let state = test_json!({"checked": true, "disabled": false});
5073 let mut writer = TestWriter::new();
5074 handle(
5075 &protocol,
5076 &state,
5077 &RenderOptions::new("index.html", "/"),
5078 &mut writer,
5079 )
5080 .unwrap();
5081 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5082 }
5083
5084 #[test]
5087 fn test_attribute_with_value() {
5088 let mut fragments = HashMap::new();
5089 fragments.insert(
5090 "index.html".to_string(),
5091 FragmentList {
5092 fragments: vec![
5093 WebUIFragment::raw("<input"),
5094 WebUIFragment::attribute("value", "inputValue"),
5095 WebUIFragment::raw(">"),
5096 ],
5097 contains_boundary: false,
5098 },
5099 );
5100 let protocol = WebUIProtocol::new(fragments);
5101 let state = test_json!({"inputValue": "Hello"});
5102 let mut writer = TestWriter::new();
5103 handle(
5104 &protocol,
5105 &state,
5106 &RenderOptions::new("index.html", "/"),
5107 &mut writer,
5108 )
5109 .unwrap();
5110 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5111 }
5112
5113 #[test]
5114 fn test_attribute_with_falsy_numeric() {
5115 let mut fragments = HashMap::new();
5116 fragments.insert(
5117 "index.html".to_string(),
5118 FragmentList {
5119 fragments: vec![
5120 WebUIFragment::raw("<div name=\"test\""),
5121 WebUIFragment::attribute("handle", "number"),
5122 WebUIFragment::raw("></div>"),
5123 ],
5124 contains_boundary: false,
5125 },
5126 );
5127 let protocol = WebUIProtocol::new(fragments);
5128 let state = test_json!({"number": 0});
5129 let mut writer = TestWriter::new();
5130 handle(
5131 &protocol,
5132 &state,
5133 &RenderOptions::new("index.html", "/"),
5134 &mut writer,
5135 )
5136 .unwrap();
5137 assert_eq!(
5138 writer.get_content(),
5139 "<div name=\"test\" handle=\"0\"></div>"
5140 );
5141 }
5142
5143 #[test]
5146 fn test_attribute_array_value_is_escaped() {
5147 let mut fragments = HashMap::new();
5148 fragments.insert(
5149 "index.html".to_string(),
5150 FragmentList {
5151 fragments: vec![
5152 WebUIFragment::raw("<a"),
5153 WebUIFragment::attribute("href", "value"),
5154 WebUIFragment::raw(">demo</a>"),
5155 ],
5156 contains_boundary: false,
5157 },
5158 );
5159 let protocol = WebUIProtocol::new(fragments);
5160 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5161 let mut writer = TestWriter::new();
5162 handle(
5163 &protocol,
5164 &state,
5165 &RenderOptions::new("index.html", "/"),
5166 &mut writer,
5167 )
5168 .unwrap();
5169 let content = writer.get_content();
5170 assert!(
5173 content.contains("""),
5174 "Double quotes inside attribute value must be escaped: {content}"
5175 );
5176 assert_eq!(
5179 content.matches("=\"").count(),
5180 1,
5181 "Only one attribute assignment expected: {content}"
5182 );
5183 }
5184
5185 #[test]
5186 fn test_attribute_object_value_is_escaped() {
5187 let mut fragments = HashMap::new();
5188 fragments.insert(
5189 "index.html".to_string(),
5190 FragmentList {
5191 fragments: vec![
5192 WebUIFragment::raw("<div"),
5193 WebUIFragment::attribute("data-cfg", "cfg"),
5194 WebUIFragment::raw("></div>"),
5195 ],
5196 contains_boundary: false,
5197 },
5198 );
5199 let protocol = WebUIProtocol::new(fragments);
5200 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5201 let mut writer = TestWriter::new();
5202 handle(
5203 &protocol,
5204 &state,
5205 &RenderOptions::new("index.html", "/"),
5206 &mut writer,
5207 )
5208 .unwrap();
5209 let content = writer.get_content();
5210 assert!(
5211 content.contains("""),
5212 "Double quotes inside attribute value must be escaped: {content}"
5213 );
5214 assert_eq!(
5215 content.matches("=\"").count(),
5216 1,
5217 "Only one attribute assignment expected: {content}"
5218 );
5219 }
5220
5221 #[test]
5224 fn test_mixed_attribute_template() {
5225 let mut fragments = HashMap::new();
5226 fragments.insert(
5227 "index.html".to_string(),
5228 FragmentList {
5229 fragments: vec![
5230 WebUIFragment::raw("<input"),
5231 WebUIFragment::attribute_template("value", "attr-1"),
5232 WebUIFragment::raw(">"),
5233 ],
5234 contains_boundary: false,
5235 },
5236 );
5237 fragments.insert(
5238 "attr-1".to_string(),
5239 FragmentList {
5240 fragments: vec![
5241 WebUIFragment::raw("hello "),
5242 WebUIFragment::signal("item", false),
5243 ],
5244 contains_boundary: false,
5245 },
5246 );
5247 let protocol = WebUIProtocol::new(fragments);
5248 let state = test_json!({"item": "world"});
5249 let mut writer = TestWriter::new();
5250 handle(
5251 &protocol,
5252 &state,
5253 &RenderOptions::new("index.html", "/"),
5254 &mut writer,
5255 )
5256 .unwrap();
5257 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5258 }
5259
5260 #[test]
5263 fn test_raw_signal_not_escaped() {
5264 let mut fragments = HashMap::new();
5265 fragments.insert(
5266 "index.html".to_string(),
5267 FragmentList {
5268 fragments: vec![
5269 WebUIFragment::signal("html", false),
5270 WebUIFragment::signal("html", true),
5271 ],
5272 contains_boundary: false,
5273 },
5274 );
5275 let protocol = WebUIProtocol::new(fragments);
5276 let state = test_json!({"html": "<strong>hi</strong>"});
5277 let mut writer = TestWriter::new();
5278 handle(
5279 &protocol,
5280 &state,
5281 &RenderOptions::new("index.html", "/"),
5282 &mut writer,
5283 )
5284 .unwrap();
5285 assert_eq!(
5286 writer.get_content(),
5287 "<strong>hi</strong><strong>hi</strong>"
5288 );
5289 }
5290
5291 #[test]
5294 fn test_nested_for_loop() {
5295 let mut fragments = HashMap::new();
5296 fragments.insert(
5297 "index.html".to_string(),
5298 FragmentList {
5299 fragments: vec![
5300 WebUIFragment::raw("<div>"),
5301 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5302 WebUIFragment::raw("</div>"),
5303 ],
5304 contains_boundary: false,
5305 },
5306 );
5307 fragments.insert(
5308 "outer".to_string(),
5309 FragmentList {
5310 fragments: vec![
5311 WebUIFragment::raw("<div>"),
5312 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5313 WebUIFragment::raw("</div>"),
5314 ],
5315 contains_boundary: false,
5316 },
5317 );
5318 fragments.insert(
5319 "inner".to_string(),
5320 FragmentList {
5321 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5322 contains_boundary: false,
5323 },
5324 );
5325 let protocol = WebUIProtocol::new(fragments);
5326 let state = test_json!({
5327 "outerItems": [
5328 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5329 {"innerItems": [{"name": "C"}]}
5330 ]
5331 });
5332 let mut writer = TestWriter::new();
5333 handle(
5334 &protocol,
5335 &state,
5336 &RenderOptions::new("index.html", "/"),
5337 &mut writer,
5338 )
5339 .unwrap();
5340 assert_eq!(
5341 writer.get_content(),
5342 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5343 );
5344 }
5345
5346 #[test]
5347 fn test_nested_for_with_signals() {
5348 let mut fragments = HashMap::new();
5349 fragments.insert(
5350 "index.html".to_string(),
5351 FragmentList {
5352 fragments: vec![
5353 WebUIFragment::raw("<div>"),
5354 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5355 WebUIFragment::raw("</div>"),
5356 ],
5357 contains_boundary: false,
5358 },
5359 );
5360 fragments.insert(
5361 "outerTemplate".to_string(),
5362 FragmentList {
5363 fragments: vec![
5364 WebUIFragment::raw("<div>"),
5365 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5366 WebUIFragment::raw("</div>"),
5367 ],
5368 contains_boundary: false,
5369 },
5370 );
5371 fragments.insert(
5372 "innerTemplate".to_string(),
5373 FragmentList {
5374 fragments: vec![
5375 WebUIFragment::raw("<span>"),
5376 WebUIFragment::signal("innerItem.name", false),
5377 WebUIFragment::raw("</span>"),
5378 ],
5379 contains_boundary: false,
5380 },
5381 );
5382 let protocol = WebUIProtocol::new(fragments);
5383 let state = test_json!({
5384 "outerItems": [
5385 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5386 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5387 ]
5388 });
5389 let mut writer = TestWriter::new();
5390 handle(
5391 &protocol,
5392 &state,
5393 &RenderOptions::new("index.html", "/"),
5394 &mut writer,
5395 )
5396 .unwrap();
5397 assert_eq!(
5398 writer.get_content(),
5399 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5400 );
5401 }
5402
5403 #[test]
5404 fn test_nested_for_with_global_state() {
5405 let mut fragments = HashMap::new();
5406 fragments.insert(
5407 "index.html".to_string(),
5408 FragmentList {
5409 fragments: vec![
5410 WebUIFragment::raw("<div>"),
5411 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5412 WebUIFragment::raw("</div>"),
5413 ],
5414 contains_boundary: false,
5415 },
5416 );
5417 fragments.insert(
5418 "outerTemplate".to_string(),
5419 FragmentList {
5420 fragments: vec![
5421 WebUIFragment::raw("<div>"),
5422 WebUIFragment::signal("globalOuter", false),
5423 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5424 WebUIFragment::raw("</div>"),
5425 ],
5426 contains_boundary: false,
5427 },
5428 );
5429 fragments.insert(
5430 "innerTemplate".to_string(),
5431 FragmentList {
5432 fragments: vec![
5433 WebUIFragment::raw("<span>"),
5434 WebUIFragment::signal("innerItem.name", false),
5435 WebUIFragment::signal("globalInner", false),
5436 WebUIFragment::raw("</span>"),
5437 ],
5438 contains_boundary: false,
5439 },
5440 );
5441 let protocol = WebUIProtocol::new(fragments);
5442 let state = test_json!({
5443 "globalOuter": "GO",
5444 "globalInner": "GI",
5445 "outerItems": [
5446 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5447 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5448 ]
5449 });
5450 let mut writer = TestWriter::new();
5451 handle(
5452 &protocol,
5453 &state,
5454 &RenderOptions::new("index.html", "/"),
5455 &mut writer,
5456 )
5457 .unwrap();
5458 assert_eq!(
5459 writer.get_content(),
5460 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5461 );
5462 }
5463
5464 #[test]
5467 fn test_if_in_for_uses_local_state() {
5468 let mut fragments = HashMap::new();
5469 fragments.insert(
5470 "index.html".to_string(),
5471 FragmentList {
5472 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5473 contains_boundary: false,
5474 },
5475 );
5476 fragments.insert(
5477 "item-tpl".to_string(),
5478 FragmentList {
5479 fragments: vec![WebUIFragment::if_cond(
5480 ConditionExpr::identifier("item.visible"),
5481 "visible-tpl",
5482 )],
5483 contains_boundary: false,
5484 },
5485 );
5486 fragments.insert(
5487 "visible-tpl".to_string(),
5488 FragmentList {
5489 fragments: vec![WebUIFragment::signal("item.name", false)],
5490 contains_boundary: false,
5491 },
5492 );
5493 let protocol = WebUIProtocol::new(fragments);
5494 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5495 let mut writer = TestWriter::new();
5496 handle(
5497 &protocol,
5498 &state,
5499 &RenderOptions::new("index.html", "/"),
5500 &mut writer,
5501 )
5502 .unwrap();
5503 assert_eq!(writer.get_content(), "Show");
5504 }
5505
5506 #[test]
5507 fn test_for_if_local_overrides_global() {
5508 let mut fragments = HashMap::new();
5509 fragments.insert(
5510 "index.html".to_string(),
5511 FragmentList {
5512 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5513 contains_boundary: false,
5514 },
5515 );
5516 fragments.insert(
5517 "item-tpl".to_string(),
5518 FragmentList {
5519 fragments: vec![WebUIFragment::if_cond(
5520 ConditionExpr::identifier("item.flag"),
5521 "show-tpl",
5522 )],
5523 contains_boundary: false,
5524 },
5525 );
5526 fragments.insert(
5527 "show-tpl".to_string(),
5528 FragmentList {
5529 fragments: vec![WebUIFragment::raw("yes")],
5530 contains_boundary: false,
5531 },
5532 );
5533 let protocol = WebUIProtocol::new(fragments);
5534 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5536 let mut writer = TestWriter::new();
5537 handle(
5538 &protocol,
5539 &state,
5540 &RenderOptions::new("index.html", "/"),
5541 &mut writer,
5542 )
5543 .unwrap();
5544 assert_eq!(writer.get_content(), "yes");
5545 }
5546
5547 #[test]
5550 fn test_component_attr_state_simple() {
5551 let mut fragments = HashMap::new();
5552 fragments.insert(
5553 "index.html".to_string(),
5554 FragmentList {
5555 fragments: vec![
5556 WebUIFragment::raw("<my-comp"),
5557 WebUIFragment {
5558 fragment: Some(web_ui_fragment::Fragment::Attribute(
5559 WebUIFragmentAttribute {
5560 name: "title".into(),
5561 value: "Attribute Title".into(),
5562 attr_start: true,
5563 raw_value: true,
5564 ..Default::default()
5565 },
5566 )),
5567 },
5568 WebUIFragment::raw(">"),
5569 WebUIFragment::component("my-comp"),
5570 WebUIFragment::raw("</my-comp>"),
5571 ],
5572
5573 contains_boundary: false,
5574 },
5575 );
5576 fragments.insert(
5577 "my-comp".to_string(),
5578 FragmentList {
5579 fragments: vec![
5580 WebUIFragment::raw("<span>"),
5581 WebUIFragment::signal("title", false),
5582 WebUIFragment::raw("</span>"),
5583 ],
5584 contains_boundary: false,
5585 },
5586 );
5587 let protocol = WebUIProtocol::new(fragments);
5588 let state = test_json!({"title": "Global Title"});
5589 let mut writer = TestWriter::new();
5590 handle(
5591 &protocol,
5592 &state,
5593 &RenderOptions::new("index.html", "/"),
5594 &mut writer,
5595 )
5596 .unwrap();
5597 assert_eq!(
5598 writer.get_content(),
5599 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
5600 );
5601 }
5602
5603 #[test]
5604 fn test_component_attr_state_template() {
5605 let mut fragments = HashMap::new();
5606 fragments.insert(
5607 "index.html".to_string(),
5608 FragmentList {
5609 fragments: vec![
5610 WebUIFragment::raw("<my-comp"),
5611 WebUIFragment {
5612 fragment: Some(web_ui_fragment::Fragment::Attribute(
5613 WebUIFragmentAttribute {
5614 name: "title".into(),
5615 template: "title-attr".into(),
5616 attr_start: true,
5617 ..Default::default()
5618 },
5619 )),
5620 },
5621 WebUIFragment::raw(">"),
5622 WebUIFragment::component("my-comp"),
5623 WebUIFragment::raw("</my-comp>"),
5624 ],
5625
5626 contains_boundary: false,
5627 },
5628 );
5629 fragments.insert(
5630 "title-attr".to_string(),
5631 FragmentList {
5632 fragments: vec![
5633 WebUIFragment::raw("hello "),
5634 WebUIFragment::signal("item", false),
5635 ],
5636 contains_boundary: false,
5637 },
5638 );
5639 fragments.insert(
5640 "my-comp".to_string(),
5641 FragmentList {
5642 fragments: vec![
5643 WebUIFragment::raw("<span>"),
5644 WebUIFragment::signal("title", false),
5645 WebUIFragment::raw("</span>"),
5646 ],
5647 contains_boundary: false,
5648 },
5649 );
5650 let protocol = WebUIProtocol::new(fragments);
5651 let state = test_json!({"item": "<world>"});
5652 let mut writer = TestWriter::new();
5653 handle(
5654 &protocol,
5655 &state,
5656 &RenderOptions::new("index.html", "/"),
5657 &mut writer,
5658 )
5659 .unwrap();
5660 assert_eq!(
5661 writer.get_content(),
5662 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
5663 );
5664 }
5665
5666 #[test]
5667 fn test_component_attr_camel_case() {
5668 let mut fragments = HashMap::new();
5669 fragments.insert(
5670 "index.html".to_string(),
5671 FragmentList {
5672 fragments: vec![
5673 WebUIFragment::raw("<my-comp"),
5674 WebUIFragment {
5675 fragment: Some(web_ui_fragment::Fragment::Attribute(
5676 WebUIFragmentAttribute {
5677 name: "data-title".into(),
5678 template: "dt-attr".into(),
5679 attr_start: true,
5680 ..Default::default()
5681 },
5682 )),
5683 },
5684 WebUIFragment::raw(">"),
5685 WebUIFragment::component("my-comp"),
5686 WebUIFragment::raw("</my-comp>"),
5687 ],
5688
5689 contains_boundary: false,
5690 },
5691 );
5692 fragments.insert(
5693 "dt-attr".to_string(),
5694 FragmentList {
5695 fragments: vec![
5696 WebUIFragment::raw("prefix "),
5697 WebUIFragment::signal("item", false),
5698 ],
5699 contains_boundary: false,
5700 },
5701 );
5702 fragments.insert(
5703 "my-comp".to_string(),
5704 FragmentList {
5705 fragments: vec![
5706 WebUIFragment::raw("<span>"),
5707 WebUIFragment::signal("dataTitle", false),
5708 WebUIFragment::raw("</span>"),
5709 ],
5710 contains_boundary: false,
5711 },
5712 );
5713 let protocol = WebUIProtocol::new(fragments);
5714 let state = test_json!({"item": "a&b"});
5715 let mut writer = TestWriter::new();
5716 handle(
5717 &protocol,
5718 &state,
5719 &RenderOptions::new("index.html", "/"),
5720 &mut writer,
5721 )
5722 .unwrap();
5723 assert_eq!(
5724 writer.get_content(),
5725 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
5726 );
5727 }
5728
5729 #[test]
5730 fn test_component_complex_attr() {
5731 let mut fragments = HashMap::new();
5732 fragments.insert(
5733 "index.html".to_string(),
5734 FragmentList {
5735 fragments: vec![
5736 WebUIFragment::raw("<my-comp"),
5737 WebUIFragment {
5738 fragment: Some(web_ui_fragment::Fragment::Attribute(
5739 WebUIFragmentAttribute {
5740 name: ":item".into(),
5741 value: "complexItem".into(),
5742 attr_start: true,
5743 complex: true,
5744 ..Default::default()
5745 },
5746 )),
5747 },
5748 WebUIFragment::raw(">"),
5749 WebUIFragment::component("my-comp"),
5750 WebUIFragment::raw("</my-comp>"),
5751 ],
5752
5753 contains_boundary: false,
5754 },
5755 );
5756 fragments.insert(
5757 "my-comp".to_string(),
5758 FragmentList {
5759 fragments: vec![
5760 WebUIFragment::raw("<span>"),
5761 WebUIFragment::signal("item.foo", false),
5762 WebUIFragment::raw("</span><p>"),
5763 WebUIFragment::signal("item.bar", false),
5764 WebUIFragment::raw("</p>"),
5765 ],
5766 contains_boundary: false,
5767 },
5768 );
5769 let protocol = WebUIProtocol::new(fragments);
5770 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
5771 let mut writer = TestWriter::new();
5772 handle(
5773 &protocol,
5774 &state,
5775 &RenderOptions::new("index.html", "/"),
5776 &mut writer,
5777 )
5778 .unwrap();
5779 assert_eq!(
5780 writer.get_content(),
5781 "<my-comp><span>1</span><p>true</p></my-comp>"
5782 );
5783 }
5784
5785 #[test]
5786 fn test_component_no_parent_pollution() {
5787 let mut fragments = HashMap::new();
5788 fragments.insert(
5789 "index.html".to_string(),
5790 FragmentList {
5791 fragments: vec![
5792 WebUIFragment::raw("<parent"),
5793 WebUIFragment {
5794 fragment: Some(web_ui_fragment::Fragment::Attribute(
5795 WebUIFragmentAttribute {
5796 name: "var".into(),
5797 value: "var".into(),
5798 attr_start: true,
5799 ..Default::default()
5800 },
5801 )),
5802 },
5803 WebUIFragment::raw(">"),
5804 WebUIFragment::component("parent"),
5805 WebUIFragment::raw("</parent>"),
5806 ],
5807
5808 contains_boundary: false,
5809 },
5810 );
5811 fragments.insert(
5812 "parent".to_string(),
5813 FragmentList {
5814 fragments: vec![
5815 WebUIFragment::raw("Before: "),
5816 WebUIFragment::signal("var", false),
5817 WebUIFragment::raw("<child foo"),
5818 WebUIFragment {
5819 fragment: Some(web_ui_fragment::Fragment::Attribute(
5820 WebUIFragmentAttribute {
5821 name: "var".into(),
5822 value: "replaced".into(),
5823 raw_value: true,
5824 ..Default::default()
5825 },
5826 )),
5827 },
5828 WebUIFragment::raw(">"),
5829 WebUIFragment::component("child"),
5830 WebUIFragment::raw("Label</child>After: "),
5831 WebUIFragment::signal("var", false),
5832 ],
5833
5834 contains_boundary: false,
5835 },
5836 );
5837 fragments.insert(
5838 "child".to_string(),
5839 FragmentList {
5840 fragments: vec![],
5841 contains_boundary: false,
5842 },
5843 );
5844 let protocol = WebUIProtocol::new(fragments);
5845 let state = test_json!({"var": "original<&"});
5846 let mut writer = TestWriter::new();
5847 handle(
5848 &protocol,
5849 &state,
5850 &RenderOptions::new("index.html", "/"),
5851 &mut writer,
5852 )
5853 .unwrap();
5854 assert_eq!(
5855 writer.get_content(),
5856 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
5857 );
5858 }
5859
5860 #[test]
5861 fn test_component_boolean_attr_state() {
5862 let mut fragments = HashMap::new();
5863 fragments.insert(
5864 "index.html".to_string(),
5865 FragmentList {
5866 fragments: vec![
5867 WebUIFragment::raw("<my-comp"),
5868 WebUIFragment {
5869 fragment: Some(web_ui_fragment::Fragment::Attribute(
5870 WebUIFragmentAttribute {
5871 name: "disabled".into(),
5872 attr_start: true,
5873 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
5874 ..Default::default()
5875 },
5876 )),
5877 },
5878 WebUIFragment::raw(">"),
5879 WebUIFragment::component("my-comp"),
5880 WebUIFragment::raw("</my-comp>"),
5881 ],
5882
5883 contains_boundary: false,
5884 },
5885 );
5886 fragments.insert(
5887 "my-comp".to_string(),
5888 FragmentList {
5889 fragments: vec![WebUIFragment::if_cond(
5890 ConditionExpr::identifier("disabled"),
5891 "show",
5892 )],
5893 contains_boundary: false,
5894 },
5895 );
5896 fragments.insert(
5897 "show".to_string(),
5898 FragmentList {
5899 fragments: vec![WebUIFragment::raw("disabled!")],
5900 contains_boundary: false,
5901 },
5902 );
5903 let protocol = WebUIProtocol::new(fragments);
5904 let state = test_json!({"isDisabled": true});
5905 let mut writer = TestWriter::new();
5906 handle(
5907 &protocol,
5908 &state,
5909 &RenderOptions::new("index.html", "/"),
5910 &mut writer,
5911 )
5912 .unwrap();
5913 assert_eq!(
5914 writer.get_content(),
5915 "<my-comp disabled>disabled!</my-comp>"
5916 );
5917 }
5918
5919 fn render_signal(value: &str) -> String {
5923 let mut fragments = HashMap::new();
5924 fragments.insert(
5925 "index.html".to_string(),
5926 FragmentList {
5927 fragments: vec![WebUIFragment::signal("v", false)],
5928 contains_boundary: false,
5929 },
5930 );
5931 let protocol = WebUIProtocol::new(fragments);
5932 let state = test_json!({"v": value});
5933 let mut writer = TestWriter::new();
5934 handle(
5935 &protocol,
5936 &state,
5937 &RenderOptions::new("index.html", "/"),
5938 &mut writer,
5939 )
5940 .unwrap();
5941 writer.get_content()
5942 }
5943
5944 #[test]
5945 fn test_escape_ampersand() {
5946 assert_eq!(render_signal("&"), "&");
5947 }
5948
5949 #[test]
5950 fn test_escape_less_than() {
5951 assert_eq!(render_signal("<"), "<");
5952 }
5953
5954 #[test]
5955 fn test_escape_greater_than() {
5956 assert_eq!(render_signal(">"), ">");
5957 }
5958
5959 #[test]
5960 fn test_escape_double_quote() {
5961 assert_eq!(render_signal("\""), """);
5962 }
5963
5964 #[test]
5965 fn test_escape_single_quote() {
5966 let result = render_signal("'");
5968 assert_eq!(
5969 result, "'",
5970 "Expected ' for single quote, got: {result}"
5971 );
5972 }
5973
5974 #[test]
5975 fn test_escape_multiple_special_chars() {
5976 let result = render_signal("<script>alert('xss');</script>");
5977 assert!(
5978 result.contains("<") && result.contains(">"),
5979 "Expected escaped HTML, got: {}",
5980 result
5981 );
5982 assert!(
5983 !result.contains("<script>"),
5984 "Should not contain raw <script> tag"
5985 );
5986 }
5987
5988 #[test]
5989 fn test_escape_no_special_chars() {
5990 assert_eq!(render_signal("Hello World"), "Hello World");
5991 }
5992
5993 #[test]
5994 fn test_escape_empty_string() {
5995 assert_eq!(render_signal(""), "");
5996 }
5997
5998 #[test]
5999 fn test_escape_special_at_beginning() {
6000 let result = render_signal("<Hello");
6001 assert!(
6002 result.starts_with("<"),
6003 "Expected < at start, got: {}",
6004 result
6005 );
6006 }
6007
6008 #[test]
6009 fn test_escape_special_at_end() {
6010 let result = render_signal("Hello>");
6011 assert!(
6012 result.ends_with(">"),
6013 "Expected > at end, got: {}",
6014 result
6015 );
6016 }
6017
6018 #[test]
6019 fn test_escape_special_in_middle() {
6020 let result = render_signal("Hel&lo");
6021 assert!(
6022 result.contains("&"),
6023 "Expected & in middle, got: {}",
6024 result
6025 );
6026 }
6027
6028 #[test]
6031 fn test_boolean_attr_truthy_values() {
6032 {
6034 let mut fragments = HashMap::new();
6035 fragments.insert(
6036 "index.html".to_string(),
6037 FragmentList {
6038 fragments: vec![
6039 WebUIFragment::raw("<input"),
6040 WebUIFragment::attribute_boolean(
6041 "checked",
6042 ConditionExpr::identifier("checked"),
6043 ),
6044 WebUIFragment::raw(">"),
6045 ],
6046 contains_boundary: false,
6047 },
6048 );
6049 let protocol = WebUIProtocol::new(fragments);
6050 let state = test_json!({"checked": 1});
6051 let mut writer = TestWriter::new();
6052 handle(
6053 &protocol,
6054 &state,
6055 &RenderOptions::new("index.html", "/"),
6056 &mut writer,
6057 )
6058 .unwrap();
6059 assert_eq!(writer.get_content(), "<input checked>");
6060 }
6061 {
6063 let mut fragments = HashMap::new();
6064 fragments.insert(
6065 "index.html".to_string(),
6066 FragmentList {
6067 fragments: vec![
6068 WebUIFragment::raw("<input"),
6069 WebUIFragment::attribute_boolean(
6070 "checked",
6071 ConditionExpr::identifier("checked"),
6072 ),
6073 WebUIFragment::raw(">"),
6074 ],
6075 contains_boundary: false,
6076 },
6077 );
6078 let protocol = WebUIProtocol::new(fragments);
6079 let state = test_json!({"checked": "yes"});
6080 let mut writer = TestWriter::new();
6081 handle(
6082 &protocol,
6083 &state,
6084 &RenderOptions::new("index.html", "/"),
6085 &mut writer,
6086 )
6087 .unwrap();
6088 assert_eq!(writer.get_content(), "<input checked>");
6089 }
6090 {
6092 let mut fragments = HashMap::new();
6093 fragments.insert(
6094 "index.html".to_string(),
6095 FragmentList {
6096 fragments: vec![
6097 WebUIFragment::raw("<input"),
6098 WebUIFragment::attribute_boolean(
6099 "checked",
6100 ConditionExpr::identifier("checked"),
6101 ),
6102 WebUIFragment::raw(">"),
6103 ],
6104 contains_boundary: false,
6105 },
6106 );
6107 let protocol = WebUIProtocol::new(fragments);
6108 let state = test_json!({"checked": {}});
6109 let mut writer = TestWriter::new();
6110 handle(
6111 &protocol,
6112 &state,
6113 &RenderOptions::new("index.html", "/"),
6114 &mut writer,
6115 )
6116 .unwrap();
6117 assert_eq!(writer.get_content(), "<input>");
6119 }
6120 {
6122 let mut fragments = HashMap::new();
6123 fragments.insert(
6124 "index.html".to_string(),
6125 FragmentList {
6126 fragments: vec![
6127 WebUIFragment::raw("<input"),
6128 WebUIFragment::attribute_boolean(
6129 "checked",
6130 ConditionExpr::identifier("checked"),
6131 ),
6132 WebUIFragment::raw(">"),
6133 ],
6134 contains_boundary: false,
6135 },
6136 );
6137 let protocol = WebUIProtocol::new(fragments);
6138 let state = test_json!({"checked": "false"});
6139 let mut writer = TestWriter::new();
6140 handle(
6141 &protocol,
6142 &state,
6143 &RenderOptions::new("index.html", "/"),
6144 &mut writer,
6145 )
6146 .unwrap();
6147 assert_eq!(writer.get_content(), "<input checked>");
6148 }
6149 }
6150
6151 #[test]
6152 fn test_boolean_attr_falsy_values() {
6153 {
6155 let mut fragments = HashMap::new();
6156 fragments.insert(
6157 "index.html".to_string(),
6158 FragmentList {
6159 fragments: vec![
6160 WebUIFragment::raw("<input"),
6161 WebUIFragment::attribute_boolean(
6162 "checked",
6163 ConditionExpr::identifier("checked"),
6164 ),
6165 WebUIFragment::raw(">"),
6166 ],
6167 contains_boundary: false,
6168 },
6169 );
6170 let protocol = WebUIProtocol::new(fragments);
6171 let state = test_json!({"checked": 0});
6172 let mut writer = TestWriter::new();
6173 handle(
6174 &protocol,
6175 &state,
6176 &RenderOptions::new("index.html", "/"),
6177 &mut writer,
6178 )
6179 .unwrap();
6180 assert_eq!(writer.get_content(), "<input>");
6181 }
6182 {
6184 let mut fragments = HashMap::new();
6185 fragments.insert(
6186 "index.html".to_string(),
6187 FragmentList {
6188 fragments: vec![
6189 WebUIFragment::raw("<input"),
6190 WebUIFragment::attribute_boolean(
6191 "checked",
6192 ConditionExpr::identifier("checked"),
6193 ),
6194 WebUIFragment::raw(">"),
6195 ],
6196 contains_boundary: false,
6197 },
6198 );
6199 let protocol = WebUIProtocol::new(fragments);
6200 let state = test_json!({"checked": ""});
6201 let mut writer = TestWriter::new();
6202 handle(
6203 &protocol,
6204 &state,
6205 &RenderOptions::new("index.html", "/"),
6206 &mut writer,
6207 )
6208 .unwrap();
6209 assert_eq!(writer.get_content(), "<input>");
6210 }
6211 {
6213 let mut fragments = HashMap::new();
6214 fragments.insert(
6215 "index.html".to_string(),
6216 FragmentList {
6217 fragments: vec![
6218 WebUIFragment::raw("<input"),
6219 WebUIFragment::attribute_boolean(
6220 "checked",
6221 ConditionExpr::identifier("checked"),
6222 ),
6223 WebUIFragment::raw(">"),
6224 ],
6225 contains_boundary: false,
6226 },
6227 );
6228 let protocol = WebUIProtocol::new(fragments);
6229 let state = test_json!({"checked": false});
6230 let mut writer = TestWriter::new();
6231 handle(
6232 &protocol,
6233 &state,
6234 &RenderOptions::new("index.html", "/"),
6235 &mut writer,
6236 )
6237 .unwrap();
6238 assert_eq!(writer.get_content(), "<input>");
6239 }
6240 {
6242 let mut fragments = HashMap::new();
6243 fragments.insert(
6244 "index.html".to_string(),
6245 FragmentList {
6246 fragments: vec![
6247 WebUIFragment::raw("<input"),
6248 WebUIFragment::attribute_boolean(
6249 "checked",
6250 ConditionExpr::identifier("checked"),
6251 ),
6252 WebUIFragment::raw(">"),
6253 ],
6254 contains_boundary: false,
6255 },
6256 );
6257 let protocol = WebUIProtocol::new(fragments);
6258 let state = test_json!({});
6259 let mut writer = TestWriter::new();
6260 handle(
6261 &protocol,
6262 &state,
6263 &RenderOptions::new("index.html", "/"),
6264 &mut writer,
6265 )
6266 .unwrap();
6267 assert_eq!(writer.get_content(), "<input>");
6268 }
6269 }
6270
6271 #[test]
6272 fn test_boolean_attr_expression_true() {
6273 let mut fragments = HashMap::new();
6274 fragments.insert(
6275 "index.html".to_string(),
6276 FragmentList {
6277 fragments: vec![
6278 WebUIFragment::raw("<button"),
6279 WebUIFragment::attribute_boolean(
6280 "disabled",
6281 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6282 ),
6283 WebUIFragment::raw(">Click</button>"),
6284 ],
6285 contains_boundary: false,
6286 },
6287 );
6288 let protocol = WebUIProtocol::new(fragments);
6289 let state = test_json!({"itemCount": 5});
6290 let mut writer = TestWriter::new();
6291 handle(
6292 &protocol,
6293 &state,
6294 &RenderOptions::new("index.html", "/"),
6295 &mut writer,
6296 )
6297 .unwrap();
6298 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6299 }
6300
6301 #[test]
6302 fn test_boolean_attr_expression_false() {
6303 let mut fragments = HashMap::new();
6304 fragments.insert(
6305 "index.html".to_string(),
6306 FragmentList {
6307 fragments: vec![
6308 WebUIFragment::raw("<button"),
6309 WebUIFragment::attribute_boolean(
6310 "disabled",
6311 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6312 ),
6313 WebUIFragment::raw(">Click</button>"),
6314 ],
6315 contains_boundary: false,
6316 },
6317 );
6318 let protocol = WebUIProtocol::new(fragments);
6319 let state = test_json!({"itemCount": 3});
6320 let mut writer = TestWriter::new();
6321 handle(
6322 &protocol,
6323 &state,
6324 &RenderOptions::new("index.html", "/"),
6325 &mut writer,
6326 )
6327 .unwrap();
6328 assert_eq!(writer.get_content(), "<button>Click</button>");
6329 }
6330
6331 #[test]
6334 fn test_nested_component_attr_capture() {
6335 let mut fragments = HashMap::new();
6336 fragments.insert(
6337 "index.html".to_string(),
6338 FragmentList {
6339 fragments: vec![
6340 WebUIFragment::raw("<parent-component"),
6341 WebUIFragment {
6342 fragment: Some(web_ui_fragment::Fragment::Attribute(
6343 WebUIFragmentAttribute {
6344 name: "title".into(),
6345 template: "parent-title".into(),
6346 attr_start: true,
6347 ..Default::default()
6348 },
6349 )),
6350 },
6351 WebUIFragment::raw(">"),
6352 WebUIFragment::component("parent-component"),
6353 WebUIFragment::raw("</parent-component>"),
6354 ],
6355
6356 contains_boundary: false,
6357 },
6358 );
6359 fragments.insert(
6360 "parent-title".to_string(),
6361 FragmentList {
6362 fragments: vec![
6363 WebUIFragment::raw("Hello "),
6364 WebUIFragment::signal("who", false),
6365 ],
6366 contains_boundary: false,
6367 },
6368 );
6369 fragments.insert(
6370 "parent-component".to_string(),
6371 FragmentList {
6372 fragments: vec![
6373 WebUIFragment::raw("<child-component"),
6374 WebUIFragment {
6375 fragment: Some(web_ui_fragment::Fragment::Attribute(
6376 WebUIFragmentAttribute {
6377 name: "title".into(),
6378 template: "child-title".into(),
6379 attr_start: true,
6380 ..Default::default()
6381 },
6382 )),
6383 },
6384 WebUIFragment::raw(">"),
6385 WebUIFragment::component("child-component"),
6386 WebUIFragment::raw("</child-component>"),
6387 ],
6388
6389 contains_boundary: false,
6390 },
6391 );
6392 fragments.insert(
6393 "child-title".to_string(),
6394 FragmentList {
6395 fragments: vec![
6396 WebUIFragment::raw("Child of "),
6397 WebUIFragment::signal("title", false),
6398 ],
6399 contains_boundary: false,
6400 },
6401 );
6402 fragments.insert(
6403 "child-component".to_string(),
6404 FragmentList {
6405 fragments: vec![
6406 WebUIFragment::raw("<span>"),
6407 WebUIFragment::signal("title", false),
6408 WebUIFragment::raw("</span>"),
6409 ],
6410 contains_boundary: false,
6411 },
6412 );
6413 let protocol = WebUIProtocol::new(fragments);
6414 let state = test_json!({"who": "<world>"});
6415 let mut writer = TestWriter::new();
6416 handle(
6417 &protocol,
6418 &state,
6419 &RenderOptions::new("index.html", "/"),
6420 &mut writer,
6421 )
6422 .unwrap();
6423 assert_eq!(
6424 writer.get_content(),
6425 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
6426 );
6427 }
6428
6429 #[test]
6430 fn test_grandchild_attr_propagation() {
6431 let mut fragments = HashMap::new();
6432 fragments.insert(
6433 "index.html".to_string(),
6434 FragmentList {
6435 fragments: vec![
6436 WebUIFragment::raw("<parent-component"),
6437 WebUIFragment {
6438 fragment: Some(web_ui_fragment::Fragment::Attribute(
6439 WebUIFragmentAttribute {
6440 name: "title".into(),
6441 template: "p-title".into(),
6442 attr_start: true,
6443 ..Default::default()
6444 },
6445 )),
6446 },
6447 WebUIFragment::raw(">"),
6448 WebUIFragment::component("parent-component"),
6449 WebUIFragment::raw("</parent-component>"),
6450 ],
6451
6452 contains_boundary: false,
6453 },
6454 );
6455 fragments.insert(
6456 "p-title".to_string(),
6457 FragmentList {
6458 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6459 contains_boundary: false,
6460 },
6461 );
6462 fragments.insert(
6463 "parent-component".to_string(),
6464 FragmentList {
6465 fragments: vec![
6466 WebUIFragment::raw("<child-component"),
6467 WebUIFragment {
6468 fragment: Some(web_ui_fragment::Fragment::Attribute(
6469 WebUIFragmentAttribute {
6470 name: "title".into(),
6471 template: "c-title".into(),
6472 attr_start: true,
6473 ..Default::default()
6474 },
6475 )),
6476 },
6477 WebUIFragment::raw(">"),
6478 WebUIFragment::component("child-component"),
6479 WebUIFragment::raw("</child-component>"),
6480 ],
6481
6482 contains_boundary: false,
6483 },
6484 );
6485 fragments.insert(
6486 "c-title".to_string(),
6487 FragmentList {
6488 fragments: vec![
6489 WebUIFragment::raw("C("),
6490 WebUIFragment::signal("title", false),
6491 WebUIFragment::raw(")-"),
6492 WebUIFragment::signal("cExtra", false),
6493 ],
6494 contains_boundary: false,
6495 },
6496 );
6497 fragments.insert(
6498 "child-component".to_string(),
6499 FragmentList {
6500 fragments: vec![
6501 WebUIFragment::raw("<grandchild-component"),
6502 WebUIFragment {
6503 fragment: Some(web_ui_fragment::Fragment::Attribute(
6504 WebUIFragmentAttribute {
6505 name: "title".into(),
6506 value: "title".into(),
6507 attr_start: true,
6508 ..Default::default()
6509 },
6510 )),
6511 },
6512 WebUIFragment::raw(">"),
6513 WebUIFragment::component("grandchild-component"),
6514 WebUIFragment::raw("</grandchild-component>"),
6515 ],
6516
6517 contains_boundary: false,
6518 },
6519 );
6520 fragments.insert(
6521 "grandchild-component".to_string(),
6522 FragmentList {
6523 fragments: vec![
6524 WebUIFragment::raw("<span>"),
6525 WebUIFragment::signal("title", false),
6526 WebUIFragment::raw("</span>"),
6527 ],
6528 contains_boundary: false,
6529 },
6530 );
6531 let protocol = WebUIProtocol::new(fragments);
6532 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6533 let mut writer = TestWriter::new();
6534 handle(
6535 &protocol,
6536 &state,
6537 &RenderOptions::new("index.html", "/"),
6538 &mut writer,
6539 )
6540 .unwrap();
6541 assert_eq!(
6542 writer.get_content(),
6543 "<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>"
6544 );
6545 }
6546
6547 #[test]
6548 fn test_for_loop_component_attr() {
6549 let mut fragments = HashMap::new();
6550 fragments.insert(
6551 "index.html".to_string(),
6552 FragmentList {
6553 fragments: vec![
6554 WebUIFragment::raw("<parent-component"),
6555 WebUIFragment {
6556 fragment: Some(web_ui_fragment::Fragment::Attribute(
6557 WebUIFragmentAttribute {
6558 name: "title".into(),
6559 template: "parent-title-loop".into(),
6560 attr_start: true,
6561 ..Default::default()
6562 },
6563 )),
6564 },
6565 WebUIFragment::raw(">"),
6566 WebUIFragment::component("parent-component"),
6567 WebUIFragment::raw("</parent-component>"),
6568 ],
6569
6570 contains_boundary: false,
6571 },
6572 );
6573 fragments.insert(
6574 "parent-title-loop".to_string(),
6575 FragmentList {
6576 fragments: vec![
6577 WebUIFragment::raw("Parent:"),
6578 WebUIFragment::signal("who", false),
6579 ],
6580 contains_boundary: false,
6581 },
6582 );
6583 fragments.insert(
6584 "parent-component".to_string(),
6585 FragmentList {
6586 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6587 contains_boundary: false,
6588 },
6589 );
6590 fragments.insert(
6591 "child-loop".to_string(),
6592 FragmentList {
6593 fragments: vec![
6594 WebUIFragment::raw("<child-component"),
6595 WebUIFragment {
6596 fragment: Some(web_ui_fragment::Fragment::Attribute(
6597 WebUIFragmentAttribute {
6598 name: "title".into(),
6599 template: "child-title-loop".into(),
6600 attr_start: true,
6601 ..Default::default()
6602 },
6603 )),
6604 },
6605 WebUIFragment::raw(">"),
6606 WebUIFragment::component("child-component"),
6607 WebUIFragment::raw("</child-component>"),
6608 ],
6609
6610 contains_boundary: false,
6611 },
6612 );
6613 fragments.insert(
6614 "child-title-loop".to_string(),
6615 FragmentList {
6616 fragments: vec![
6617 WebUIFragment::raw("Hi "),
6618 WebUIFragment::signal("item.name", false),
6619 WebUIFragment::raw(" / "),
6620 WebUIFragment::signal("title", false),
6621 ],
6622 contains_boundary: false,
6623 },
6624 );
6625 fragments.insert(
6626 "child-component".to_string(),
6627 FragmentList {
6628 fragments: vec![
6629 WebUIFragment::raw("<span>"),
6630 WebUIFragment::signal("title", false),
6631 WebUIFragment::raw("</span>"),
6632 ],
6633 contains_boundary: false,
6634 },
6635 );
6636 let protocol = WebUIProtocol::new(fragments);
6637 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
6638 let mut writer = TestWriter::new();
6639 handle(
6640 &protocol,
6641 &state,
6642 &RenderOptions::new("index.html", "/"),
6643 &mut writer,
6644 )
6645 .unwrap();
6646 assert_eq!(
6647 writer.get_content(),
6648 "<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>"
6649 );
6650 }
6651
6652 #[test]
6653 fn test_multiple_template_attrs() {
6654 let mut fragments = HashMap::new();
6655 fragments.insert(
6656 "index.html".to_string(),
6657 FragmentList {
6658 fragments: vec![
6659 WebUIFragment::raw("<my-component"),
6660 WebUIFragment {
6661 fragment: Some(web_ui_fragment::Fragment::Attribute(
6662 WebUIFragmentAttribute {
6663 name: "title".into(),
6664 template: "attr-title".into(),
6665 attr_start: true,
6666 ..Default::default()
6667 },
6668 )),
6669 },
6670 WebUIFragment {
6671 fragment: Some(web_ui_fragment::Fragment::Attribute(
6672 WebUIFragmentAttribute {
6673 name: "data-title".into(),
6674 template: "attr-data-title".into(),
6675 attr_start: false,
6676 ..Default::default()
6677 },
6678 )),
6679 },
6680 WebUIFragment {
6681 fragment: Some(web_ui_fragment::Fragment::Attribute(
6682 WebUIFragmentAttribute {
6683 name: "aria-label".into(),
6684 template: "attr-aria-label".into(),
6685 attr_start: false,
6686 ..Default::default()
6687 },
6688 )),
6689 },
6690 WebUIFragment::raw(">"),
6691 WebUIFragment::component("my-component"),
6692 WebUIFragment::raw("</my-component>"),
6693 ],
6694
6695 contains_boundary: false,
6696 },
6697 );
6698 fragments.insert(
6699 "attr-title".to_string(),
6700 FragmentList {
6701 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
6702 contains_boundary: false,
6703 },
6704 );
6705 fragments.insert(
6706 "attr-data-title".to_string(),
6707 FragmentList {
6708 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
6709 contains_boundary: false,
6710 },
6711 );
6712 fragments.insert(
6713 "attr-aria-label".to_string(),
6714 FragmentList {
6715 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
6716 contains_boundary: false,
6717 },
6718 );
6719 fragments.insert(
6720 "my-component".to_string(),
6721 FragmentList {
6722 fragments: vec![
6723 WebUIFragment::raw("<span>"),
6724 WebUIFragment::signal("title", false),
6725 WebUIFragment::raw("|"),
6726 WebUIFragment::signal("dataTitle", false),
6727 WebUIFragment::raw("|"),
6728 WebUIFragment::signal("ariaLabel", false),
6729 WebUIFragment::raw("</span>"),
6730 ],
6731 contains_boundary: false,
6732 },
6733 );
6734 let protocol = WebUIProtocol::new(fragments);
6735 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
6736 let mut writer = TestWriter::new();
6737 handle(
6738 &protocol,
6739 &state,
6740 &RenderOptions::new("index.html", "/"),
6741 &mut writer,
6742 )
6743 .unwrap();
6744 assert_eq!(
6745 writer.get_content(),
6746 "<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>"
6747 );
6748 }
6749
6750 #[test]
6751 fn test_attr_priority_over_global() {
6752 let mut fragments = HashMap::new();
6753 fragments.insert(
6754 "index.html".to_string(),
6755 FragmentList {
6756 fragments: vec![
6757 WebUIFragment::raw("<my-component"),
6758 WebUIFragment {
6759 fragment: Some(web_ui_fragment::Fragment::Attribute(
6760 WebUIFragmentAttribute {
6761 name: "title".into(),
6762 value: "Attribute Title".into(),
6763 raw_value: true,
6764 attr_start: true,
6765 ..Default::default()
6766 },
6767 )),
6768 },
6769 WebUIFragment::raw(">"),
6770 WebUIFragment::component("my-component"),
6771 WebUIFragment::raw("</my-component>"),
6772 ],
6773
6774 contains_boundary: false,
6775 },
6776 );
6777 fragments.insert(
6778 "my-component".to_string(),
6779 FragmentList {
6780 fragments: vec![
6781 WebUIFragment::raw("<span>"),
6782 WebUIFragment::signal("title", false),
6783 WebUIFragment::raw("</span>"),
6784 ],
6785 contains_boundary: false,
6786 },
6787 );
6788 let protocol = WebUIProtocol::new(fragments);
6789 let state = test_json!({"title": "Global Title"});
6790 let mut writer = TestWriter::new();
6791 handle(
6792 &protocol,
6793 &state,
6794 &RenderOptions::new("index.html", "/"),
6795 &mut writer,
6796 )
6797 .unwrap();
6798 assert_eq!(
6799 writer.get_content(),
6800 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6801 );
6802 }
6803
6804 #[test]
6805 fn test_attr_priority_over_local_and_global() {
6806 let mut fragments = HashMap::new();
6807 fragments.insert(
6808 "index.html".to_string(),
6809 FragmentList {
6810 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
6811 contains_boundary: false,
6812 },
6813 );
6814 fragments.insert(
6815 "loop".to_string(),
6816 FragmentList {
6817 fragments: vec![
6818 WebUIFragment::raw("<my-component"),
6819 WebUIFragment {
6820 fragment: Some(web_ui_fragment::Fragment::Attribute(
6821 WebUIFragmentAttribute {
6822 name: "title".into(),
6823 value: "Attribute Title".into(),
6824 raw_value: true,
6825 attr_start: true,
6826 ..Default::default()
6827 },
6828 )),
6829 },
6830 WebUIFragment::raw(">"),
6831 WebUIFragment::component("my-component"),
6832 WebUIFragment::raw("</my-component>"),
6833 ],
6834
6835 contains_boundary: false,
6836 },
6837 );
6838 fragments.insert(
6839 "my-component".to_string(),
6840 FragmentList {
6841 fragments: vec![
6842 WebUIFragment::raw("<span>"),
6843 WebUIFragment::signal("title", false),
6844 WebUIFragment::raw("</span>"),
6845 ],
6846 contains_boundary: false,
6847 },
6848 );
6849 let protocol = WebUIProtocol::new(fragments);
6850 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
6851 let mut writer = TestWriter::new();
6852 handle(
6853 &protocol,
6854 &state,
6855 &RenderOptions::new("index.html", "/"),
6856 &mut writer,
6857 )
6858 .unwrap();
6859 assert_eq!(
6860 writer.get_content(),
6861 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
6862 );
6863 }
6864
6865 #[test]
6866 fn test_boolean_attr_first_component_attr() {
6867 let mut fragments = HashMap::new();
6868 fragments.insert(
6869 "index.html".to_string(),
6870 FragmentList {
6871 fragments: vec![
6872 WebUIFragment::raw("<my-component"),
6873 WebUIFragment {
6874 fragment: Some(web_ui_fragment::Fragment::Attribute(
6875 WebUIFragmentAttribute {
6876 name: "disabled".into(),
6877 attr_start: true,
6878 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6879 ..Default::default()
6880 },
6881 )),
6882 },
6883 WebUIFragment {
6884 fragment: Some(web_ui_fragment::Fragment::Attribute(
6885 WebUIFragmentAttribute {
6886 name: "label".into(),
6887 value: "Component Label".into(),
6888 raw_value: true,
6889 attr_start: false,
6890 ..Default::default()
6891 },
6892 )),
6893 },
6894 WebUIFragment::raw(">"),
6895 WebUIFragment::component("my-component"),
6896 WebUIFragment::raw("</my-component>"),
6897 ],
6898
6899 contains_boundary: false,
6900 },
6901 );
6902 fragments.insert(
6903 "my-component".to_string(),
6904 FragmentList {
6905 fragments: vec![
6906 WebUIFragment::if_cond(
6907 ConditionExpr::identifier("disabled"),
6908 "disabledTemplate",
6909 ),
6910 WebUIFragment::raw("<span>"),
6911 WebUIFragment::signal("label", false),
6912 WebUIFragment::raw("</span>"),
6913 ],
6914 contains_boundary: false,
6915 },
6916 );
6917 fragments.insert(
6918 "disabledTemplate".to_string(),
6919 FragmentList {
6920 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
6921 contains_boundary: false,
6922 },
6923 );
6924 let protocol = WebUIProtocol::new(fragments);
6925 let state = test_json!({"isDisabled": true});
6926 let mut writer = TestWriter::new();
6927 handle(
6928 &protocol,
6929 &state,
6930 &RenderOptions::new("index.html", "/"),
6931 &mut writer,
6932 )
6933 .unwrap();
6934 assert_eq!(
6935 writer.get_content(),
6936 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
6937 );
6938 }
6939
6940 #[test]
6941 fn test_hyphenated_attr_camelcase() {
6942 let mut fragments = HashMap::new();
6943 fragments.insert(
6944 "index.html".to_string(),
6945 FragmentList {
6946 fragments: vec![
6947 WebUIFragment::raw("<my-component"),
6948 WebUIFragment {
6949 fragment: Some(web_ui_fragment::Fragment::Attribute(
6950 WebUIFragmentAttribute {
6951 name: "key-hyphen".into(),
6952 value: "Local Value".into(),
6953 raw_value: true,
6954 attr_start: true,
6955 ..Default::default()
6956 },
6957 )),
6958 },
6959 WebUIFragment::raw(">"),
6960 WebUIFragment::component("my-component"),
6961 WebUIFragment::raw("</my-component>"),
6962 ],
6963
6964 contains_boundary: false,
6965 },
6966 );
6967 fragments.insert(
6968 "my-component".to_string(),
6969 FragmentList {
6970 fragments: vec![
6971 WebUIFragment::raw("<span>"),
6972 WebUIFragment::signal("keyHyphen", false),
6973 WebUIFragment::raw("</span>"),
6974 ],
6975 contains_boundary: false,
6976 },
6977 );
6978 let protocol = WebUIProtocol::new(fragments);
6979 let state = test_json!({"keyHyphen": "Global Value"});
6980 let mut writer = TestWriter::new();
6981 handle(
6982 &protocol,
6983 &state,
6984 &RenderOptions::new("index.html", "/"),
6985 &mut writer,
6986 )
6987 .unwrap();
6988 assert_eq!(
6989 writer.get_content(),
6990 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
6991 );
6992 }
6993
6994 #[test]
6995 fn test_skipped_component_attrs() {
6996 let mut fragments = HashMap::new();
7000 fragments.insert(
7001 "index.html".to_string(),
7002 FragmentList {
7003 fragments: vec![
7004 WebUIFragment::raw("<test-component"),
7005 WebUIFragment {
7007 fragment: Some(web_ui_fragment::Fragment::Attribute(
7008 WebUIFragmentAttribute {
7009 name: "class".into(),
7010 value: "skippedClass".into(),
7011 attr_start: true,
7012 attr_skip: true,
7013 ..Default::default()
7014 },
7015 )),
7016 },
7017 WebUIFragment {
7019 fragment: Some(web_ui_fragment::Fragment::Attribute(
7020 WebUIFragmentAttribute {
7021 name: "style".into(),
7022 value: "skippedStyle".into(),
7023 attr_skip: true,
7024 ..Default::default()
7025 },
7026 )),
7027 },
7028 WebUIFragment {
7030 fragment: Some(web_ui_fragment::Fragment::Attribute(
7031 WebUIFragmentAttribute {
7032 name: "role".into(),
7033 value: "skippedRole".into(),
7034 attr_skip: true,
7035 ..Default::default()
7036 },
7037 )),
7038 },
7039 WebUIFragment {
7041 fragment: Some(web_ui_fragment::Fragment::Attribute(
7042 WebUIFragmentAttribute {
7043 name: "data-testid".into(),
7044 value: "skippedDataTestid".into(),
7045 attr_skip: true,
7046 ..Default::default()
7047 },
7048 )),
7049 },
7050 WebUIFragment {
7052 fragment: Some(web_ui_fragment::Fragment::Attribute(
7053 WebUIFragmentAttribute {
7054 name: "aria-label".into(),
7055 value: "skippedAriaLabel".into(),
7056 attr_skip: true,
7057 ..Default::default()
7058 },
7059 )),
7060 },
7061 WebUIFragment {
7063 fragment: Some(web_ui_fragment::Fragment::Attribute(
7064 WebUIFragmentAttribute {
7065 name: "title".into(),
7066 value: "title".into(),
7067 ..Default::default()
7068 },
7069 )),
7070 },
7071 WebUIFragment::raw(">"),
7072 WebUIFragment::component("test-component"),
7073 WebUIFragment::raw("</test-component>"),
7074 ],
7075
7076 contains_boundary: false,
7077 },
7078 );
7079 fragments.insert(
7080 "test-component".to_string(),
7081 FragmentList {
7082 fragments: vec![
7083 WebUIFragment::raw("<span>"),
7084 WebUIFragment::signal("title", false),
7085 WebUIFragment::raw("-"),
7086 WebUIFragment::signal("class", false),
7087 WebUIFragment::raw("-"),
7088 WebUIFragment::signal("style", false),
7089 WebUIFragment::raw("-"),
7090 WebUIFragment::signal("role", false),
7091 WebUIFragment::raw("-"),
7092 WebUIFragment::signal("dataTestid", false),
7093 WebUIFragment::raw("-"),
7094 WebUIFragment::signal("ariaLabel", false),
7095 WebUIFragment::raw("</span>"),
7096 ],
7097 contains_boundary: false,
7098 },
7099 );
7100 let protocol = WebUIProtocol::new(fragments);
7101 let state = test_json!({
7102 "title": "Hello",
7103 "skippedClass": "my-class",
7104 "skippedStyle": "color:red",
7105 "skippedRole": "button",
7106 "skippedDataTestid": "test-id",
7107 "skippedAriaLabel": "label-text"
7108 });
7109 let mut writer = TestWriter::new();
7110 handle(
7111 &protocol,
7112 &state,
7113 &RenderOptions::new("index.html", "/"),
7114 &mut writer,
7115 )
7116 .unwrap();
7117 assert_eq!(
7121 writer.get_content(),
7122 "<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>"
7123 );
7124 }
7125
7126 #[test]
7129 fn test_attr_inherit_parent_to_child() {
7130 let mut fragments = HashMap::new();
7131 fragments.insert(
7132 "index.html".to_string(),
7133 FragmentList {
7134 fragments: vec![
7135 WebUIFragment::raw("<parent-component"),
7136 WebUIFragment {
7137 fragment: Some(web_ui_fragment::Fragment::Attribute(
7138 WebUIFragmentAttribute {
7139 name: "title".into(),
7140 value: "Parent Title".into(),
7141 raw_value: true,
7142 attr_start: true,
7143 ..Default::default()
7144 },
7145 )),
7146 },
7147 WebUIFragment::raw(">"),
7148 WebUIFragment::component("parent-component"),
7149 WebUIFragment::raw("</parent-component>"),
7150 ],
7151
7152 contains_boundary: false,
7153 },
7154 );
7155 fragments.insert(
7156 "parent-component".to_string(),
7157 FragmentList {
7158 fragments: vec![
7159 WebUIFragment::raw("<h1>"),
7160 WebUIFragment::signal("title", false),
7161 WebUIFragment::raw("</h1><child-component"),
7162 WebUIFragment {
7163 fragment: Some(web_ui_fragment::Fragment::Attribute(
7164 WebUIFragmentAttribute {
7165 name: "title".into(),
7166 value: "title".into(),
7167 attr_start: true,
7168 ..Default::default()
7169 },
7170 )),
7171 },
7172 WebUIFragment::raw(">"),
7173 WebUIFragment::component("child-component"),
7174 WebUIFragment::raw("</child-component>"),
7175 ],
7176
7177 contains_boundary: false,
7178 },
7179 );
7180 fragments.insert(
7181 "child-component".to_string(),
7182 FragmentList {
7183 fragments: vec![
7184 WebUIFragment::raw("<h2>"),
7185 WebUIFragment::signal("title", false),
7186 WebUIFragment::raw("</h2>"),
7187 ],
7188 contains_boundary: false,
7189 },
7190 );
7191 let protocol = WebUIProtocol::new(fragments);
7192 let state = test_json!({});
7193 let mut writer = TestWriter::new();
7194 handle(
7195 &protocol,
7196 &state,
7197 &RenderOptions::new("index.html", "/"),
7198 &mut writer,
7199 )
7200 .unwrap();
7201 assert_eq!(
7202 writer.get_content(),
7203 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7204 );
7205 }
7206
7207 #[test]
7208 fn test_attr_inherit_deep() {
7209 let mut fragments = HashMap::new();
7210 fragments.insert(
7211 "index.html".to_string(),
7212 FragmentList {
7213 fragments: vec![
7214 WebUIFragment::raw("<parent-component"),
7215 WebUIFragment {
7216 fragment: Some(web_ui_fragment::Fragment::Attribute(
7217 WebUIFragmentAttribute {
7218 name: "title".into(),
7219 value: "Parent Title".into(),
7220 raw_value: true,
7221 attr_start: true,
7222 ..Default::default()
7223 },
7224 )),
7225 },
7226 WebUIFragment::raw(">"),
7227 WebUIFragment::component("parent-component"),
7228 WebUIFragment::raw("</parent-component>"),
7229 ],
7230
7231 contains_boundary: false,
7232 },
7233 );
7234 fragments.insert(
7235 "parent-component".to_string(),
7236 FragmentList {
7237 fragments: vec![
7238 WebUIFragment::raw("<child-component"),
7239 WebUIFragment {
7240 fragment: Some(web_ui_fragment::Fragment::Attribute(
7241 WebUIFragmentAttribute {
7242 name: "title".into(),
7243 value: "Child Title".into(),
7244 raw_value: true,
7245 attr_start: true,
7246 ..Default::default()
7247 },
7248 )),
7249 },
7250 WebUIFragment::raw(">"),
7251 WebUIFragment::component("child-component"),
7252 WebUIFragment::raw("</child-component>"),
7253 ],
7254
7255 contains_boundary: false,
7256 },
7257 );
7258 fragments.insert(
7259 "child-component".to_string(),
7260 FragmentList {
7261 fragments: vec![
7262 WebUIFragment::raw("<grandchild-component"),
7263 WebUIFragment {
7264 fragment: Some(web_ui_fragment::Fragment::Attribute(
7265 WebUIFragmentAttribute {
7266 name: "title".into(),
7267 value: "title".into(),
7268 attr_start: true,
7269 ..Default::default()
7270 },
7271 )),
7272 },
7273 WebUIFragment::raw(">"),
7274 WebUIFragment::component("grandchild-component"),
7275 WebUIFragment::raw("</grandchild-component>"),
7276 ],
7277
7278 contains_boundary: false,
7279 },
7280 );
7281 fragments.insert(
7282 "grandchild-component".to_string(),
7283 FragmentList {
7284 fragments: vec![
7285 WebUIFragment::raw("<h3>"),
7286 WebUIFragment::signal("title", false),
7287 WebUIFragment::raw("</h3>"),
7288 ],
7289 contains_boundary: false,
7290 },
7291 );
7292 let protocol = WebUIProtocol::new(fragments);
7293 let state = test_json!({});
7294 let mut writer = TestWriter::new();
7295 handle(
7296 &protocol,
7297 &state,
7298 &RenderOptions::new("index.html", "/"),
7299 &mut writer,
7300 )
7301 .unwrap();
7302 assert_eq!(
7303 writer.get_content(),
7304 "<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>"
7305 );
7306 }
7307
7308 #[test]
7309 fn test_complex_attr_access() {
7310 let mut fragments = HashMap::new();
7311 fragments.insert(
7312 "index.html".to_string(),
7313 FragmentList {
7314 fragments: vec![
7315 WebUIFragment::raw("<my-component"),
7316 WebUIFragment {
7317 fragment: Some(web_ui_fragment::Fragment::Attribute(
7318 WebUIFragmentAttribute {
7319 name: ":item".into(),
7320 value: "complexItem".into(),
7321 attr_start: true,
7322 complex: true,
7323 ..Default::default()
7324 },
7325 )),
7326 },
7327 WebUIFragment::raw(">"),
7328 WebUIFragment::component("my-component"),
7329 WebUIFragment::raw("</my-component>"),
7330 ],
7331
7332 contains_boundary: false,
7333 },
7334 );
7335 fragments.insert(
7336 "my-component".to_string(),
7337 FragmentList {
7338 fragments: vec![
7339 WebUIFragment::raw("<span>"),
7340 WebUIFragment::signal("item.foo", false),
7341 WebUIFragment::raw("</span><p>"),
7342 WebUIFragment::signal("item.bar", false),
7343 WebUIFragment::raw("</p>"),
7344 ],
7345 contains_boundary: false,
7346 },
7347 );
7348 let protocol = WebUIProtocol::new(fragments);
7349 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7350 let mut writer = TestWriter::new();
7351 handle(
7352 &protocol,
7353 &state,
7354 &RenderOptions::new("index.html", "/"),
7355 &mut writer,
7356 )
7357 .unwrap();
7358 assert_eq!(
7359 writer.get_content(),
7360 "<my-component><span>1</span><p>true</p></my-component>"
7361 );
7362 }
7363
7364 #[test]
7365 fn test_complex_attr_for_loop() {
7366 let mut fragments = HashMap::new();
7367 fragments.insert(
7368 "index.html".to_string(),
7369 FragmentList {
7370 fragments: vec![WebUIFragment::for_loop(
7371 "item",
7372 "list.items",
7373 "listTemplate",
7374 )],
7375 contains_boundary: false,
7376 },
7377 );
7378 fragments.insert(
7379 "listTemplate".to_string(),
7380 FragmentList {
7381 fragments: vec![
7382 WebUIFragment {
7383 fragment: Some(web_ui_fragment::Fragment::Attribute(
7384 WebUIFragmentAttribute {
7385 name: ":item".into(),
7386 value: "item".into(),
7387 attr_start: true,
7388 complex: true,
7389 ..Default::default()
7390 },
7391 )),
7392 },
7393 WebUIFragment::component("item_component"),
7394 ],
7395
7396 contains_boundary: false,
7397 },
7398 );
7399 fragments.insert(
7400 "item_component".to_string(),
7401 FragmentList {
7402 fragments: vec![
7403 WebUIFragment::raw("<span>"),
7404 WebUIFragment::signal("item.name", false),
7405 WebUIFragment::raw("</span>"),
7406 ],
7407 contains_boundary: false,
7408 },
7409 );
7410 let protocol = WebUIProtocol::new(fragments);
7411 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7412 let mut writer = TestWriter::new();
7413 handle(
7414 &protocol,
7415 &state,
7416 &RenderOptions::new("index.html", "/"),
7417 &mut writer,
7418 )
7419 .unwrap();
7420 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7421 }
7422
7423 #[test]
7424 fn test_complex_attr_nested_for() {
7425 let mut fragments = HashMap::new();
7426 fragments.insert(
7427 "index.html".to_string(),
7428 FragmentList {
7429 fragments: vec![WebUIFragment::for_loop(
7430 "outer",
7431 "data.outer",
7432 "outerTemplate",
7433 )],
7434 contains_boundary: false,
7435 },
7436 );
7437 fragments.insert(
7438 "outerTemplate".to_string(),
7439 FragmentList {
7440 fragments: vec![WebUIFragment::for_loop(
7441 "middle",
7442 "outer.middle",
7443 "middleTemplate",
7444 )],
7445 contains_boundary: false,
7446 },
7447 );
7448 fragments.insert(
7449 "middleTemplate".to_string(),
7450 FragmentList {
7451 fragments: vec![WebUIFragment::for_loop(
7452 "inner",
7453 "middle.inner",
7454 "innerTemplate",
7455 )],
7456 contains_boundary: false,
7457 },
7458 );
7459 fragments.insert(
7460 "innerTemplate".to_string(),
7461 FragmentList {
7462 fragments: vec![
7463 WebUIFragment::raw("<card"),
7464 WebUIFragment {
7465 fragment: Some(web_ui_fragment::Fragment::Attribute(
7466 WebUIFragmentAttribute {
7467 name: ":outer".into(),
7468 value: "outer".into(),
7469 attr_start: true,
7470 complex: true,
7471 ..Default::default()
7472 },
7473 )),
7474 },
7475 WebUIFragment {
7476 fragment: Some(web_ui_fragment::Fragment::Attribute(
7477 WebUIFragmentAttribute {
7478 name: ":middle".into(),
7479 value: "middle".into(),
7480 attr_start: false,
7481 complex: true,
7482 ..Default::default()
7483 },
7484 )),
7485 },
7486 WebUIFragment {
7487 fragment: Some(web_ui_fragment::Fragment::Attribute(
7488 WebUIFragmentAttribute {
7489 name: ":inner".into(),
7490 value: "inner".into(),
7491 attr_start: false,
7492 complex: true,
7493 ..Default::default()
7494 },
7495 )),
7496 },
7497 WebUIFragment::raw(">"),
7498 WebUIFragment::component("card_component"),
7499 WebUIFragment::raw("</card>"),
7500 ],
7501
7502 contains_boundary: false,
7503 },
7504 );
7505 fragments.insert(
7506 "card_component".to_string(),
7507 FragmentList {
7508 fragments: vec![
7509 WebUIFragment::raw("<p>"),
7510 WebUIFragment::signal("outer.label", false),
7511 WebUIFragment::raw(" / "),
7512 WebUIFragment::signal("middle.label", false),
7513 WebUIFragment::raw(" / "),
7514 WebUIFragment::signal("inner.label", false),
7515 WebUIFragment::raw("</p>"),
7516 ],
7517 contains_boundary: false,
7518 },
7519 );
7520 let protocol = WebUIProtocol::new(fragments);
7521 let state = test_json!({"data": {"outer": [
7522 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7523 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7524 ]}});
7525 let mut writer = TestWriter::new();
7526 handle(
7527 &protocol,
7528 &state,
7529 &RenderOptions::new("index.html", "/"),
7530 &mut writer,
7531 )
7532 .unwrap();
7533 assert_eq!(
7534 writer.get_content(),
7535 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7536 );
7537 }
7538
7539 #[test]
7542 fn test_bool_component_state_true() {
7543 let mut fragments = HashMap::new();
7544 fragments.insert(
7545 "index.html".to_string(),
7546 FragmentList {
7547 fragments: vec![
7548 WebUIFragment::raw("<my-component"),
7549 WebUIFragment {
7550 fragment: Some(web_ui_fragment::Fragment::Attribute(
7551 WebUIFragmentAttribute {
7552 name: "disabled".into(),
7553 attr_start: true,
7554 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7555 ..Default::default()
7556 },
7557 )),
7558 },
7559 WebUIFragment::raw(">"),
7560 WebUIFragment::component("my-component"),
7561 WebUIFragment::raw("</my-component>"),
7562 ],
7563
7564 contains_boundary: false,
7565 },
7566 );
7567 fragments.insert(
7568 "my-component".to_string(),
7569 FragmentList {
7570 fragments: vec![
7571 WebUIFragment::if_cond(
7572 ConditionExpr::identifier("disabled"),
7573 "disabledTemplate",
7574 ),
7575 WebUIFragment::if_cond(
7576 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7577 "enabledTemplate",
7578 ),
7579 ],
7580 contains_boundary: false,
7581 },
7582 );
7583 fragments.insert(
7584 "disabledTemplate".to_string(),
7585 FragmentList {
7586 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7587 contains_boundary: false,
7588 },
7589 );
7590 fragments.insert(
7591 "enabledTemplate".to_string(),
7592 FragmentList {
7593 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7594 contains_boundary: false,
7595 },
7596 );
7597 let protocol = WebUIProtocol::new(fragments);
7598 let state = test_json!({"isDisabled": true});
7599 let mut writer = TestWriter::new();
7600 handle(
7601 &protocol,
7602 &state,
7603 &RenderOptions::new("index.html", "/"),
7604 &mut writer,
7605 )
7606 .unwrap();
7607 assert_eq!(
7608 writer.get_content(),
7609 "<my-component disabled><span>Disabled</span></my-component>"
7610 );
7611 }
7612
7613 #[test]
7614 fn test_bool_component_state_false() {
7615 let mut fragments = HashMap::new();
7616 fragments.insert(
7617 "index.html".to_string(),
7618 FragmentList {
7619 fragments: vec![
7620 WebUIFragment::raw("<my-component"),
7621 WebUIFragment {
7622 fragment: Some(web_ui_fragment::Fragment::Attribute(
7623 WebUIFragmentAttribute {
7624 name: "disabled".into(),
7625 attr_start: true,
7626 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7627 ..Default::default()
7628 },
7629 )),
7630 },
7631 WebUIFragment::raw(">"),
7632 WebUIFragment::component("my-component"),
7633 WebUIFragment::raw("</my-component>"),
7634 ],
7635
7636 contains_boundary: false,
7637 },
7638 );
7639 fragments.insert(
7640 "my-component".to_string(),
7641 FragmentList {
7642 fragments: vec![
7643 WebUIFragment::if_cond(
7644 ConditionExpr::identifier("disabled"),
7645 "disabledTemplate",
7646 ),
7647 WebUIFragment::if_cond(
7648 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7649 "enabledTemplate",
7650 ),
7651 ],
7652 contains_boundary: false,
7653 },
7654 );
7655 fragments.insert(
7656 "disabledTemplate".to_string(),
7657 FragmentList {
7658 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7659 contains_boundary: false,
7660 },
7661 );
7662 fragments.insert(
7663 "enabledTemplate".to_string(),
7664 FragmentList {
7665 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
7666 contains_boundary: false,
7667 },
7668 );
7669 let protocol = WebUIProtocol::new(fragments);
7670 let state = test_json!({"isDisabled": false});
7671 let mut writer = TestWriter::new();
7672 handle(
7673 &protocol,
7674 &state,
7675 &RenderOptions::new("index.html", "/"),
7676 &mut writer,
7677 )
7678 .unwrap();
7679 assert_eq!(
7680 writer.get_content(),
7681 "<my-component><span>Enabled</span></my-component>"
7682 );
7683 }
7684
7685 #[test]
7686 fn test_bool_component_state_forward() {
7687 let mut fragments = HashMap::new();
7688 fragments.insert(
7689 "index.html".to_string(),
7690 FragmentList {
7691 fragments: vec![
7692 WebUIFragment::raw("<parent-component"),
7693 WebUIFragment {
7694 fragment: Some(web_ui_fragment::Fragment::Attribute(
7695 WebUIFragmentAttribute {
7696 name: "disabled".into(),
7697 attr_start: true,
7698 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7699 ..Default::default()
7700 },
7701 )),
7702 },
7703 WebUIFragment::raw(">"),
7704 WebUIFragment::component("parent-component"),
7705 WebUIFragment::raw("</parent-component>"),
7706 ],
7707
7708 contains_boundary: false,
7709 },
7710 );
7711 fragments.insert(
7712 "parent-component".to_string(),
7713 FragmentList {
7714 fragments: vec![
7715 WebUIFragment::if_cond(
7716 ConditionExpr::identifier("disabled"),
7717 "parentDisabledTemplate",
7718 ),
7719 WebUIFragment::raw("<child-component"),
7720 WebUIFragment {
7721 fragment: Some(web_ui_fragment::Fragment::Attribute(
7722 WebUIFragmentAttribute {
7723 name: "disabled".into(),
7724 attr_start: true,
7725 condition_tree: Some(ConditionExpr::identifier("disabled")),
7726 ..Default::default()
7727 },
7728 )),
7729 },
7730 WebUIFragment::raw(">"),
7731 WebUIFragment::component("child-component"),
7732 WebUIFragment::raw("</child-component>"),
7733 ],
7734
7735 contains_boundary: false,
7736 },
7737 );
7738 fragments.insert(
7739 "parentDisabledTemplate".to_string(),
7740 FragmentList {
7741 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
7742 contains_boundary: false,
7743 },
7744 );
7745 fragments.insert(
7746 "child-component".to_string(),
7747 FragmentList {
7748 fragments: vec![
7749 WebUIFragment::if_cond(
7750 ConditionExpr::identifier("disabled"),
7751 "childDisabledTemplate",
7752 ),
7753 WebUIFragment::if_cond(
7754 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7755 "childEnabledTemplate",
7756 ),
7757 ],
7758 contains_boundary: false,
7759 },
7760 );
7761 fragments.insert(
7762 "childDisabledTemplate".to_string(),
7763 FragmentList {
7764 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
7765 contains_boundary: false,
7766 },
7767 );
7768 fragments.insert(
7769 "childEnabledTemplate".to_string(),
7770 FragmentList {
7771 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
7772 contains_boundary: false,
7773 },
7774 );
7775
7776 {
7778 let protocol = WebUIProtocol::new(fragments.clone());
7779 let state = test_json!({"isDisabled": true});
7780 let mut writer = TestWriter::new();
7781 handle(
7782 &protocol,
7783 &state,
7784 &RenderOptions::new("index.html", "/"),
7785 &mut writer,
7786 )
7787 .unwrap();
7788 assert_eq!(
7789 writer.get_content(),
7790 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
7791 );
7792 }
7793
7794 {
7796 let protocol = WebUIProtocol::new(fragments.clone());
7797 let state = test_json!({"isDisabled": false});
7798 let mut writer = TestWriter::new();
7799 handle(
7800 &protocol,
7801 &state,
7802 &RenderOptions::new("index.html", "/"),
7803 &mut writer,
7804 )
7805 .unwrap();
7806 assert_eq!(
7807 writer.get_content(),
7808 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
7809 );
7810 }
7811 }
7812
7813 #[test]
7820 fn test_component_with_template() {
7821 let mut fragments = HashMap::new();
7822 fragments.insert(
7823 "index.html".to_string(),
7824 FragmentList {
7825 fragments: vec![
7826 WebUIFragment::raw("<custom-element>"),
7827 WebUIFragment::component("custom-element"),
7828 WebUIFragment::raw("</custom-element>"),
7829 ],
7830 contains_boundary: false,
7831 },
7832 );
7833 fragments.insert(
7834 "custom-element".to_string(),
7835 FragmentList {
7836 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
7837 contains_boundary: false,
7838 },
7839 );
7840 let protocol = WebUIProtocol::new(fragments);
7841 let state = test_json!({});
7842 let mut writer = TestWriter::new();
7843 handle(
7844 &protocol,
7845 &state,
7846 &RenderOptions::new("index.html", "/"),
7847 &mut writer,
7848 )
7849 .unwrap();
7850 assert_eq!(
7851 writer.get_content(),
7852 "<custom-element><div>Custom Element</div></custom-element>"
7853 );
7854 assert!(writer.is_ended());
7855 }
7856
7857 #[test]
7858 fn test_component_with_slots() {
7859 let mut fragments = HashMap::new();
7860 fragments.insert(
7861 "index.html".to_string(),
7862 FragmentList {
7863 fragments: vec![
7864 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
7865 WebUIFragment::component("custom-element"),
7866 WebUIFragment::raw("Hello World</custom-element>"),
7867 ],
7868 contains_boundary: false,
7869 },
7870 );
7871 fragments.insert(
7872 "custom-element".to_string(),
7873 FragmentList {
7874 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7875 contains_boundary: false,
7876 },
7877 );
7878 let protocol = WebUIProtocol::new(fragments);
7879 let state = test_json!({});
7880 let mut writer = TestWriter::new();
7881 handle(
7882 &protocol,
7883 &state,
7884 &RenderOptions::new("index.html", "/"),
7885 &mut writer,
7886 )
7887 .unwrap();
7888 assert_eq!(
7889 writer.get_content(),
7890 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
7891 );
7892 assert!(writer.is_ended());
7893 }
7894
7895 #[test]
7896 fn test_multiple_nested_components() {
7897 let mut fragments = HashMap::new();
7898 fragments.insert(
7899 "index.html".to_string(),
7900 FragmentList {
7901 fragments: vec![
7902 WebUIFragment::raw("<div>"),
7903 WebUIFragment::for_loop("item", "items", "templateRepeat"),
7904 WebUIFragment::raw("</div>"),
7905 ],
7906 contains_boundary: false,
7907 },
7908 );
7909 fragments.insert(
7910 "custom-button".to_string(),
7911 FragmentList {
7912 fragments: vec![WebUIFragment::raw("<slot></slot>")],
7913 contains_boundary: false,
7914 },
7915 );
7916 fragments.insert(
7917 "custom-element".to_string(),
7918 FragmentList {
7919 fragments: vec![
7920 WebUIFragment::raw("<custom-child>"),
7921 WebUIFragment::component("custom-child"),
7922 WebUIFragment::raw("</custom-child><slot></slot>"),
7923 ],
7924 contains_boundary: false,
7925 },
7926 );
7927 fragments.insert(
7928 "custom-child".to_string(),
7929 FragmentList {
7930 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
7931 contains_boundary: false,
7932 },
7933 );
7934 fragments.insert(
7935 "templateRepeat".to_string(),
7936 FragmentList {
7937 fragments: vec![
7938 WebUIFragment::raw("<custom-element>"),
7939 WebUIFragment::component("custom-element"),
7940 WebUIFragment::raw("<custom-button>"),
7941 WebUIFragment::component("custom-button"),
7942 WebUIFragment::raw("Ok</custom-button></custom-element>"),
7943 ],
7944 contains_boundary: false,
7945 },
7946 );
7947 let protocol = WebUIProtocol::new(fragments);
7948 let state = test_json!({"items": [{"name": "Item1"}]});
7949 let mut writer = TestWriter::new();
7950 handle(
7951 &protocol,
7952 &state,
7953 &RenderOptions::new("index.html", "/"),
7954 &mut writer,
7955 )
7956 .unwrap();
7957 assert_eq!(
7958 writer.get_content(),
7959 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
7960 );
7961 assert!(writer.is_ended());
7962 }
7963
7964 #[test]
7967 fn test_if_with_binary_expression() {
7968 let mut fragments = HashMap::new();
7969 fragments.insert(
7970 "index.html".to_string(),
7971 FragmentList {
7972 fragments: vec![
7973 WebUIFragment::raw("<div>"),
7974 WebUIFragment::if_cond(
7975 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
7976 "if-1",
7977 ),
7978 WebUIFragment::raw("</div>"),
7979 ],
7980 contains_boundary: false,
7981 },
7982 );
7983 fragments.insert(
7984 "if-1".to_string(),
7985 FragmentList {
7986 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
7987 contains_boundary: false,
7988 },
7989 );
7990 let protocol = WebUIProtocol::new(fragments);
7991
7992 let state_true = test_json!({"x": 10});
7994 let mut writer_true = TestWriter::new();
7995 handle(
7996 &protocol,
7997 &state_true,
7998 &RenderOptions::new("index.html", "/"),
7999 &mut writer_true,
8000 )
8001 .unwrap();
8002 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8003
8004 let state_false = test_json!({"x": 1});
8006 let mut writer_false = TestWriter::new();
8007 handle(
8008 &protocol,
8009 &state_false,
8010 &RenderOptions::new("index.html", "/"),
8011 &mut writer_false,
8012 )
8013 .unwrap();
8014 assert_eq!(writer_false.get_content(), "<div></div>");
8015 }
8016
8017 #[test]
8018 fn test_for_if_overlapping_local_state() {
8019 let mut fragments = HashMap::new();
8020 fragments.insert(
8021 "index.html".to_string(),
8022 FragmentList {
8023 fragments: vec![
8024 WebUIFragment::raw("<div>"),
8025 WebUIFragment::for_loop("item", "items", "template1"),
8026 WebUIFragment::raw("</div>"),
8027 ],
8028 contains_boundary: false,
8029 },
8030 );
8031 fragments.insert(
8032 "template1".to_string(),
8033 FragmentList {
8034 fragments: vec![
8035 WebUIFragment::raw("<div>"),
8036 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8037 WebUIFragment::raw("</div>"),
8038 ],
8039 contains_boundary: false,
8040 },
8041 );
8042 fragments.insert(
8043 "ifBlock".to_string(),
8044 FragmentList {
8045 fragments: vec![
8046 WebUIFragment::raw("<span>"),
8047 WebUIFragment::signal("item.label", false),
8048 WebUIFragment::raw("</span>"),
8049 ],
8050 contains_boundary: false,
8051 },
8052 );
8053 let protocol = WebUIProtocol::new(fragments);
8054 let state = test_json!({
8055 "flag": false,
8056 "items": [
8057 {"label": "A", "flag": true},
8058 {"label": "B", "flag": false},
8059 {"label": "C", "flag": true}
8060 ]
8061 });
8062 let mut writer = TestWriter::new();
8063 handle(
8064 &protocol,
8065 &state,
8066 &RenderOptions::new("index.html", "/"),
8067 &mut writer,
8068 )
8069 .unwrap();
8070 assert_eq!(
8071 writer.get_content(),
8072 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8073 );
8074 }
8075
8076 #[test]
8077 fn test_for_if_global_flag_no_effect() {
8078 let mut fragments = HashMap::new();
8079 fragments.insert(
8080 "index.html".to_string(),
8081 FragmentList {
8082 fragments: vec![
8083 WebUIFragment::raw("<div>"),
8084 WebUIFragment::for_loop("item", "items", "template1"),
8085 WebUIFragment::raw("</div>"),
8086 ],
8087 contains_boundary: false,
8088 },
8089 );
8090 fragments.insert(
8091 "template1".to_string(),
8092 FragmentList {
8093 fragments: vec![
8094 WebUIFragment::raw("<div>"),
8095 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8096 WebUIFragment::raw("</div>"),
8097 ],
8098 contains_boundary: false,
8099 },
8100 );
8101 fragments.insert(
8102 "ifBlock".to_string(),
8103 FragmentList {
8104 fragments: vec![
8105 WebUIFragment::raw("<span>"),
8106 WebUIFragment::signal("item.label", false),
8107 WebUIFragment::raw("</span>"),
8108 ],
8109 contains_boundary: false,
8110 },
8111 );
8112 let protocol = WebUIProtocol::new(fragments);
8113 let state = test_json!({
8114 "item": {"flag": true},
8115 "items": [
8116 {"label": "A", "flag": false},
8117 {"label": "B", "flag": true},
8118 {"label": "C", "flag": false}
8119 ]
8120 });
8121 let mut writer = TestWriter::new();
8122 handle(
8123 &protocol,
8124 &state,
8125 &RenderOptions::new("index.html", "/"),
8126 &mut writer,
8127 )
8128 .unwrap();
8129 assert_eq!(
8130 writer.get_content(),
8131 "<div><div></div><div><span>B</span></div><div></div></div>"
8132 );
8133 }
8134
8135 #[test]
8138 fn test_recursive_template_refs() {
8139 let mut fragments = HashMap::new();
8140 fragments.insert(
8141 "index.html".to_string(),
8142 FragmentList {
8143 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8144 contains_boundary: false,
8145 },
8146 );
8147 fragments.insert(
8148 "static".to_string(),
8149 FragmentList {
8150 fragments: vec![
8151 WebUIFragment::raw("<div expanded=\""),
8152 WebUIFragment::signal("item.expanded", false),
8153 WebUIFragment::raw("\" class=\""),
8154 WebUIFragment::signal("testScenario", false),
8155 WebUIFragment::raw("\"><span>"),
8156 WebUIFragment::signal("item.name", false),
8157 WebUIFragment::raw("</span>"),
8158 WebUIFragment::for_loop("item", "item.children", "static"),
8159 WebUIFragment::raw("</div>"),
8160 ],
8161 contains_boundary: false,
8162 },
8163 );
8164 let protocol = WebUIProtocol::new(fragments);
8165 let state = test_json!({
8166 "testScenario": "RecursiveTemplatesWithGlobalState",
8167 "items": [
8168 {"name": "A", "expanded": "false", "children": []},
8169 {"name": "B", "expanded": "true", "children": [
8170 {"name": "C", "expanded": "false"},
8171 {"name": "D", "expanded": "false"}
8172 ]},
8173 {"name": "E", "expanded": "false"}
8174 ]
8175 });
8176 let mut writer = TestWriter::new();
8177 handle(
8178 &protocol,
8179 &state,
8180 &RenderOptions::new("index.html", "/"),
8181 &mut writer,
8182 )
8183 .unwrap();
8184 assert_eq!(
8185 writer.get_content(),
8186 "<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>"
8187 );
8188 }
8189
8190 #[test]
8193 fn test_component_in_for_no_local_access() {
8194 let mut fragments = HashMap::new();
8195 fragments.insert(
8196 "index.html".to_string(),
8197 FragmentList {
8198 fragments: vec![
8199 WebUIFragment::raw("<div>"),
8200 WebUIFragment::for_loop("item", "items", "templateComponent"),
8201 WebUIFragment::raw("</div>"),
8202 ],
8203 contains_boundary: false,
8204 },
8205 );
8206 fragments.insert(
8207 "templateComponent".to_string(),
8208 FragmentList {
8209 fragments: vec![
8210 WebUIFragment::raw("<component-tag>"),
8211 WebUIFragment::component("my-component"),
8212 WebUIFragment::raw("</component-tag>"),
8213 ],
8214 contains_boundary: false,
8215 },
8216 );
8217 fragments.insert(
8218 "my-component".to_string(),
8219 FragmentList {
8220 fragments: vec![
8221 WebUIFragment::raw("<span>"),
8222 WebUIFragment::signal("name", false),
8223 WebUIFragment::raw("</span>"),
8224 ],
8225 contains_boundary: false,
8226 },
8227 );
8228 let protocol = WebUIProtocol::new(fragments);
8229 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8230 let mut writer = TestWriter::new();
8231 handle(
8232 &protocol,
8233 &state,
8234 &RenderOptions::new("index.html", "/"),
8235 &mut writer,
8236 )
8237 .unwrap();
8238 assert_eq!(
8239 writer.get_content(),
8240 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8241 );
8242 }
8243
8244 #[test]
8245 fn test_nested_for_hierarchical_state() {
8246 let mut fragments = HashMap::new();
8247 fragments.insert(
8248 "index.html".to_string(),
8249 FragmentList {
8250 fragments: vec![
8251 WebUIFragment::raw("<div>"),
8252 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8253 WebUIFragment::raw("</div>"),
8254 ],
8255 contains_boundary: false,
8256 },
8257 );
8258 fragments.insert(
8259 "outerTemplate".to_string(),
8260 FragmentList {
8261 fragments: vec![
8262 WebUIFragment::raw("<section>"),
8263 WebUIFragment::signal("globalPrefix", false),
8264 WebUIFragment::signal("outerItem.outerLabel", false),
8265 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8266 WebUIFragment::raw("</section>"),
8267 ],
8268 contains_boundary: false,
8269 },
8270 );
8271 fragments.insert(
8272 "innerTemplate".to_string(),
8273 FragmentList {
8274 fragments: vec![
8275 WebUIFragment::raw("<p>"),
8276 WebUIFragment::signal("globalPrefix", false),
8277 WebUIFragment::signal("outerItem.outerLabel", false),
8278 WebUIFragment::raw(": "),
8279 WebUIFragment::signal("innerItem.innerLabel", false),
8280 WebUIFragment::raw("</p>"),
8281 ],
8282 contains_boundary: false,
8283 },
8284 );
8285 let protocol = WebUIProtocol::new(fragments);
8286 let state = test_json!({
8287 "globalPrefix": "Prefix: ",
8288 "outerItems": [
8289 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8290 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8291 ]
8292 });
8293 let mut writer = TestWriter::new();
8294 handle(
8295 &protocol,
8296 &state,
8297 &RenderOptions::new("index.html", "/"),
8298 &mut writer,
8299 )
8300 .unwrap();
8301 assert_eq!(
8302 writer.get_content(),
8303 "<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>"
8304 );
8305 }
8306
8307 #[test]
8308 fn test_component_in_for_global_only() {
8309 let mut fragments = HashMap::new();
8310 fragments.insert(
8311 "index.html".to_string(),
8312 FragmentList {
8313 fragments: vec![
8314 WebUIFragment::raw("<div>"),
8315 WebUIFragment::for_loop("item", "items", "templateComponent"),
8316 WebUIFragment::raw("</div>"),
8317 ],
8318 contains_boundary: false,
8319 },
8320 );
8321 fragments.insert(
8322 "templateComponent".to_string(),
8323 FragmentList {
8324 fragments: vec![
8325 WebUIFragment::raw("<component-tag>"),
8326 WebUIFragment::component("my-component"),
8327 WebUIFragment::raw("</component-tag>"),
8328 ],
8329 contains_boundary: false,
8330 },
8331 );
8332 fragments.insert(
8333 "my-component".to_string(),
8334 FragmentList {
8335 fragments: vec![
8336 WebUIFragment::raw("<span>"),
8337 WebUIFragment::signal("name", false),
8338 WebUIFragment::raw("-"),
8339 WebUIFragment::signal("globalSuffix", false),
8340 WebUIFragment::raw("</span>"),
8341 ],
8342 contains_boundary: false,
8343 },
8344 );
8345 let protocol = WebUIProtocol::new(fragments);
8346 let state =
8347 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8348 let mut writer = TestWriter::new();
8349 handle(
8350 &protocol,
8351 &state,
8352 &RenderOptions::new("index.html", "/"),
8353 &mut writer,
8354 )
8355 .unwrap();
8356 assert_eq!(
8357 writer.get_content(),
8358 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8359 );
8360 }
8361
8362 #[test]
8363 fn test_component_no_item_moniker() {
8364 let mut fragments = HashMap::new();
8365 fragments.insert(
8366 "index.html".to_string(),
8367 FragmentList {
8368 fragments: vec![
8369 WebUIFragment::raw("<div>"),
8370 WebUIFragment::for_loop("item", "items", "templateComponent"),
8371 WebUIFragment::raw("</div>"),
8372 ],
8373 contains_boundary: false,
8374 },
8375 );
8376 fragments.insert(
8377 "templateComponent".to_string(),
8378 FragmentList {
8379 fragments: vec![
8380 WebUIFragment::raw("<component-tag>"),
8381 WebUIFragment::component("my-component"),
8382 WebUIFragment::raw("</component-tag>"),
8383 ],
8384 contains_boundary: false,
8385 },
8386 );
8387 fragments.insert(
8388 "my-component".to_string(),
8389 FragmentList {
8390 fragments: vec![
8391 WebUIFragment::raw("<span>"),
8392 WebUIFragment::signal("item.name", false),
8393 WebUIFragment::raw("-"),
8394 WebUIFragment::signal("globalSuffix", false),
8395 WebUIFragment::raw("</span>"),
8396 ],
8397 contains_boundary: false,
8398 },
8399 );
8400 let protocol = WebUIProtocol::new(fragments);
8401 let state =
8402 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8403 let mut writer = TestWriter::new();
8404 handle(
8405 &protocol,
8406 &state,
8407 &RenderOptions::new("index.html", "/"),
8408 &mut writer,
8409 )
8410 .unwrap();
8411 assert_eq!(
8412 writer.get_content(),
8413 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8414 );
8415 }
8416
8417 #[test]
8418 fn test_for_nonqualified_uses_global() {
8419 let mut fragments = HashMap::new();
8420 fragments.insert(
8421 "index.html".to_string(),
8422 FragmentList {
8423 fragments: vec![
8424 WebUIFragment::raw("<div>"),
8425 WebUIFragment::for_loop("item", "items", "template1"),
8426 WebUIFragment::raw("</div>"),
8427 ],
8428 contains_boundary: false,
8429 },
8430 );
8431 fragments.insert(
8432 "template1".to_string(),
8433 FragmentList {
8434 fragments: vec![
8435 WebUIFragment::raw("<span>"),
8436 WebUIFragment::signal("name", false),
8437 WebUIFragment::raw("</span>"),
8438 ],
8439 contains_boundary: false,
8440 },
8441 );
8442 let protocol = WebUIProtocol::new(fragments);
8443 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8444 let mut writer = TestWriter::new();
8445 handle(
8446 &protocol,
8447 &state,
8448 &RenderOptions::new("index.html", "/"),
8449 &mut writer,
8450 )
8451 .unwrap();
8452 assert_eq!(
8453 writer.get_content(),
8454 "<div><span>GlobalName</span><span>GlobalName</span></div>"
8455 );
8456 }
8457
8458 #[test]
8459 fn test_nested_for_if_interleaved() {
8460 let mut fragments = HashMap::new();
8461 fragments.insert(
8462 "index.html".to_string(),
8463 FragmentList {
8464 fragments: vec![
8465 WebUIFragment::raw("<div>"),
8466 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8467 WebUIFragment::raw("</div>"),
8468 ],
8469 contains_boundary: false,
8470 },
8471 );
8472 fragments.insert(
8473 "outerTemplate".to_string(),
8474 FragmentList {
8475 fragments: vec![
8476 WebUIFragment::raw("<section>"),
8477 WebUIFragment::signal("globalPrefix", false),
8478 WebUIFragment::signal("outerItem.outerLabel", false),
8479 WebUIFragment::if_cond(
8480 ConditionExpr::identifier("outerItem.include"),
8481 "ifTemplate",
8482 ),
8483 WebUIFragment::raw("</section>"),
8484 ],
8485 contains_boundary: false,
8486 },
8487 );
8488 fragments.insert(
8489 "ifTemplate".to_string(),
8490 FragmentList {
8491 fragments: vec![
8492 WebUIFragment::raw("<div>"),
8493 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8494 WebUIFragment::raw("</div>"),
8495 ],
8496 contains_boundary: false,
8497 },
8498 );
8499 fragments.insert(
8500 "innerTemplate".to_string(),
8501 FragmentList {
8502 fragments: vec![
8503 WebUIFragment::raw("<p>"),
8504 WebUIFragment::signal("globalSuffix", false),
8505 WebUIFragment::raw(": "),
8506 WebUIFragment::signal("innerItem.innerLabel", false),
8507 WebUIFragment::raw("</p>"),
8508 ],
8509 contains_boundary: false,
8510 },
8511 );
8512 let protocol = WebUIProtocol::new(fragments);
8513 let state = test_json!({
8514 "globalPrefix": "Prefix: ",
8515 "globalSuffix": "Suffix",
8516 "outerItems": [
8517 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8518 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8519 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8520 ]
8521 });
8522 let mut writer = TestWriter::new();
8523 handle(
8524 &protocol,
8525 &state,
8526 &RenderOptions::new("index.html", "/"),
8527 &mut writer,
8528 )
8529 .unwrap();
8530 assert_eq!(
8531 writer.get_content(),
8532 "<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>"
8533 );
8534 }
8535
8536 #[test]
8537 fn test_nested_for_if_outer_state() {
8538 let mut fragments = HashMap::new();
8539 fragments.insert(
8540 "index.html".to_string(),
8541 FragmentList {
8542 fragments: vec![
8543 WebUIFragment::raw("<div>"),
8544 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8545 WebUIFragment::raw("</div>"),
8546 ],
8547 contains_boundary: false,
8548 },
8549 );
8550 fragments.insert(
8551 "outerTemplate".to_string(),
8552 FragmentList {
8553 fragments: vec![
8554 WebUIFragment::raw("<section>"),
8555 WebUIFragment::signal("globalPrefix", false),
8556 WebUIFragment::signal("outerItem.label", false),
8557 WebUIFragment::for_loop(
8558 "middleItem",
8559 "outerItem.middleItems",
8560 "middleTemplate",
8561 ),
8562 WebUIFragment::raw("</section>"),
8563 ],
8564 contains_boundary: false,
8565 },
8566 );
8567 fragments.insert(
8568 "middleTemplate".to_string(),
8569 FragmentList {
8570 fragments: vec![
8571 WebUIFragment::raw("<div>"),
8572 WebUIFragment::if_cond(
8573 ConditionExpr::identifier("outerItem.active"),
8574 "ifTemplate",
8575 ),
8576 WebUIFragment::raw("</div>"),
8577 ],
8578 contains_boundary: false,
8579 },
8580 );
8581 fragments.insert(
8582 "ifTemplate".to_string(),
8583 FragmentList {
8584 fragments: vec![
8585 WebUIFragment::raw("<p>"),
8586 WebUIFragment::signal("middleItem.value", false),
8587 WebUIFragment::raw("</p>"),
8588 ],
8589 contains_boundary: false,
8590 },
8591 );
8592 let protocol = WebUIProtocol::new(fragments);
8593 let state = test_json!({
8594 "globalPrefix": "GP-",
8595 "outerItems": [
8596 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
8597 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
8598 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
8599 ]
8600 });
8601 let mut writer = TestWriter::new();
8602 handle(
8603 &protocol,
8604 &state,
8605 &RenderOptions::new("index.html", "/"),
8606 &mut writer,
8607 )
8608 .unwrap();
8609 assert_eq!(
8610 writer.get_content(),
8611 "<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>"
8612 );
8613 }
8614
8615 #[test]
8616 fn test_nested_for_if_inner_state() {
8617 let mut fragments = HashMap::new();
8618 fragments.insert(
8619 "index.html".to_string(),
8620 FragmentList {
8621 fragments: vec![
8622 WebUIFragment::raw("<div>"),
8623 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8624 WebUIFragment::raw("</div>"),
8625 ],
8626 contains_boundary: false,
8627 },
8628 );
8629 fragments.insert(
8630 "outerTemplate".to_string(),
8631 FragmentList {
8632 fragments: vec![
8633 WebUIFragment::raw("<section>"),
8634 WebUIFragment::signal("outerItem.label", false),
8635 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8636 WebUIFragment::raw("</section>"),
8637 ],
8638 contains_boundary: false,
8639 },
8640 );
8641 fragments.insert(
8642 "innerTemplate".to_string(),
8643 FragmentList {
8644 fragments: vec![
8645 WebUIFragment::raw("<article>"),
8646 WebUIFragment::if_cond(
8647 ConditionExpr::identifier("innerItem.show"),
8648 "ifTemplate",
8649 ),
8650 WebUIFragment::raw("</article>"),
8651 ],
8652 contains_boundary: false,
8653 },
8654 );
8655 fragments.insert(
8656 "ifTemplate".to_string(),
8657 FragmentList {
8658 fragments: vec![
8659 WebUIFragment::raw("<p>"),
8660 WebUIFragment::signal("innerItem.detail", false),
8661 WebUIFragment::raw("</p>"),
8662 ],
8663 contains_boundary: false,
8664 },
8665 );
8666 let protocol = WebUIProtocol::new(fragments);
8667 let state = test_json!({
8668 "outerItems": [
8669 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
8670 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
8671 ]
8672 });
8673 let mut writer = TestWriter::new();
8674 handle(
8675 &protocol,
8676 &state,
8677 &RenderOptions::new("index.html", "/"),
8678 &mut writer,
8679 )
8680 .unwrap();
8681 assert_eq!(
8682 writer.get_content(),
8683 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
8684 );
8685 }
8686
8687 #[test]
8688 fn test_for_merge_local_global_monikers() {
8689 let mut fragments = HashMap::new();
8690 fragments.insert(
8691 "index.html".to_string(),
8692 FragmentList {
8693 fragments: vec![
8694 WebUIFragment::raw("<div>"),
8695 WebUIFragment::for_loop("item", "items", "template1"),
8696 WebUIFragment::raw("</div>"),
8697 ],
8698 contains_boundary: false,
8699 },
8700 );
8701 fragments.insert(
8702 "template1".to_string(),
8703 FragmentList {
8704 fragments: vec![
8705 WebUIFragment::raw("<span>"),
8706 WebUIFragment::signal("item.name", false),
8707 WebUIFragment::raw("-"),
8708 WebUIFragment::signal("item.globalValue", false),
8709 WebUIFragment::raw("-"),
8710 WebUIFragment::signal("item.localOnly", false),
8711 WebUIFragment::raw("-"),
8712 WebUIFragment::signal("item.otherVal", false),
8713 WebUIFragment::raw("</span>"),
8714 ],
8715 contains_boundary: false,
8716 },
8717 );
8718 let protocol = WebUIProtocol::new(fragments);
8719 let state = test_json!({
8720 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8721 "items": [
8722 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8723 {"name": "Local2", "localOnly": "Only2"}
8724 ]
8725 });
8726 let mut writer = TestWriter::new();
8727 handle(
8728 &protocol,
8729 &state,
8730 &RenderOptions::new("index.html", "/"),
8731 &mut writer,
8732 )
8733 .unwrap();
8734 assert_eq!(
8735 writer.get_content(),
8736 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
8737 );
8738 }
8739
8740 #[test]
8741 fn test_component_in_for_global_moniker_shadow() {
8742 let mut fragments = HashMap::new();
8743 fragments.insert(
8744 "index.html".to_string(),
8745 FragmentList {
8746 fragments: vec![
8747 WebUIFragment::raw("<div>"),
8748 WebUIFragment::for_loop("item", "items", "templateComponent"),
8749 WebUIFragment::raw("</div>"),
8750 ],
8751 contains_boundary: false,
8752 },
8753 );
8754 fragments.insert(
8755 "templateComponent".to_string(),
8756 FragmentList {
8757 fragments: vec![
8758 WebUIFragment::raw("<component-tag>"),
8759 WebUIFragment::component("my-component"),
8760 WebUIFragment::raw("</component-tag>"),
8761 ],
8762 contains_boundary: false,
8763 },
8764 );
8765 fragments.insert(
8766 "my-component".to_string(),
8767 FragmentList {
8768 fragments: vec![
8769 WebUIFragment::raw("<span>"),
8770 WebUIFragment::signal("name", false),
8771 WebUIFragment::raw("-"),
8772 WebUIFragment::signal("item.globalValue", false),
8773 WebUIFragment::raw("-"),
8774 WebUIFragment::signal("localOnly", false),
8775 WebUIFragment::raw("-"),
8776 WebUIFragment::signal("item.otherVal", false),
8777 WebUIFragment::raw("</span>"),
8778 ],
8779 contains_boundary: false,
8780 },
8781 );
8782 let protocol = WebUIProtocol::new(fragments);
8783 let state = test_json!({
8784 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
8785 "items": [
8786 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
8787 {"name": "Local2", "localOnly": "Only2"}
8788 ]
8789 });
8790 let mut writer = TestWriter::new();
8791 handle(
8792 &protocol,
8793 &state,
8794 &RenderOptions::new("index.html", "/"),
8795 &mut writer,
8796 )
8797 .unwrap();
8798 assert_eq!(
8799 writer.get_content(),
8800 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
8801 );
8802 }
8803
8804 #[test]
8805 fn test_if_in_nested_for_local_flag() {
8806 let mut fragments = HashMap::new();
8807 fragments.insert(
8808 "index.html".to_string(),
8809 FragmentList {
8810 fragments: vec![
8811 WebUIFragment::raw("<div>"),
8812 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8813 WebUIFragment::raw("</div>"),
8814 ],
8815 contains_boundary: false,
8816 },
8817 );
8818 fragments.insert(
8819 "outerTemplate".to_string(),
8820 FragmentList {
8821 fragments: vec![
8822 WebUIFragment::raw("<section>"),
8823 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8824 WebUIFragment::raw("</section>"),
8825 ],
8826 contains_boundary: false,
8827 },
8828 );
8829 fragments.insert(
8830 "innerTemplate".to_string(),
8831 FragmentList {
8832 fragments: vec![WebUIFragment::if_cond(
8833 ConditionExpr::identifier("inner_item.flag"),
8834 "ifInner",
8835 )],
8836 contains_boundary: false,
8837 },
8838 );
8839 fragments.insert(
8840 "ifInner".to_string(),
8841 FragmentList {
8842 fragments: vec![
8843 WebUIFragment::raw("<p>"),
8844 WebUIFragment::signal("inner_item.value", false),
8845 WebUIFragment::raw("</p>"),
8846 ],
8847 contains_boundary: false,
8848 },
8849 );
8850 let protocol = WebUIProtocol::new(fragments);
8851 let state = test_json!({
8852 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
8853 "inner_item": {"flag": false}
8854 });
8855 let mut writer = TestWriter::new();
8856 handle(
8857 &protocol,
8858 &state,
8859 &RenderOptions::new("index.html", "/"),
8860 &mut writer,
8861 )
8862 .unwrap();
8863 assert_eq!(
8864 writer.get_content(),
8865 "<div><section><p>X</p></section></div>"
8866 );
8867 }
8868
8869 #[test]
8870 fn test_if_in_nested_for_global_fallback() {
8871 let mut fragments = HashMap::new();
8872 fragments.insert(
8873 "index.html".to_string(),
8874 FragmentList {
8875 fragments: vec![
8876 WebUIFragment::raw("<div>"),
8877 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
8878 WebUIFragment::raw("</div>"),
8879 ],
8880 contains_boundary: false,
8881 },
8882 );
8883 fragments.insert(
8884 "outerTemplate".to_string(),
8885 FragmentList {
8886 fragments: vec![
8887 WebUIFragment::raw("<section>"),
8888 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
8889 WebUIFragment::raw("</section>"),
8890 ],
8891 contains_boundary: false,
8892 },
8893 );
8894 fragments.insert(
8895 "innerTemplate".to_string(),
8896 FragmentList {
8897 fragments: vec![WebUIFragment::if_cond(
8898 ConditionExpr::identifier("inner_item.flag"),
8899 "ifInner",
8900 )],
8901 contains_boundary: false,
8902 },
8903 );
8904 fragments.insert(
8905 "ifInner".to_string(),
8906 FragmentList {
8907 fragments: vec![
8908 WebUIFragment::raw("<p>"),
8909 WebUIFragment::signal("inner_item.value", false),
8910 WebUIFragment::raw("</p>"),
8911 ],
8912 contains_boundary: false,
8913 },
8914 );
8915 let protocol = WebUIProtocol::new(fragments);
8916 let state = test_json!({
8917 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
8918 "inner_item": {"flag": true}
8919 });
8920 let mut writer = TestWriter::new();
8921 handle(
8922 &protocol,
8923 &state,
8924 &RenderOptions::new("index.html", "/"),
8925 &mut writer,
8926 )
8927 .unwrap();
8928 assert_eq!(
8929 writer.get_content(),
8930 "<div><section><p>X</p><p>Y</p></section></div>"
8931 );
8932 }
8933
8934 #[test]
8935 fn test_if_mixed_for_monikers() {
8936 let mut fragments = HashMap::new();
8937 fragments.insert(
8938 "index.html".to_string(),
8939 FragmentList {
8940 fragments: vec![
8941 WebUIFragment::raw("<div>"),
8942 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
8943 WebUIFragment::raw("</div>"),
8944 ],
8945 contains_boundary: false,
8946 },
8947 );
8948 fragments.insert(
8949 "outerTemplate".to_string(),
8950 FragmentList {
8951 fragments: vec![
8952 WebUIFragment::raw("<section>"),
8953 WebUIFragment::signal("outer.outerLabel", false),
8954 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
8955 WebUIFragment::raw("</section>"),
8956 ],
8957 contains_boundary: false,
8958 },
8959 );
8960 fragments.insert(
8961 "innerTemplate".to_string(),
8962 FragmentList {
8963 fragments: vec![WebUIFragment::if_cond(
8964 ConditionExpr::compound(
8965 ConditionExpr::identifier("outer.active"),
8966 LogicalOperator::And,
8967 ConditionExpr::predicate(
8968 "inner.value",
8969 ComparisonOperator::GreaterThan,
8970 "globalLimit",
8971 ),
8972 ),
8973 "ifInner",
8974 )],
8975 contains_boundary: false,
8976 },
8977 );
8978 fragments.insert(
8979 "ifInner".to_string(),
8980 FragmentList {
8981 fragments: vec![
8982 WebUIFragment::raw("<p>"),
8983 WebUIFragment::signal("inner.value", false),
8984 WebUIFragment::raw("</p>"),
8985 ],
8986 contains_boundary: false,
8987 },
8988 );
8989 let protocol = WebUIProtocol::new(fragments);
8990 let state = test_json!({
8991 "globalLimit": 10,
8992 "list": {"outerItems": [
8993 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
8994 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
8995 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
8996 ]}
8997 });
8998 let mut writer = TestWriter::new();
8999 handle(
9000 &protocol,
9001 &state,
9002 &RenderOptions::new("index.html", "/"),
9003 &mut writer,
9004 )
9005 .unwrap();
9006 assert_eq!(
9007 writer.get_content(),
9008 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9009 );
9010 }
9011
9012 fn make_route_protocol() -> WebUIProtocol {
9015 use webui_protocol::WebUiFragmentRoute;
9016
9017 let mut fragments = HashMap::new();
9018
9019 fragments.insert(
9021 "index.html".to_string(),
9022 FragmentList {
9023 fragments: vec![
9024 WebUIFragment::raw("<h1>Shell</h1>"),
9025 WebUIFragment::route_from(WebUiFragmentRoute {
9026 path: "/".into(),
9027 fragment_id: "dash-page".into(),
9028 exact: true,
9029 keep_alive: false,
9030 ..Default::default()
9031 }),
9032 WebUIFragment::route_from(WebUiFragmentRoute {
9033 path: "/contacts/:id".into(),
9034 fragment_id: "detail-page".into(),
9035 exact: true,
9036 keep_alive: true,
9037 ..Default::default()
9038 }),
9039 ],
9040
9041 contains_boundary: false,
9042 },
9043 );
9044
9045 fragments.insert(
9047 "dash-page".to_string(),
9048 FragmentList {
9049 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9050 contains_boundary: false,
9051 },
9052 );
9053
9054 fragments.insert(
9056 "detail-page".to_string(),
9057 FragmentList {
9058 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9059 contains_boundary: false,
9060 },
9061 );
9062
9063 WebUIProtocol::new(fragments)
9064 }
9065
9066 fn make_nested_route_protocol() -> WebUIProtocol {
9067 use webui_protocol::WebUiFragmentRoute;
9068
9069 let mut fragments = HashMap::new();
9070
9071 fragments.insert(
9072 "index.html".to_string(),
9073 FragmentList {
9074 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9075 path: "/".into(),
9076 fragment_id: "app-shell".into(),
9077 exact: false,
9078 children: vec![WebUiFragmentRoute {
9079 path: "sections/:id".into(),
9080 fragment_id: "section-comp".into(),
9081 exact: false,
9082 children: vec![WebUiFragmentRoute {
9083 path: "topics/:topicId".into(),
9084 fragment_id: "topic-comp".into(),
9085 exact: true,
9086 children: vec![],
9087 keep_alive: false,
9088 ..Default::default()
9089 }],
9090 keep_alive: false,
9091 ..Default::default()
9092 }],
9093 keep_alive: false,
9094 ..Default::default()
9095 })],
9096
9097 contains_boundary: false,
9098 },
9099 );
9100
9101 fragments.insert(
9102 "app-shell".to_string(),
9103 FragmentList {
9104 fragments: vec![
9105 WebUIFragment::raw("<h1>Shell</h1>"),
9106 WebUIFragment::outlet(),
9107 ],
9108 contains_boundary: false,
9109 },
9110 );
9111
9112 fragments.insert(
9113 "section-comp".to_string(),
9114 FragmentList {
9115 fragments: vec![
9116 WebUIFragment::raw("<h2>Section</h2>"),
9117 WebUIFragment::outlet(),
9118 ],
9119 contains_boundary: false,
9120 },
9121 );
9122
9123 fragments.insert(
9124 "topic-comp".to_string(),
9125 FragmentList {
9126 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9127 contains_boundary: false,
9128 },
9129 );
9130
9131 WebUIProtocol::new(fragments)
9132 }
9133
9134 #[test]
9135 fn test_route_renders_shell_always() {
9136 let protocol = make_route_protocol();
9137 let state = test_json!({});
9138 let mut writer = TestWriter::new();
9139 handle(
9140 &protocol,
9141 &state,
9142 &RenderOptions::new("index.html", "/"),
9143 &mut writer,
9144 )
9145 .unwrap();
9146 let html = writer.get_content();
9147
9148 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9150 assert!(html.contains(" active>"), "matched route should be active");
9152 assert!(
9154 html.contains("style=\"display:none\""),
9155 "non-matched routes should be hidden"
9156 );
9157 }
9158
9159 #[test]
9160 fn test_route_matched_renders_visible() {
9161 let protocol = make_route_protocol();
9162 let state = test_json!({});
9163 let mut writer = TestWriter::new();
9164 handle(
9165 &protocol,
9166 &state,
9167 &RenderOptions::new("index.html", "/"),
9168 &mut writer,
9169 )
9170 .unwrap();
9171 let html = writer.get_content();
9172
9173 assert!(
9175 html.contains("<webui-route path=\"/\""),
9176 "dashboard route should exist"
9177 );
9178 assert!(
9179 html.contains("active>") && html.contains("<dash-page"),
9180 "matched route should be active with component tag: {html}"
9181 );
9182 assert!(
9183 html.contains("<p>Dashboard</p>"),
9184 "matched route should have content"
9185 );
9186 }
9187
9188 #[test]
9189 fn test_route_non_matched_renders_hidden_empty() {
9190 let protocol = make_route_protocol();
9191 let state = test_json!({});
9192 let mut writer = TestWriter::new();
9193 handle(
9194 &protocol,
9195 &state,
9196 &RenderOptions::new("index.html", "/"),
9197 &mut writer,
9198 )
9199 .unwrap();
9200 let html = writer.get_content();
9201
9202 assert!(
9204 html.contains("<webui-route path=\"/contacts/:id\""),
9205 "detail route element should exist"
9206 );
9207 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9209 let after_detail = &html[detail_start..];
9210 assert!(
9211 after_detail.contains("style=\"display:none\">")
9212 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9213 "non-matched route should be hidden: {after_detail}"
9214 );
9215 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9217 let detail_body = &after_detail[..detail_end];
9218 assert!(
9219 !detail_body.contains("<detail-page"),
9220 "non-matched route should not render component content: {detail_body}"
9221 );
9222 }
9223
9224 #[test]
9225 fn test_route_parameterized_match() {
9226 let protocol = make_route_protocol();
9227 let state = test_json!({});
9228 let mut writer = TestWriter::new();
9229 handle(
9230 &protocol,
9231 &state,
9232 &RenderOptions::new("index.html", "/contacts/42"),
9233 &mut writer,
9234 )
9235 .unwrap();
9236 let html = writer.get_content();
9237
9238 assert!(
9240 html.contains("active>") && html.contains("<detail-page"),
9241 "detail route should be active: {html}"
9242 );
9243 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9244 let dash_start = html
9246 .find("component=\"dash-page\"")
9247 .expect("dashboard route");
9248 let after_dash = &html[dash_start..];
9249 assert!(
9250 after_dash.contains("style=\"display:none\">"),
9251 "dashboard should be hidden when detail matches: {after_dash}"
9252 );
9253 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9254 let dash_body = &after_dash[..dash_end];
9255 assert!(
9256 !dash_body.contains("<dash-page"),
9257 "dashboard should not render component content: {dash_body}"
9258 );
9259 }
9260
9261 #[test]
9262 fn test_route_no_match_all_hidden_empty() {
9263 let protocol = make_route_protocol();
9264 let state = test_json!({});
9265 let mut writer = TestWriter::new();
9266 handle(
9267 &protocol,
9268 &state,
9269 &RenderOptions::new("index.html", "/nonexistent"),
9270 &mut writer,
9271 )
9272 .unwrap();
9273 let html = writer.get_content();
9274
9275 assert!(html.contains("<h1>Shell</h1>"));
9277 assert!(
9279 !html.contains("<p>Dashboard</p>"),
9280 "no route content when nothing matches"
9281 );
9282 assert!(
9283 !html.contains("<p>Detail</p>"),
9284 "no route content when nothing matches"
9285 );
9286 }
9287
9288 #[test]
9289 fn test_route_component_attr_emitted() {
9290 let protocol = make_route_protocol();
9291 let state = test_json!({});
9292 let mut writer = TestWriter::new();
9293 handle(
9294 &protocol,
9295 &state,
9296 &RenderOptions::new("index.html", "/"),
9297 &mut writer,
9298 )
9299 .unwrap();
9300 let html = writer.get_content();
9301 assert!(
9303 html.contains("component=\"dash-page\""),
9304 "component attr should be on webui-route: {html}"
9305 );
9306 assert!(
9307 html.contains("component=\"detail-page\""),
9308 "component attr should be on webui-route: {html}"
9309 );
9310 assert!(
9311 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9312 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9313 );
9314 }
9315
9316 #[test]
9317 fn test_no_plugin_no_state_attributes() {
9318 let protocol = make_route_protocol();
9319 let state = test_json!({
9320 "title": "Fish & Chips",
9321 "cartOpen": true,
9322 "items": [{"name": "A&B"}]
9323 });
9324 let mut writer = TestWriter::new();
9325
9326 handle(
9327 &protocol,
9328 &state,
9329 &RenderOptions::new("index.html", "/"),
9330 &mut writer,
9331 )
9332 .unwrap();
9333
9334 let html = writer.get_content();
9335 assert!(
9337 !html.contains("data-state"),
9338 "no data-state without plugin: {html}"
9339 );
9340 assert!(
9341 !html.contains(r#"title="Fish"#),
9342 "no scalar attrs without plugin: {html}"
9343 );
9344 }
9345
9346 #[test]
9347 fn test_nested_routes_render_webui_route_as_light_dom() {
9348 let protocol = make_nested_route_protocol();
9349 let state = test_json!({"title": "Test"});
9350 let handler = WebUIHandler::new();
9351 let mut writer = TestWriter::new();
9352
9353 handler
9354 .handle(
9355 &protocol,
9356 &state,
9357 &RenderOptions::new("index.html", "/sections/frontend"),
9358 &mut writer,
9359 )
9360 .expect("render failed");
9361
9362 let html = writer.get_content();
9363
9364 assert!(
9365 html.contains("component=\"app-shell\"") && html.contains("active>"),
9366 "root route should be active: {html}"
9367 );
9368 assert!(
9370 !html.contains("<template shadowrootmode"),
9371 "webui-route should be light DOM (no shadow template): {html}"
9372 );
9373 }
9374
9375 #[test]
9376 fn test_nested_routes_render_outlet_as_light_dom() {
9377 let protocol = make_nested_route_protocol();
9378 let state = test_json!({"title": "Test"});
9379 let handler = WebUIHandler::new();
9380 let mut writer = TestWriter::new();
9381
9382 handler
9383 .handle(
9384 &protocol,
9385 &state,
9386 &RenderOptions::new("index.html", "/sections/frontend"),
9387 &mut writer,
9388 )
9389 .expect("render failed");
9390
9391 let html = writer.get_content();
9392
9393 assert!(
9395 !html.contains("<webui-outlet>"),
9396 "should not contain webui-outlet wrapper: {html}"
9397 );
9398 assert!(
9400 html.contains("<webui-route"),
9401 "should contain webui-route elements: {html}"
9402 );
9403 }
9404
9405 #[test]
9406 fn test_nested_routes_match_child_at_outlet() {
9407 let protocol = make_nested_route_protocol();
9408 let state = test_json!({"title": "Test"});
9409 let handler = WebUIHandler::new();
9410 let mut writer = TestWriter::new();
9411
9412 handler
9413 .handle(
9414 &protocol,
9415 &state,
9416 &RenderOptions::new("index.html", "/sections/frontend"),
9417 &mut writer,
9418 )
9419 .expect("render failed");
9420
9421 let html = writer.get_content();
9422
9423 assert!(
9424 html.contains("component=\"section-comp\"") && html.contains("active>"),
9425 "section route should be active: {html}"
9426 );
9427 assert!(
9428 html.contains("<h2>Section</h2>"),
9429 "section content should be present: {html}"
9430 );
9431 }
9432
9433 #[test]
9434 fn test_nested_routes_three_levels_deep() {
9435 let protocol = make_nested_route_protocol();
9436 let state = test_json!({"title": "Test"});
9437 let handler = WebUIHandler::new();
9438 let mut writer = TestWriter::new();
9439
9440 handler
9441 .handle(
9442 &protocol,
9443 &state,
9444 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9445 &mut writer,
9446 )
9447 .expect("render failed");
9448
9449 let html = writer.get_content();
9450
9451 assert!(
9452 html.contains("component=\"app-shell\"") && html.contains("active>"),
9453 "root active: {html}"
9454 );
9455 assert!(
9456 html.contains("component=\"section-comp\"") && html.contains("active>"),
9457 "section active: {html}"
9458 );
9459 assert!(
9460 html.contains("component=\"topic-comp\"")
9461 && html.contains("exact")
9462 && html.contains("active>"),
9463 "topic active: {html}"
9464 );
9465 assert!(
9466 html.contains("<p>Topic content</p>"),
9467 "leaf content present: {html}"
9468 );
9469 }
9470
9471 #[test]
9472 fn test_nested_routes_nonmatched_siblings_hidden() {
9473 let protocol = make_nested_route_protocol();
9474 let state = test_json!({"title": "Test"});
9475 let handler = WebUIHandler::new();
9476 let mut writer = TestWriter::new();
9477
9478 handler
9479 .handle(
9480 &protocol,
9481 &state,
9482 &RenderOptions::new("index.html", "/sections/frontend"),
9483 &mut writer,
9484 )
9485 .expect("render failed");
9486
9487 let html = writer.get_content();
9488
9489 assert!(
9490 html.contains(r#"component="topic-comp" exact style="display:none">"#),
9491 "topic should be hidden: {html}"
9492 );
9493 }
9494
9495 #[test]
9496 fn test_nested_routes_root_only() {
9497 let protocol = make_nested_route_protocol();
9498 let state = test_json!({"title": "Test"});
9499 let handler = WebUIHandler::new();
9500 let mut writer = TestWriter::new();
9501
9502 handler
9503 .handle(
9504 &protocol,
9505 &state,
9506 &RenderOptions::new("index.html", "/"),
9507 &mut writer,
9508 )
9509 .expect("render failed");
9510
9511 let html = writer.get_content();
9512
9513 assert!(
9514 html.contains("component=\"app-shell\"") && html.contains("active>"),
9515 "root active at /: {html}"
9516 );
9517 assert!(
9518 html.contains("<h1>Shell</h1>"),
9519 "shell renders at /: {html}"
9520 );
9521 assert!(
9522 html.contains(r#"component="section-comp" style="display:none">"#),
9523 "section hidden at /: {html}"
9524 );
9525 }
9526
9527 #[test]
9530 fn test_css_module_emitted_once_inline_in_component() {
9531 let template = r#"<p><slot></slot></p>"#;
9534
9535 let mut fragments = HashMap::new();
9536 fragments.insert(
9537 "index.html".to_string(),
9538 FragmentList {
9539 fragments: vec![
9540 WebUIFragment::raw("<html><head>".to_string()),
9541 structural_fragment("head_end"),
9542 WebUIFragment::raw("</head><body><div>".to_string()),
9543 WebUIFragment::component("my-card"),
9544 WebUIFragment::raw("A".to_string()),
9545 WebUIFragment::component("my-card"),
9546 WebUIFragment::raw("B</div>".to_string()),
9547 structural_fragment("body_end"),
9548 WebUIFragment::raw("</body></html>".to_string()),
9549 ],
9550 contains_boundary: false,
9551 },
9552 );
9553 fragments.insert(
9554 "my-card".to_string(),
9555 FragmentList {
9556 fragments: vec![WebUIFragment::raw(template.to_string())],
9557 contains_boundary: false,
9558 },
9559 );
9560
9561 let mut protocol = WebUIProtocol::new(fragments);
9562 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
9563 protocol
9564 .components
9565 .entry("my-card".to_string())
9566 .or_default()
9567 .css = "p{color:red}".to_string();
9568 protocol.populate_style_closures(&["index.html"]);
9569 let state = test_json!({});
9570 let mut writer = TestWriter::new();
9571
9572 handle(
9573 &protocol,
9574 &state,
9575 &RenderOptions::new("index.html", "/"),
9576 &mut writer,
9577 )
9578 .unwrap();
9579
9580 let html = writer.get_content();
9581
9582 let count = html.matches(r#"<script type="importmap""#).count();
9584 assert_eq!(
9585 count, 1,
9586 "CSS module importmap should be emitted once, got {count} in: {html}"
9587 );
9588 assert!(
9589 html.contains(r#""my-card":"data:text/css,"#),
9590 "Importmap must register my-card under a data: URI: {html}"
9591 );
9592
9593 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
9595 assert_eq!(
9596 tmpl_count, 2,
9597 "Template should render twice, got {tmpl_count} in: {html}"
9598 );
9599
9600 let css_pos = html
9602 .find(r#"<script type="importmap""#)
9603 .expect("CSS module importmap missing");
9604 let body_pos = html.find("<body>").expect("<body> missing");
9605 assert!(
9606 css_pos > body_pos,
9607 "CSS module should be inline in component, not in <head>: {html}"
9608 );
9609 }
9610
9611 #[test]
9612 fn test_component_without_css_renders_normally() {
9613 let mut fragments = HashMap::new();
9615 fragments.insert(
9616 "index.html".to_string(),
9617 FragmentList {
9618 fragments: vec![WebUIFragment::component("my-card")],
9619 contains_boundary: false,
9620 },
9621 );
9622 fragments.insert(
9623 "my-card".to_string(),
9624 FragmentList {
9625 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
9626 contains_boundary: false,
9627 },
9628 );
9629
9630 let protocol = WebUIProtocol::new(fragments);
9631 let state = test_json!({});
9632 let mut writer = TestWriter::new();
9633
9634 handle(
9635 &protocol,
9636 &state,
9637 &RenderOptions::new("index.html", "/"),
9638 &mut writer,
9639 )
9640 .unwrap();
9641
9642 let html = writer.get_content();
9643 assert!(
9644 html.contains("<p>hello</p>"),
9645 "Non-module component should render normally: {html}"
9646 );
9647 }
9648
9649 #[test]
9650 fn test_non_module_strategy_no_css_in_head() {
9651 let template = r#"<p>hello</p>"#;
9654
9655 let mut fragments = HashMap::new();
9656 fragments.insert(
9657 "index.html".to_string(),
9658 FragmentList {
9659 fragments: vec![
9660 WebUIFragment::raw("<html><head>".to_string()),
9661 structural_fragment("head_end"),
9662 WebUIFragment::raw("</head><body>".to_string()),
9663 WebUIFragment::component("my-card"),
9664 WebUIFragment::raw("</body></html>".to_string()),
9665 ],
9666 contains_boundary: false,
9667 },
9668 );
9669 fragments.insert(
9670 "my-card".to_string(),
9671 FragmentList {
9672 fragments: vec![WebUIFragment::raw(template.to_string())],
9673 contains_boundary: false,
9674 },
9675 );
9676
9677 let protocol = WebUIProtocol::new(fragments);
9679 let state = test_json!({});
9680 let mut writer = TestWriter::new();
9681
9682 handle(
9683 &protocol,
9684 &state,
9685 &RenderOptions::new("index.html", "/"),
9686 &mut writer,
9687 )
9688 .unwrap();
9689
9690 let html = writer.get_content();
9691
9692 assert!(
9693 !html.contains(r#"<style type="module""#),
9694 "Non-module strategy should not emit CSS module tags in <head>: {html}"
9695 );
9696 assert!(
9697 !html.contains(r#"<script type="importmap""#),
9698 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
9699 );
9700 assert!(
9701 html.contains("<p>hello</p>"),
9702 "Component should still render: {html}"
9703 );
9704 }
9705
9706 #[test]
9707 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
9708 let mut fragments = HashMap::new();
9709 fragments.insert(
9710 "index.html".to_string(),
9711 FragmentList {
9712 fragments: vec![
9713 WebUIFragment::raw("<html><head>".to_string()),
9714 structural_fragment("head_end"),
9715 WebUIFragment::raw("</head><body><my-card>".to_string()),
9716 WebUIFragment::component("my-card"),
9717 WebUIFragment::raw("</my-card></body></html>".to_string()),
9718 ],
9719 contains_boundary: false,
9720 },
9721 );
9722 fragments.insert(
9723 "my-card".to_string(),
9724 FragmentList {
9725 fragments: vec![WebUIFragment::raw(
9726 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
9727 .to_string(),
9728 )],
9729 contains_boundary: false,
9730},
9731 );
9732
9733 let protocol = WebUIProtocol::new(fragments);
9734 let state = test_json!({});
9735 let mut writer = TestWriter::new();
9736
9737 handle(
9738 &protocol,
9739 &state,
9740 &RenderOptions::new("index.html", "/"),
9741 &mut writer,
9742 )
9743 .unwrap();
9744
9745 let html = writer.get_content();
9746
9747 assert!(
9748 html.contains("<style>.card{color:red}</style>"),
9749 "Style strategy should embed inline CSS in shadow template: {html}"
9750 );
9751 assert!(
9752 !html.contains(r#"<style type="module""#),
9753 "Style strategy should not emit legacy module CSS in <head>: {html}"
9754 );
9755 assert!(
9756 !html.contains(r#"<script type="importmap""#),
9757 "Style strategy should not emit CSS module importmaps in <head>: {html}"
9758 );
9759 }
9760
9761 #[test]
9762 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
9763 let mut fragments = HashMap::new();
9766 fragments.insert(
9767 "index.html".to_string(),
9768 FragmentList {
9769 fragments: vec![
9770 WebUIFragment::raw("<html><head>".to_string()),
9771 structural_fragment("head_end"),
9772 WebUIFragment::raw("</head><body><my-card>".to_string()),
9773 WebUIFragment::component("my-card"),
9774 WebUIFragment::raw("</my-card>".to_string()),
9775 structural_fragment("body_end"),
9776 WebUIFragment::raw("</body></html>".to_string()),
9777 ],
9778 contains_boundary: false,
9779 },
9780 );
9781 fragments.insert(
9782 "my-card".to_string(),
9783 FragmentList {
9784 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
9785 contains_boundary: false,
9786 },
9787 );
9788
9789 let mut protocol = WebUIProtocol::new(fragments);
9790 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9791
9792 let comp = protocol
9793 .components
9794 .entry("my-card".to_string())
9795 .or_default();
9796 comp.css_href = "my-card.css".to_string();
9797 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9798 protocol.populate_style_closures(&["index.html"]);
9799
9800 let state = test_json!({});
9801 let mut writer = TestWriter::new();
9802
9803 handle(
9804 &protocol,
9805 &state,
9806 &RenderOptions::new("index.html", "/"),
9807 &mut writer,
9808 )
9809 .unwrap();
9810
9811 let html = writer.get_content();
9812
9813 let head_end = html.find("</head>").expect("</head> missing");
9814 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
9815 assert!(
9816 link_pos.is_some_and(|p| p < head_end),
9817 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
9818 );
9819 assert!(
9820 !html.contains(r#"<link rel="preload""#),
9821 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
9822 );
9823 assert!(
9824 !html.contains(r#"<style type="module""#),
9825 "Link strategy should not emit legacy module CSS: {html}"
9826 );
9827 assert!(
9828 !html.contains(r#"<script type="importmap""#),
9829 "Link strategy should not emit CSS module importmaps: {html}"
9830 );
9831 }
9832
9833 #[test]
9834 fn link_strategy_preloads_static_shadow_css_in_head() {
9835 let mut fragments = HashMap::new();
9836 fragments.insert(
9837 "index.html".to_string(),
9838 FragmentList {
9839 fragments: vec![
9840 WebUIFragment::raw("<html><head>".to_string()),
9841 structural_fragment("head_end"),
9842 WebUIFragment::raw("</head><body><my-card>".to_string()),
9843 WebUIFragment::component("my-card"),
9844 WebUIFragment::raw("</my-card></body></html>".to_string()),
9845 ],
9846 contains_boundary: false,
9847 },
9848 );
9849 fragments.insert(
9850 "o-loading-state".to_string(),
9851 FragmentList {
9852 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
9853 contains_boundary: false,
9854 },
9855 );
9856 fragments.insert(
9857 "my-card".to_string(),
9858 FragmentList {
9859 fragments: vec![
9860 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9861 structural_fragment("shadow_styles:my-card"),
9862 WebUIFragment::raw("<div>card</div></template>".to_string()),
9863 ],
9864 contains_boundary: false,
9865 },
9866 );
9867
9868 let mut protocol = WebUIProtocol::new(fragments);
9869 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
9870 let component = protocol
9871 .components
9872 .entry("my-card".to_string())
9873 .or_default();
9874 component.css_href = "my-card.css".to_string();
9875 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
9876 component.uses_shadow_dom = true;
9877 protocol.populate_style_closures(&["index.html"]);
9878
9879 let mut writer = TestWriter::new();
9880 handle(
9881 &protocol,
9882 &test_json!({}),
9883 &RenderOptions::new("index.html", "/"),
9884 &mut writer,
9885 )
9886 .unwrap();
9887
9888 let html = writer.get_content();
9889 let head_end = html.find("</head>").expect("</head> missing");
9890 let preload = html
9891 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
9892 .expect("Shadow preload missing");
9893 let stylesheet = html
9894 .find(r#"<link rel="stylesheet" href="my-card.css""#)
9895 .expect("Shadow stylesheet missing");
9896 assert!(preload < head_end);
9897 assert!(stylesheet > head_end);
9898 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
9899 }
9900
9901 #[test]
9902 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
9903 let fragments = HashMap::from([
9904 (
9905 "index.html".to_string(),
9906 FragmentList {
9907 fragments: vec![
9908 WebUIFragment::raw("<html><head>".to_string()),
9909 structural_fragment("head_end"),
9910 WebUIFragment::raw("</head><body><outer-box>".to_string()),
9911 WebUIFragment::component("outer-box"),
9912 WebUIFragment::raw("</outer-box></body></html>".to_string()),
9913 ],
9914 contains_boundary: false,
9915 },
9916 ),
9917 (
9918 "outer-box".to_string(),
9919 FragmentList {
9920 fragments: vec![
9921 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9922 structural_fragment("shadow_styles:outer-box"),
9923 WebUIFragment::raw("<light-card>".to_string()),
9924 WebUIFragment::component("light-card"),
9925 WebUIFragment::raw("</light-card><light-card>".to_string()),
9926 WebUIFragment::component("light-card"),
9927 WebUIFragment::raw("</light-card><inner-box>".to_string()),
9928 WebUIFragment::component("inner-box"),
9929 WebUIFragment::raw("</inner-box><light-card>".to_string()),
9930 WebUIFragment::component("light-card"),
9931 WebUIFragment::raw("</light-card></template>".to_string()),
9932 ],
9933 contains_boundary: false,
9934 },
9935 ),
9936 (
9937 "inner-box".to_string(),
9938 FragmentList {
9939 fragments: vec![
9940 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
9941 structural_fragment("shadow_styles:inner-box"),
9942 WebUIFragment::raw("<light-card>".to_string()),
9943 WebUIFragment::component("light-card"),
9944 WebUIFragment::raw("</light-card></template>".to_string()),
9945 ],
9946 contains_boundary: false,
9947 },
9948 ),
9949 (
9950 "light-card".to_string(),
9951 FragmentList {
9952 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
9953 contains_boundary: false,
9954 },
9955 ),
9956 ]);
9957 let mut protocol = WebUIProtocol::new(fragments);
9958 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
9959 for (tag, css, uses_shadow_dom) in [
9960 ("outer-box", ".outer{}", true),
9961 ("inner-box", ".inner{}", true),
9962 ("light-card", ".card{}", false),
9963 ] {
9964 let component = protocol.components.entry(tag.to_string()).or_default();
9965 component.css = css.to_string();
9966 component.uses_shadow_dom = uses_shadow_dom;
9967 }
9968 protocol.populate_style_closures(&["index.html"]);
9969
9970 let mut writer = TestWriter::new();
9971 handle(
9972 &protocol,
9973 &test_json!({}),
9974 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
9975 &mut writer,
9976 )
9977 .unwrap();
9978 let html = writer.get_content();
9979
9980 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
9981 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
9982 assert_eq!(
9983 html.matches("data-webui-resource=\"light-card\"").count(),
9984 2,
9985 "the Light resource installs once in each Shadow tree: {html}"
9986 );
9987 assert_eq!(
9988 html.matches("data-webui-resource=").count(),
9989 4,
9990 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
9991 );
9992 assert_eq!(
9993 html.matches("nonce=\"css-nonce\"").count(),
9994 4,
9995 "each inline style resource carries the nonce"
9996 );
9997 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
9998 let inner_end = html.find("</inner-box>").expect("inner host close");
9999 let caller_continuation = &html[inner_end..];
10000 let trailing_card = caller_continuation
10001 .find("<p>card</p>")
10002 .expect("caller continues after nested Shadow root");
10003 assert!(trailing_card > 0);
10004 assert!(
10005 !caller_continuation.contains("data-webui-resource=\"light-card\""),
10006 "returning from the nested Shadow root must restore the outer frame's delivered set"
10007 );
10008 }
10009
10010 #[test]
10011 fn routed_document_styles_are_hoisted_for_the_active_chain() {
10012 let route = WebUiFragmentRoute {
10013 path: "/".to_string(),
10014 fragment_id: "app-shell".to_string(),
10015 children: vec![
10016 WebUiFragmentRoute {
10017 path: String::new(),
10018 fragment_id: "dashboard-page".to_string(),
10019 exact: true,
10020 ..Default::default()
10021 },
10022 WebUiFragmentRoute {
10023 path: "details".to_string(),
10024 fragment_id: "detail-page".to_string(),
10025 exact: true,
10026 ..Default::default()
10027 },
10028 ],
10029 ..Default::default()
10030 };
10031 let fragments = HashMap::from([
10032 (
10033 "index.html".to_string(),
10034 FragmentList {
10035 fragments: vec![
10036 WebUIFragment::raw("<html><head>"),
10037 structural_fragment("head_start"),
10038 structural_fragment("head_end"),
10039 WebUIFragment::raw("</head><body>"),
10040 structural_fragment("body_start"),
10041 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10042 WebUIFragment::route_from(route),
10043 WebUIFragment::boundary_end(0),
10044 structural_fragment("body_end"),
10045 WebUIFragment::raw("</body></html>"),
10046 ],
10047 contains_boundary: true,
10048 },
10049 ),
10050 (
10051 "app-shell".to_string(),
10052 FragmentList {
10053 fragments: vec![
10054 WebUIFragment::raw("<header>App</header>"),
10055 WebUIFragment::outlet(),
10056 ],
10057 contains_boundary: false,
10058 },
10059 ),
10060 (
10061 "dashboard-page".to_string(),
10062 FragmentList {
10063 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10064 contains_boundary: false,
10065 },
10066 ),
10067 (
10068 "detail-page".to_string(),
10069 FragmentList {
10070 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10071 contains_boundary: false,
10072 },
10073 ),
10074 ]);
10075 let mut protocol = WebUIProtocol::new(fragments);
10076 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10077 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10078 let component = protocol.components.entry(tag.to_string()).or_default();
10079 component.css_href = format!("/{tag}.css");
10080 component.template_json = r#"{"h":""}"#.to_string();
10081 }
10082 protocol.populate_style_closures(&["index.html"]);
10083
10084 let mut writer = TestWriter::new();
10085 handle(
10086 &protocol,
10087 &test_json!({}),
10088 &RenderOptions::new("index.html", "/"),
10089 &mut writer,
10090 )
10091 .unwrap();
10092 let html = writer.get_content();
10093
10094 assert_eq!(
10095 html.matches(r#"data-webui-resource="app-shell""#).count(),
10096 1,
10097 "the matched route shell must install once: {html}"
10098 );
10099 assert_eq!(
10100 html.matches(r#"data-webui-resource="dashboard-page""#)
10101 .count(),
10102 1,
10103 "the matched child route must install once: {html}"
10104 );
10105 assert!(
10106 !html.contains(r#"data-webui-resource="detail-page""#),
10107 "inactive route CSS must not be delivered: {html}"
10108 );
10109 let head_end = html.find("</head>").expect("head close");
10110 let head = &html[..head_end];
10111 for (resource, href) in [
10112 ("app-shell", "/app-shell.css"),
10113 ("dashboard-page", "/dashboard-page.css"),
10114 ] {
10115 let encoded_href = crate::html_encode::encode_safe(href);
10116 assert!(
10117 head.contains(&format!(
10118 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10119 )),
10120 "Document-targeted route CSS must be render-blocking in head: {html}"
10121 );
10122 assert!(
10123 !head.contains(&format!(
10124 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10125 )),
10126 "a hoisted stylesheet must not also be preloaded: {html}"
10127 );
10128 }
10129 assert!(
10130 !head.contains("detail-page.css"),
10131 "inactive route CSS must not be preloaded: {html}"
10132 );
10133 let app_style = html
10134 .find(r#"data-webui-resource="app-shell""#)
10135 .expect("app style");
10136 let dashboard_style = html
10137 .find(r#"data-webui-resource="dashboard-page""#)
10138 .expect("dashboard style");
10139 let app_host = html.find("<app-shell").expect("app route host");
10140 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10141 assert!(
10142 app_style < dashboard_style
10143 && dashboard_style < head_end
10144 && head_end < app_host
10145 && app_host < dashboard_host,
10146 "matched Document route closures must be hoisted in chain order: {html}"
10147 );
10148
10149 let protocol = Protocol::new(protocol);
10150 let mut streamed = FlushTestWriter::default();
10151 WebUIHandler::new()
10152 .render_streaming(
10153 &protocol,
10154 &test_json!({}),
10155 &RenderOptions::new("index.html", "/"),
10156 &mut streamed,
10157 )
10158 .unwrap();
10159 let streamed_head_end = streamed
10160 .output
10161 .find("</head>")
10162 .expect("streamed head close");
10163 for resource in ["app-shell", "dashboard-page"] {
10164 let marker = format!(r#"data-webui-resource="{resource}""#);
10165 let position = streamed.output.find(&marker).expect("streamed route style");
10166 assert!(
10167 position < streamed_head_end,
10168 "streaming must hoist {resource} into head: {}",
10169 streamed.output
10170 );
10171 assert_eq!(
10172 streamed.output.matches(&marker).count(),
10173 1,
10174 "streaming must not emit {resource} twice: {}",
10175 streamed.output
10176 );
10177 }
10178 }
10179
10180 #[test]
10181 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10182 let route = WebUiFragmentRoute {
10183 path: "/".to_string(),
10184 fragment_id: "dashboard-page".to_string(),
10185 exact: true,
10186 ..Default::default()
10187 };
10188 let mut protocol = WebUIProtocol::new(HashMap::from([
10189 (
10190 "index.html".to_string(),
10191 FragmentList {
10192 fragments: vec![
10193 WebUIFragment::raw("<html><head>"),
10194 structural_fragment("head_start"),
10195 structural_fragment("head_end"),
10196 WebUIFragment::raw("</head><body>"),
10197 WebUIFragment::route_from(route),
10198 structural_fragment("body_end"),
10199 WebUIFragment::raw("</body></html>"),
10200 ],
10201 contains_boundary: false,
10202 },
10203 ),
10204 (
10205 "dashboard-page".to_string(),
10206 FragmentList {
10207 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10208 contains_boundary: false,
10209 },
10210 ),
10211 ]));
10212 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10213 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10214 protocol
10215 .components
10216 .entry(tag.to_string())
10217 .or_default()
10218 .css_href = format!("/{tag}.css");
10219 }
10220 protocol.style_closures.insert(
10221 "index.html".to_string(),
10222 webui_protocol::ComponentStyleClosure::default(),
10223 );
10224 protocol.style_closures.insert(
10225 "dashboard-page".to_string(),
10226 webui_protocol::ComponentStyleClosure {
10227 component_tags: vec![
10228 "dashboard-page".to_string(),
10229 "summary-card".to_string(),
10230 "activity-list".to_string(),
10231 ],
10232 style_chunks: vec![0],
10233 },
10234 );
10235 protocol.style_chunks.push(webui_protocol::StyleChunk {
10236 name: "_chunk-dashboard-page-3".to_string(),
10237 css: String::new(),
10238 css_href: "/_chunk-dashboard-page-3.css".to_string(),
10239 component_tags: vec![
10240 "dashboard-page".to_string(),
10241 "summary-card".to_string(),
10242 "activity-list".to_string(),
10243 ],
10244 });
10245
10246 let mut writer = TestWriter::new();
10247 handle(
10248 &protocol,
10249 &test_json!({}),
10250 &RenderOptions::new("index.html", "/"),
10251 &mut writer,
10252 )
10253 .unwrap();
10254 let html = writer.get_content();
10255 let head_end = html.find("</head>").expect("head close");
10256 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10257 let style = html.find(marker).expect("bundled route stylesheet");
10258
10259 assert!(
10260 style < head_end,
10261 "bundled route CSS must be in head: {html}"
10262 );
10263 assert_eq!(
10264 html.matches(marker).count(),
10265 1,
10266 "the route host must not emit the hoisted chunk again: {html}"
10267 );
10268 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10269 assert!(
10270 !html.contains(&format!(
10271 r#"<link rel="preload" as="style" href="{bundled_href}">"#
10272 )),
10273 "the applied bundled stylesheet must not also be preloaded: {html}"
10274 );
10275 }
10276
10277 #[test]
10278 fn routed_document_inline_styles_are_hoisted() {
10279 for strategy in [
10280 webui_protocol::CssStrategy::Style,
10281 webui_protocol::CssStrategy::Module,
10282 ] {
10283 let route = WebUiFragmentRoute {
10284 path: "/".to_string(),
10285 fragment_id: "dashboard-page".to_string(),
10286 exact: true,
10287 ..Default::default()
10288 };
10289 let mut protocol = WebUIProtocol::new(HashMap::from([
10290 (
10291 "index.html".to_string(),
10292 FragmentList {
10293 fragments: vec![
10294 WebUIFragment::raw("<html><head>"),
10295 structural_fragment("head_start"),
10296 structural_fragment("head_end"),
10297 WebUIFragment::raw("</head><body>"),
10298 WebUIFragment::route_from(route),
10299 structural_fragment("body_end"),
10300 WebUIFragment::raw("</body></html>"),
10301 ],
10302 contains_boundary: false,
10303 },
10304 ),
10305 (
10306 "dashboard-page".to_string(),
10307 FragmentList {
10308 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10309 contains_boundary: false,
10310 },
10311 ),
10312 ]));
10313 protocol.set_css_strategy(strategy);
10314 protocol
10315 .components
10316 .entry("dashboard-page".to_string())
10317 .or_default()
10318 .css = ".dashboard{display:grid}".to_string();
10319 protocol.populate_style_closures(&["index.html"]);
10320
10321 let mut writer = TestWriter::new();
10322 handle(
10323 &protocol,
10324 &test_json!({}),
10325 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10326 &mut writer,
10327 )
10328 .unwrap();
10329 let html = writer.get_content();
10330 let head_end = html.find("</head>").expect("head close");
10331 let marker = r#"data-webui-resource="dashboard-page""#;
10332 let style = html.find(marker).expect("route style fallback");
10333 let host = html.find("<dashboard-page").expect("dashboard route host");
10334 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10335 "module"
10336 } else {
10337 "style"
10338 };
10339 let style_marker = format!(
10340 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10341 );
10342
10343 assert!(
10344 style < head_end && head_end < host,
10345 "{strategy:?} route CSS must be applied in head before its host: {html}"
10346 );
10347 assert_eq!(
10348 html.matches(&style_marker).count(),
10349 1,
10350 "{strategy:?} route CSS must not be emitted twice: {html}"
10351 );
10352 }
10353 }
10354
10355 #[test]
10356 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10357 let route = WebUiFragmentRoute {
10358 path: "/".to_string(),
10359 fragment_id: "dashboard-page".to_string(),
10360 exact: true,
10361 ..Default::default()
10362 };
10363 let mut protocol = WebUIProtocol::new(HashMap::from([
10364 (
10365 "index.html".to_string(),
10366 FragmentList {
10367 fragments: vec![
10368 WebUIFragment::raw("<html><head>"),
10369 structural_fragment("head_start"),
10370 structural_fragment("head_end"),
10371 WebUIFragment::raw("</head><body><outer-box>"),
10372 WebUIFragment::component("outer-box"),
10373 WebUIFragment::raw("</outer-box></body></html>"),
10374 ],
10375 contains_boundary: false,
10376 },
10377 ),
10378 (
10379 "outer-box".to_string(),
10380 FragmentList {
10381 fragments: vec![
10382 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10383 structural_fragment("shadow_styles:outer-box"),
10384 WebUIFragment::route_from(route),
10385 WebUIFragment::raw("</template>"),
10386 ],
10387 contains_boundary: false,
10388 },
10389 ),
10390 (
10391 "dashboard-page".to_string(),
10392 FragmentList {
10393 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10394 contains_boundary: false,
10395 },
10396 ),
10397 ]));
10398 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10399 for tag in ["outer-box", "dashboard-page"] {
10400 protocol
10401 .components
10402 .entry(tag.to_string())
10403 .or_default()
10404 .css_href = format!("/{tag}.css");
10405 }
10406 protocol
10407 .components
10408 .get_mut("outer-box")
10409 .expect("outer-box component")
10410 .uses_shadow_dom = true;
10411 protocol.populate_style_closures(&["index.html"]);
10412
10413 let mut writer = TestWriter::new();
10414 handle(
10415 &protocol,
10416 &test_json!({}),
10417 &RenderOptions::new("index.html", "/"),
10418 &mut writer,
10419 )
10420 .unwrap();
10421 let html = writer.get_content();
10422 let head_end = html.find("</head>").expect("head close");
10423 let head = &html[..head_end];
10424 let dashboard_style = html
10425 .find(r#"data-webui-resource="dashboard-page""#)
10426 .expect("tree-local dashboard stylesheet");
10427 let shadow_start = html
10428 .find("<template shadowrootmode=\"open\">")
10429 .expect("Shadow template");
10430 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10431 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10432
10433 assert!(
10434 head.contains(&format!(
10435 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10436 )),
10437 "Shadow-targeted route CSS must start loading from head: {html}"
10438 );
10439 assert!(
10440 !head.contains(r#"data-webui-resource="dashboard-page""#),
10441 "Shadow-targeted route CSS must not be applied to Document: {html}"
10442 );
10443 assert!(
10444 shadow_start < dashboard_style && dashboard_style < dashboard_host,
10445 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10446 );
10447 }
10448
10449 #[test]
10450 fn isolated_shadow_entry_does_not_emit_document_preloads() {
10451 let route = WebUiFragmentRoute {
10452 path: "/".to_string(),
10453 fragment_id: "dashboard-page".to_string(),
10454 exact: true,
10455 ..Default::default()
10456 };
10457 let mut protocol = WebUIProtocol::new(HashMap::from([
10458 (
10459 "outer-box".to_string(),
10460 FragmentList {
10461 fragments: vec![
10462 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10463 structural_fragment("shadow_styles:outer-box"),
10464 WebUIFragment::route_from(route),
10465 WebUIFragment::raw("</template>"),
10466 ],
10467 contains_boundary: false,
10468 },
10469 ),
10470 (
10471 "dashboard-page".to_string(),
10472 FragmentList {
10473 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10474 contains_boundary: false,
10475 },
10476 ),
10477 ]));
10478 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10479 for tag in ["outer-box", "dashboard-page"] {
10480 protocol
10481 .components
10482 .entry(tag.to_string())
10483 .or_default()
10484 .css_href = format!("/{tag}.css");
10485 }
10486 protocol
10487 .components
10488 .get_mut("outer-box")
10489 .expect("outer-box component")
10490 .uses_shadow_dom = true;
10491 protocol.populate_style_closures(&["outer-box"]);
10492
10493 let mut writer = TestWriter::new();
10494 handle(
10495 &protocol,
10496 &test_json!({}),
10497 &RenderOptions::new("outer-box", "/"),
10498 &mut writer,
10499 )
10500 .unwrap();
10501 let html = writer.get_content();
10502
10503 assert!(
10504 html.starts_with("<template shadowrootmode=\"open\">"),
10505 "isolated component output must not be prefixed with document metadata: {html}"
10506 );
10507 assert!(
10508 !html.contains(r#"<link rel="preload""#),
10509 "an isolated Shadow entry has no Document head for preloads: {html}"
10510 );
10511 assert!(
10512 html.contains(r#"data-webui-resource="dashboard-page""#),
10513 "the route stylesheet must still install inside the ShadowRoot: {html}"
10514 );
10515 }
10516
10517 #[test]
10518 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
10519 let route = WebUiFragmentRoute {
10520 path: "/".to_string(),
10521 fragment_id: "app-shell".to_string(),
10522 children: vec![
10523 WebUiFragmentRoute {
10524 path: String::new(),
10525 fragment_id: "dashboard-page".to_string(),
10526 exact: true,
10527 ..Default::default()
10528 },
10529 WebUiFragmentRoute {
10530 path: "details".to_string(),
10531 fragment_id: "detail-page".to_string(),
10532 exact: true,
10533 ..Default::default()
10534 },
10535 ],
10536 ..Default::default()
10537 };
10538 let fragments = HashMap::from([
10539 (
10540 "index.html".to_string(),
10541 FragmentList {
10542 fragments: vec![
10543 WebUIFragment::raw("<html><head>"),
10544 structural_fragment("head_start"),
10545 structural_fragment("head_end"),
10546 WebUIFragment::raw("</head><body>"),
10547 structural_fragment("body_start"),
10548 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10549 WebUIFragment::route_from(route),
10550 WebUIFragment::boundary_end(0),
10551 structural_fragment("body_end"),
10552 WebUIFragment::raw("</body></html>"),
10553 ],
10554 contains_boundary: true,
10555 },
10556 ),
10557 (
10558 "app-shell".to_string(),
10559 FragmentList {
10560 fragments: vec![
10561 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10562 structural_fragment("shadow_styles:app-shell"),
10563 WebUIFragment::raw("<shared-card"),
10564 structural_fragment("streaming_root:shared-card"),
10565 WebUIFragment::raw(">"),
10566 WebUIFragment::component("shared-card"),
10567 WebUIFragment::raw("</shared-card>"),
10568 WebUIFragment::outlet(),
10569 WebUIFragment::raw("</template>"),
10570 ],
10571 contains_boundary: false,
10572 },
10573 ),
10574 (
10575 "dashboard-page".to_string(),
10576 FragmentList {
10577 fragments: vec![
10578 WebUIFragment::raw("<shared-card"),
10579 structural_fragment("streaming_root:shared-card"),
10580 WebUIFragment::raw(">"),
10581 WebUIFragment::component("shared-card"),
10582 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
10583 ],
10584 contains_boundary: false,
10585 },
10586 ),
10587 (
10588 "detail-page".to_string(),
10589 FragmentList {
10590 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10591 contains_boundary: false,
10592 },
10593 ),
10594 (
10595 "shared-card".to_string(),
10596 FragmentList {
10597 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
10598 contains_boundary: false,
10599 },
10600 ),
10601 ]);
10602 let mut protocol = WebUIProtocol::new(fragments);
10603 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10604 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
10605 let component = protocol.components.entry(tag.to_string()).or_default();
10606 component.css = format!(".{tag}{{display:block}}");
10607 component.template_json = r#"{"h":""}"#.to_string();
10608 }
10609 protocol
10610 .components
10611 .get_mut("app-shell")
10612 .expect("app shell component")
10613 .uses_shadow_dom = true;
10614 protocol.populate_style_closures(&["index.html"]);
10615
10616 let mut writer = TestWriter::new();
10617 handle(
10618 &protocol,
10619 &test_json!({}),
10620 &RenderOptions::new("index.html", "/"),
10621 &mut writer,
10622 )
10623 .unwrap();
10624 let html = writer.get_content();
10625
10626 for resource in ["app-shell", "shared-card", "dashboard-page"] {
10627 assert_eq!(
10628 html.matches(&format!(r#"data-webui-resource="{resource}""#))
10629 .count(),
10630 1,
10631 "{resource} must install once in the active ShadowRoot: {html}"
10632 );
10633 }
10634 assert!(
10635 !html.contains(r#"data-webui-resource="detail-page""#),
10636 "inactive route CSS must not enter the ShadowRoot: {html}"
10637 );
10638 let shadow_start = html
10639 .find("<template shadowrootmode=\"open\">")
10640 .expect("Shadow template");
10641 let dashboard_style = html
10642 .find(r#"data-webui-resource="dashboard-page""#)
10643 .expect("dashboard style");
10644 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10645 let shadow_end = html.find("</template>").expect("Shadow template close");
10646 assert!(
10647 shadow_start < dashboard_style
10648 && dashboard_style < dashboard_host
10649 && dashboard_host < shadow_end,
10650 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
10651 );
10652
10653 let protocol = Protocol::new(protocol);
10654 let mut streamed = FlushTestWriter::default();
10655 WebUIHandler::new()
10656 .render_streaming(
10657 &protocol,
10658 &test_json!({}),
10659 &RenderOptions::new("index.html", "/"),
10660 &mut streamed,
10661 )
10662 .unwrap();
10663 for resource in ["app-shell", "shared-card"] {
10664 assert_eq!(
10665 streamed
10666 .output
10667 .matches(&format!(r#"data-webui-resource="{resource}""#))
10668 .count(),
10669 1,
10670 "streaming must preserve tree-local delivery for {resource}: {}",
10671 streamed.output
10672 );
10673 }
10674 assert!(
10675 !streamed
10676 .output
10677 .contains(r#"data-webui-resource="dashboard-page""#),
10678 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
10679 streamed.output
10680 );
10681 assert!(
10682 streamed
10683 .output
10684 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
10685 && streamed
10686 .output
10687 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
10688 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
10689 streamed.output
10690 );
10691 assert!(
10692 !streamed
10693 .output
10694 .contains(r#"data-webui-resource="detail-page""#),
10695 "streaming must omit inactive route CSS: {}",
10696 streamed.output
10697 );
10698 }
10699
10700 #[test]
10701 fn document_styles_follow_doctype_before_headless_document_content() {
10702 for entry_fragments in [
10703 vec![
10704 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
10705 structural_fragment("body_start"),
10706 WebUIFragment::raw("<my-card>"),
10707 WebUIFragment::component("my-card"),
10708 WebUIFragment::raw("</my-card></body></html>"),
10709 ],
10710 vec![
10711 WebUIFragment::raw("<!doctype html><html><my-card>"),
10712 WebUIFragment::component("my-card"),
10713 WebUIFragment::raw("</my-card></html>"),
10714 ],
10715 ] {
10716 let mut protocol = WebUIProtocol::new(HashMap::from([
10717 (
10718 "index.html".to_string(),
10719 FragmentList {
10720 fragments: entry_fragments,
10721 contains_boundary: false,
10722 },
10723 ),
10724 (
10725 "my-card".to_string(),
10726 FragmentList {
10727 fragments: vec![WebUIFragment::raw("<p>card</p>")],
10728 contains_boundary: false,
10729 },
10730 ),
10731 ]));
10732 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10733 let component = protocol
10734 .components
10735 .entry("my-card".to_string())
10736 .or_default();
10737 component.css = ".card{color:red}".to_string();
10738 component.uses_shadow_dom = false;
10739 protocol.populate_style_closures(&["index.html"]);
10740
10741 let mut writer = TestWriter::new();
10742 handle(
10743 &protocol,
10744 &test_json!({}),
10745 &RenderOptions::new("index.html", "/"),
10746 &mut writer,
10747 )
10748 .expect("render");
10749 let html = writer.get_content();
10750
10751 assert!(
10752 html.to_ascii_lowercase().starts_with("<!doctype html>"),
10753 "the doctype must remain the first document token: {html}"
10754 );
10755 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
10756 let document = html.find("<html").expect("document root");
10757 let component = html.find("<my-card>").expect("component host");
10758 assert!(
10759 style < document && style < component,
10760 "headless document styles must precede document content: {html}"
10761 );
10762 }
10763 }
10764
10765 #[test]
10766 fn authored_shadow_component_can_render_as_entry_fragment() {
10767 let mut protocol = WebUIProtocol::new(HashMap::from([(
10768 "my-card".to_string(),
10769 FragmentList {
10770 fragments: vec![
10771 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10772 structural_fragment("shadow_styles:my-card"),
10773 WebUIFragment::raw("<p>card</p></template>"),
10774 ],
10775 contains_boundary: false,
10776 },
10777 )]));
10778 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10779 let component = protocol
10780 .components
10781 .entry("my-card".to_string())
10782 .or_default();
10783 component.css = ".card{color:red}".to_string();
10784 component.uses_shadow_dom = true;
10785 protocol.populate_style_closures(&["my-card"]);
10786
10787 let mut writer = TestWriter::new();
10788 handle(
10789 &protocol,
10790 &test_json!({}),
10791 &RenderOptions::new("my-card", "/"),
10792 &mut writer,
10793 )
10794 .expect("render");
10795 let html = writer.get_content();
10796
10797 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
10798 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
10799 assert!(html.ends_with("<p>card</p></template>"));
10800 }
10801
10802 #[test]
10803 fn component_style_closures_escape_mixed_case_style_end_tags() {
10804 for strategy in [
10805 webui_protocol::CssStrategy::Style,
10806 webui_protocol::CssStrategy::Module,
10807 ] {
10808 let mut protocol = WebUIProtocol::new(HashMap::from([
10809 (
10810 "index.html".to_string(),
10811 FragmentList {
10812 fragments: vec![
10813 WebUIFragment::raw("<html><head>"),
10814 structural_fragment("head_end"),
10815 WebUIFragment::raw("</head><body><safe-card>"),
10816 WebUIFragment::component("safe-card"),
10817 WebUIFragment::raw("</safe-card></body></html>"),
10818 ],
10819 contains_boundary: false,
10820 },
10821 ),
10822 (
10823 "safe-card".to_string(),
10824 FragmentList {
10825 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
10826 contains_boundary: false,
10827 },
10828 ),
10829 ]));
10830 protocol.set_css_strategy(strategy);
10831 protocol.components.insert(
10832 "safe-card".to_string(),
10833 webui_protocol::ComponentData {
10834 css: ".safe{content:'</StYlE>'}".to_string(),
10835 ..Default::default()
10836 },
10837 );
10838 protocol.populate_style_closures(&["index.html"]);
10839 let mut writer = TestWriter::new();
10840
10841 handle(
10842 &protocol,
10843 &test_json!({}),
10844 &RenderOptions::new("index.html", "/"),
10845 &mut writer,
10846 )
10847 .unwrap();
10848
10849 let html = writer.get_content();
10850 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
10851 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
10852 }
10853 }
10854
10855 #[test]
10856 fn shadow_closure_supports_link_and_module_ssr_paths() {
10857 for strategy in [
10858 webui_protocol::CssStrategy::Link,
10859 webui_protocol::CssStrategy::Module,
10860 ] {
10861 let mut protocol = WebUIProtocol::new(HashMap::from([
10862 (
10863 "index.html".to_string(),
10864 FragmentList {
10865 fragments: vec![
10866 WebUIFragment::raw("<html><head>".to_string()),
10867 structural_fragment("head_end"),
10868 WebUIFragment::raw("</head><body><my-card>".to_string()),
10869 WebUIFragment::component("my-card"),
10870 WebUIFragment::raw("</my-card></body></html>".to_string()),
10871 ],
10872 contains_boundary: false,
10873 },
10874 ),
10875 (
10876 "my-card".to_string(),
10877 FragmentList {
10878 fragments: vec![
10879 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10880 structural_fragment("shadow_styles:my-card"),
10881 WebUIFragment::raw("<p>card</p></template>".to_string()),
10882 ],
10883 contains_boundary: false,
10884 },
10885 ),
10886 ]));
10887 protocol.set_css_strategy(strategy);
10888 let component = protocol
10889 .components
10890 .entry("my-card".to_string())
10891 .or_default();
10892 component.css = ".card{color:red}".to_string();
10893 component.css_href = "/my-card.css".to_string();
10894 component.uses_shadow_dom = true;
10895 protocol.populate_style_closures(&["index.html"]);
10896
10897 let mut writer = TestWriter::new();
10898 handle(
10899 &protocol,
10900 &test_json!({}),
10901 &RenderOptions::new("index.html", "/"),
10902 &mut writer,
10903 )
10904 .unwrap();
10905 let html = writer.get_content();
10906 match strategy {
10907 webui_protocol::CssStrategy::Link => {
10908 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
10909 assert!(html.contains(&format!(
10910 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
10911 )));
10912 }
10913 webui_protocol::CssStrategy::Module => {
10914 assert!(html.contains(
10915 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
10916 ));
10917 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
10918 }
10919 webui_protocol::CssStrategy::Style => unreachable!(),
10920 }
10921 }
10922 }
10923
10924 #[test]
10925 fn test_module_preloads_emit_in_head_in_compiler_order() {
10926 let mut fragments = HashMap::new();
10930 fragments.insert(
10931 "index.html".to_string(),
10932 FragmentList {
10933 fragments: vec![
10934 WebUIFragment::raw(
10935 r#"<html><head><script type="module" async src="/index.js"></script>"#
10936 .to_string(),
10937 ),
10938 structural_fragment("head_end"),
10939 WebUIFragment::raw("</head><body>".to_string()),
10940 structural_fragment("body_end"),
10941 WebUIFragment::raw("</body></html>".to_string()),
10942 ],
10943 contains_boundary: false,
10944 },
10945 );
10946
10947 let mut protocol = WebUIProtocol::new(fragments);
10948 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
10949
10950 let state = test_json!({});
10951 let mut writer = TestWriter::new();
10952 handle(
10953 &protocol,
10954 &state,
10955 &RenderOptions::new("index.html", "/"),
10956 &mut writer,
10957 )
10958 .unwrap();
10959
10960 let html = writer.get_content();
10961 let head_end = html.find("</head>").expect("</head> missing");
10962 let head = &html[..head_end];
10963 assert!(
10964 head.contains(
10965 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
10966 ),
10967 "hints must appear in <head> in the compiler's order: {html}"
10968 );
10969 }
10970
10971 #[test]
10972 fn test_no_module_preloads_emits_nothing() {
10973 let mut fragments = HashMap::new();
10974 fragments.insert(
10975 "index.html".to_string(),
10976 FragmentList {
10977 fragments: vec![
10978 WebUIFragment::raw("<html><head>".to_string()),
10979 structural_fragment("head_end"),
10980 WebUIFragment::raw("</head><body>".to_string()),
10981 structural_fragment("body_end"),
10982 WebUIFragment::raw("</body></html>".to_string()),
10983 ],
10984 contains_boundary: false,
10985 },
10986 );
10987 let protocol = WebUIProtocol::new(fragments);
10988
10989 let state = test_json!({});
10990 let mut writer = TestWriter::new();
10991 handle(
10992 &protocol,
10993 &state,
10994 &RenderOptions::new("index.html", "/"),
10995 &mut writer,
10996 )
10997 .unwrap();
10998
10999 assert!(
11000 !writer.get_content().contains("modulepreload"),
11001 "a build without hints must be byte-identical to before"
11002 );
11003 }
11004
11005 #[test]
11006 fn test_link_strategy_head_links_follow_document_order() {
11007 let mut fragments = HashMap::new();
11012 fragments.insert(
11013 "index.html".to_string(),
11014 FragmentList {
11015 fragments: vec![
11016 WebUIFragment::raw("<html><head>".to_string()),
11017 structural_fragment("head_end"),
11018 WebUIFragment::raw("</head><body><z-widget>".to_string()),
11019 WebUIFragment::component("z-widget"),
11020 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11021 WebUIFragment::component("a-widget"),
11022 WebUIFragment::raw("</a-widget>".to_string()),
11023 structural_fragment("body_end"),
11024 WebUIFragment::raw("</body></html>".to_string()),
11025 ],
11026 contains_boundary: false,
11027 },
11028 );
11029 fragments.insert(
11030 "z-widget".to_string(),
11031 FragmentList {
11032 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11033 contains_boundary: false,
11034 },
11035 );
11036 fragments.insert(
11037 "a-widget".to_string(),
11038 FragmentList {
11039 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11040 contains_boundary: false,
11041 },
11042 );
11043
11044 let mut protocol = WebUIProtocol::new(fragments);
11045 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11046
11047 let z = protocol
11048 .components
11049 .entry("z-widget".to_string())
11050 .or_default();
11051 z.css_href = "z-widget.css".to_string();
11052 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11053
11054 let a = protocol
11055 .components
11056 .entry("a-widget".to_string())
11057 .or_default();
11058 a.css_href = "a-widget.css".to_string();
11059 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11060 protocol.populate_style_closures(&["index.html"]);
11061
11062 let state = test_json!({});
11063 let mut writer = TestWriter::new();
11064
11065 handle(
11066 &protocol,
11067 &state,
11068 &RenderOptions::new("index.html", "/"),
11069 &mut writer,
11070 )
11071 .unwrap();
11072
11073 let html = writer.get_content();
11074 let head_end = html.find("</head>").expect("</head> missing");
11075 let head_section = &html[..head_end];
11076
11077 let z_pos = head_section
11078 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11079 .expect("z-widget stylesheet link missing from <head>");
11080 let a_pos = head_section
11081 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11082 .expect("a-widget stylesheet link missing from <head>");
11083
11084 assert!(
11085 z_pos < a_pos,
11086 "CSS <link> tags must follow document order (z-widget before \
11087 a-widget), not alphabetical order: {html}"
11088 );
11089 }
11090
11091 #[test]
11092 fn test_css_module_emitted_in_component_light_dom() {
11093 let mut fragments = HashMap::new();
11097 fragments.insert(
11098 "index.html".to_string(),
11099 FragmentList {
11100 fragments: vec![
11101 WebUIFragment::raw("<html><head>".to_string()),
11102 structural_fragment("head_end"),
11103 WebUIFragment::raw("</head><body><my-card>".to_string()),
11104 WebUIFragment::component("my-card"),
11105 WebUIFragment::raw("</my-card>".to_string()),
11106 WebUIFragment::raw("</body></html>".to_string()),
11107 ],
11108 contains_boundary: false,
11109 },
11110 );
11111 fragments.insert(
11112 "my-card".to_string(),
11113 FragmentList {
11114 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11115 contains_boundary: false,
11116 },
11117 );
11118
11119 let mut protocol = WebUIProtocol::new(fragments);
11120 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11121 protocol
11122 .components
11123 .entry("my-card".to_string())
11124 .or_default()
11125 .css = "p{color:red}".to_string();
11126 protocol.populate_style_closures(&["index.html"]);
11127 let state = test_json!({});
11128 let mut writer = TestWriter::new();
11129
11130 handle(
11131 &protocol,
11132 &state,
11133 &RenderOptions::new("index.html", "/"),
11134 &mut writer,
11135 )
11136 .unwrap();
11137
11138 let html = writer.get_content();
11139
11140 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11142 let css_pos = html
11143 .find(r#"<script type="importmap""#)
11144 .expect("CSS module importmap missing");
11145 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11146 assert!(
11147 css_pos > tag_open && css_pos < tag_close,
11148 "CSS module should be inside component light DOM: {html}"
11149 );
11150
11151 let head_end = html.find("</head>").expect("</head> missing");
11153 assert!(
11154 css_pos > head_end,
11155 "CSS module should not be in <head>: {html}"
11156 );
11157 }
11158
11159 #[test]
11160 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11161 let mut protocol = WebUIProtocol::default();
11162 protocol.fragments = HashMap::from([
11163 (
11164 "index.html".to_string(),
11165 FragmentList {
11166 fragments: vec![
11167 WebUIFragment::raw("<legacy-card>"),
11168 WebUIFragment::component("legacy-card"),
11169 WebUIFragment::raw("</legacy-card>"),
11170 ],
11171 contains_boundary: false,
11172 },
11173 ),
11174 (
11175 "legacy-card".to_string(),
11176 FragmentList {
11177 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11178 contains_boundary: false,
11179 },
11180 ),
11181 ]);
11182 protocol.components.insert(
11183 "legacy-card".to_string(),
11184 webui_protocol::ComponentData {
11185 css: ".legacy{color:red}".to_string(),
11186 ..Default::default()
11187 },
11188 );
11189 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11190 let mut writer = TestWriter::new();
11191
11192 let error = handle(
11193 &protocol,
11194 &test_json!({}),
11195 &RenderOptions::new("index.html", "/"),
11196 &mut writer,
11197 )
11198 .expect_err("styled protocols require closure metadata");
11199 assert!(error
11200 .to_string()
11201 .contains("component style closure metadata is required"));
11202 }
11203
11204 #[test]
11205 fn test_css_module_emitted_for_route_components() {
11206 let template = r#"<h1>Dashboard</h1>"#;
11208
11209 let mut fragments = HashMap::new();
11210 fragments.insert(
11211 "index.html".to_string(),
11212 FragmentList {
11213 fragments: vec![
11214 WebUIFragment::raw("<html><head>".to_string()),
11215 structural_fragment("head_end"),
11216 WebUIFragment::raw("</head><body>".to_string()),
11217 WebUIFragment::route("/", "dash-page"),
11218 structural_fragment("body_end"),
11219 WebUIFragment::raw("</body></html>".to_string()),
11220 ],
11221 contains_boundary: false,
11222 },
11223 );
11224 fragments.insert(
11225 "dash-page".to_string(),
11226 FragmentList {
11227 fragments: vec![WebUIFragment::raw(template.to_string())],
11228 contains_boundary: false,
11229 },
11230 );
11231
11232 let mut protocol = WebUIProtocol::new(fragments);
11233 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11234 let comp = protocol
11235 .components
11236 .entry("dash-page".to_string())
11237 .or_default();
11238 comp.css = "h1{font-size:2rem}".to_string();
11239 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11240 protocol.populate_style_closures(&["index.html"]);
11241 let state = test_json!({});
11242 let mut writer = TestWriter::new();
11243
11244 handle(
11245 &protocol,
11246 &state,
11247 &RenderOptions::new("index.html", "/"),
11248 &mut writer,
11249 )
11250 .unwrap();
11251
11252 let html = writer.get_content();
11253
11254 assert!(
11255 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11256 "Route component should have CSS module importmap with data: URI: {html}"
11257 );
11258 assert!(
11259 html.contains("<h1>Dashboard</h1>"),
11260 "Route component should render content: {html}"
11261 );
11262 let fallback = html
11263 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11264 .expect("route Module fallback");
11265 let host_open = html.find("<dash-page>").expect("route host");
11266 let content = html.find("<h1>Dashboard</h1>").expect("route content");
11267 assert!(
11268 fallback < host_open && host_open < content,
11269 "the Module fallback must precede the generated Light route host: {html}"
11270 );
11271 let importmap = html
11277 .find(r#"<script type="importmap""#)
11278 .expect("route CSS module importmap");
11279 assert!(
11280 host_open < importmap,
11281 "the CSS module importmap must live inside the route host, not as a route child: {html}"
11282 );
11283 }
11284
11285 #[test]
11286 fn test_head_css_link_skipped_for_components_without_css() {
11287 let mut fragments = HashMap::new();
11290 fragments.insert(
11291 "index.html".to_string(),
11292 FragmentList {
11293 fragments: vec![
11294 WebUIFragment::raw("<html><head>".to_string()),
11295 structural_fragment("head_end"),
11296 WebUIFragment::raw("</head><body>".to_string()),
11297 WebUIFragment::component("has-css"),
11298 WebUIFragment::component("no-css"),
11299 structural_fragment("body_end"),
11300 WebUIFragment::raw("</body></html>".to_string()),
11301 ],
11302 contains_boundary: false,
11303 },
11304 );
11305 fragments.insert(
11306 "has-css".to_string(),
11307 FragmentList {
11308 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11309 contains_boundary: false,
11310 },
11311 );
11312 fragments.insert(
11313 "no-css".to_string(),
11314 FragmentList {
11315 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11316 contains_boundary: false,
11317 },
11318 );
11319
11320 let mut protocol = WebUIProtocol::new(fragments);
11321 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11322
11323 protocol
11325 .components
11326 .entry("has-css".to_string())
11327 .or_default()
11328 .css_href = "has-css.css".to_string();
11329 protocol
11330 .components
11331 .entry("no-css".to_string())
11332 .or_default()
11333 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11334 protocol.populate_style_closures(&["index.html"]);
11335
11336 let state = test_json!({});
11337 let mut writer = TestWriter::new();
11338
11339 let handler = WebUIHandler::with_plugin(|| {
11340 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11341 });
11342 handler
11343 .handle(
11344 &protocol,
11345 &state,
11346 &RenderOptions::new("index.html", "/"),
11347 &mut writer,
11348 )
11349 .unwrap();
11350
11351 let html = writer.get_content();
11352 assert!(
11353 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11354 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11355 );
11356 assert!(
11357 !html.contains("no-css.css"),
11358 "Component without CSS must NOT get a <link> in <head>: {html}"
11359 );
11360 }
11361
11362 #[test]
11363 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11364 let mut fragments = HashMap::new();
11372 fragments.insert(
11373 "index.html".to_string(),
11374 FragmentList {
11375 fragments: vec![
11376 WebUIFragment::raw("<html><head>".to_string()),
11377 structural_fragment("head_end"),
11378 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11379 WebUIFragment::component("app-shell"),
11380 WebUIFragment::raw("</app-shell>".to_string()),
11381 structural_fragment("body_end"),
11382 WebUIFragment::raw("</body></html>".to_string()),
11383 ],
11384 contains_boundary: false,
11385 },
11386 );
11387 fragments.insert(
11389 "app-shell".to_string(),
11390 FragmentList {
11391 fragments: vec![
11392 WebUIFragment::raw("<div>Shell</div>".to_string()),
11393 WebUIFragment::component("cart-panel"),
11394 ],
11395 contains_boundary: false,
11396 },
11397 );
11398 fragments.insert(
11400 "cart-panel".to_string(),
11401 FragmentList {
11402 fragments: vec![
11403 WebUIFragment::raw("<aside>".to_string()),
11404 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11405 WebUIFragment::raw("</aside>".to_string()),
11406 ],
11407 contains_boundary: false,
11408 },
11409 );
11410 fragments.insert(
11412 "cart-items".to_string(),
11413 FragmentList {
11414 fragments: vec![WebUIFragment::component("product-card")],
11415 contains_boundary: false,
11416 },
11417 );
11418 fragments.insert(
11419 "product-card".to_string(),
11420 FragmentList {
11421 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11422 contains_boundary: false,
11423 },
11424 );
11425
11426 let mut protocol = WebUIProtocol::new(fragments);
11427 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11428 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11429 for name in ["app-shell", "cart-panel", "product-card"] {
11430 let comp = protocol.components.entry(name.to_string()).or_default();
11431 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11432 comp.css = format!(".{name}{{display:block}}");
11433 if name == "cart-panel" {
11434 comp.hydration_mode = StateProjectionMode::Keys as i32;
11435 comp.hydration_keys = vec!["hasItems".to_string()];
11436 }
11437 if name == "product-card" {
11438 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11439 }
11440 }
11441 protocol.populate_style_closures(&["index.html"]);
11442
11443 let state = test_json!({ "hasItems": false });
11445 let mut writer = TestWriter::new();
11446
11447 let handler = WebUIHandler::with_plugin(|| {
11448 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11449 });
11450 handler
11451 .handle(
11452 &protocol,
11453 &state,
11454 &RenderOptions::new("index.html", "/"),
11455 &mut writer,
11456 )
11457 .unwrap();
11458
11459 let html = writer.get_content();
11460
11461 assert!(
11462 html.contains(r#"<script type="application/json" id="webui-data">"#),
11463 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11464 );
11465 assert!(
11466 html.contains(r#""state":{"hasItems":false}"#),
11467 "SSR state should live in the JSON data block: {html}"
11468 );
11469 assert!(
11470 html.contains(r#""inventory":"#),
11471 "SSR inventory should live in the JSON data block: {html}"
11472 );
11473 assert!(
11474 !html.contains("window.__webui={\""),
11475 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
11476 );
11477 assert!(
11478 !html.contains(r#"document.getElementById("webui-data")"#),
11479 "SSR must not parse webui-data; client packages own that lazy load: {html}"
11480 );
11481 assert!(
11482 !html.contains("window.__webui=w;"),
11483 "executable bootstrap must not replace existing window.__webui registrations: {html}"
11484 );
11485 assert!(
11486 !html.contains("w.templateFns={\""),
11487 "template function emission must not replace existing templateFns registrations: {html}"
11488 );
11489 assert!(
11490 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
11491 "template functions should merge into the flat templateFns registry: {html}"
11492 );
11493
11494 assert!(
11497 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
11498 "product-card template should be emitted even when unrendered: {html}"
11499 );
11500
11501 assert!(
11504 html.contains(r#""product-card":"data:text/css,"#),
11505 "reachable product-card CSS module importmap should be emitted: {html}"
11506 );
11507
11508 assert!(
11510 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
11511 "rendered app-shell template should be emitted: {html}"
11512 );
11513 assert!(
11514 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
11515 "rendered cart-panel template should be emitted: {html}"
11516 );
11517 }
11518
11519 #[test]
11520 fn component_asset_manifest_falls_back_to_body_start() {
11521 let fragments = HashMap::from([(
11522 "index.html".to_string(),
11523 FragmentList {
11524 fragments: vec![
11525 WebUIFragment::raw("<body>".to_string()),
11526 structural_fragment("body_start"),
11527 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
11528 ],
11529 contains_boundary: false,
11530 },
11531 )]);
11532 let mut protocol = WebUIProtocol::new(fragments);
11533 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11534 root: "lazy-panel".to_string(),
11535 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11536 }];
11537 let mut writer = TestWriter::new();
11538
11539 handle(
11540 &protocol,
11541 &Value::Null,
11542 &RenderOptions::new("index.html", "/"),
11543 &mut writer,
11544 )
11545 .unwrap();
11546
11547 assert_eq!(
11548 writer.get_content(),
11549 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
11550 );
11551 }
11552
11553 #[test]
11554 fn component_asset_styles_emit_once_when_body_precedes_head() {
11555 let fragments = HashMap::from([(
11556 "index.html".to_string(),
11557 FragmentList {
11558 fragments: vec![
11559 WebUIFragment::raw("<body>".to_string()),
11560 structural_fragment("body_start"),
11561 WebUIFragment::raw("</body><head>".to_string()),
11562 structural_fragment("head_end"),
11563 WebUIFragment::raw("</head>".to_string()),
11564 ],
11565 contains_boundary: false,
11566 },
11567 )]);
11568 let mut protocol = WebUIProtocol::new(fragments);
11569 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
11570 root: "lazy-panel".to_string(),
11571 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
11572 }];
11573 let mut writer = TestWriter::new();
11574
11575 handle(
11576 &protocol,
11577 &Value::Null,
11578 &RenderOptions::new("index.html", "/"),
11579 &mut writer,
11580 )
11581 .unwrap();
11582
11583 assert_eq!(
11584 writer
11585 .get_content()
11586 .matches(r#"id="webui-component-assets""#)
11587 .count(),
11588 1
11589 );
11590 }
11591
11592 #[test]
11593 fn light_component_asset_styles_apply_at_head_end() {
11594 let fragments = HashMap::from([(
11595 "index.html".to_string(),
11596 FragmentList {
11597 fragments: vec![
11598 WebUIFragment::raw("<html><head>".to_string()),
11599 structural_fragment("head_end"),
11600 WebUIFragment::raw("</head><body></body></html>".to_string()),
11601 ],
11602 contains_boundary: false,
11603 },
11604 )]);
11605 let mut protocol = WebUIProtocol::new(fragments);
11606 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11607 for root in ["lazy-panel", "secondary-panel"] {
11608 protocol.components.insert(
11609 root.to_string(),
11610 webui_protocol::ComponentData {
11611 uses_shadow_dom: false,
11612 ..Default::default()
11613 },
11614 );
11615 }
11616 protocol.component_asset_style_preloads = vec![
11617 ComponentAssetStylePreload {
11618 root: "lazy-panel".to_string(),
11619 style_hrefs: vec![
11620 "/assets/lazy-panel.css".to_string(),
11621 "/assets/shared.css".to_string(),
11622 ],
11623 },
11624 ComponentAssetStylePreload {
11625 root: "secondary-panel".to_string(),
11626 style_hrefs: vec![
11627 "/assets/secondary-panel.css".to_string(),
11628 "/assets/shared.css".to_string(),
11629 ],
11630 },
11631 ];
11632 let mut writer = TestWriter::new();
11633
11634 handle(
11635 &protocol,
11636 &Value::Null,
11637 &RenderOptions::new("index.html", "/"),
11638 &mut writer,
11639 )
11640 .unwrap();
11641
11642 let html = writer.get_content();
11643 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
11644 assert_eq!(html.matches("/assets/shared.css").count(), 1);
11645 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
11646 let links_end = html
11647 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
11648 .expect("asset stylesheet must be emitted");
11649 let head_end = html.find("</head>").expect("head must close");
11650 assert!(links_end < head_end);
11651 }
11652
11653 #[test]
11663 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
11664 let template = r#"<h1>Dashboard</h1>"#;
11666
11667 let mut fragments = HashMap::new();
11668 fragments.insert(
11669 "index.html".to_string(),
11670 FragmentList {
11671 fragments: vec![
11672 WebUIFragment::raw("<html><head>".to_string()),
11673 structural_fragment("head_end"),
11674 WebUIFragment::raw("</head><body>".to_string()),
11675 WebUIFragment::route("/", "dash-page"),
11676 structural_fragment("body_end"),
11677 WebUIFragment::raw("</body></html>".to_string()),
11678 ],
11679 contains_boundary: false,
11680 },
11681 );
11682 fragments.insert(
11683 "dash-page".to_string(),
11684 FragmentList {
11685 fragments: vec![WebUIFragment::raw(template.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 let comp = protocol
11693 .components
11694 .entry("dash-page".to_string())
11695 .or_default();
11696 comp.css = "h1{font-size:2rem}".to_string();
11697 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11698 protocol.populate_style_closures(&["index.html"]);
11699 let state = test_json!({});
11700 let mut writer = TestWriter::new();
11701
11702 handle(
11703 &protocol,
11704 &state,
11705 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11706 &mut writer,
11707 )
11708 .unwrap();
11709
11710 let html = writer.get_content();
11711
11712 assert!(
11713 html.contains(
11714 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
11715 ),
11716 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
11717 );
11718 }
11719
11720 #[test]
11721 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
11722 let mut fragments = HashMap::new();
11727 fragments.insert(
11728 "index.html".to_string(),
11729 FragmentList {
11730 fragments: vec![
11731 WebUIFragment::raw("<html><head>".to_string()),
11732 structural_fragment("head_end"),
11733 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11734 WebUIFragment::component("app-shell"),
11735 WebUIFragment::raw("</app-shell>".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::if_cond(
11746 ConditionExpr::identifier("hasItems"),
11747 "cart-items",
11748 )],
11749 contains_boundary: false,
11750 },
11751 );
11752 fragments.insert(
11753 "cart-items".to_string(),
11754 FragmentList {
11755 fragments: vec![WebUIFragment::component("product-card")],
11756 contains_boundary: false,
11757 },
11758 );
11759 fragments.insert(
11760 "product-card".to_string(),
11761 FragmentList {
11762 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11763 contains_boundary: false,
11764 },
11765 );
11766
11767 let mut protocol = WebUIProtocol::new(fragments);
11768 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11769 for name in ["app-shell", "product-card"] {
11770 let comp = protocol.components.entry(name.to_string()).or_default();
11771 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11772 comp.css = format!(".{name}{{display:block}}");
11773 }
11774 protocol.populate_style_closures(&["index.html"]);
11775
11776 let state = test_json!({ "hasItems": false });
11779 let mut writer = TestWriter::new();
11780
11781 let handler = WebUIHandler::with_plugin(|| {
11782 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11783 });
11784 handler
11785 .handle(
11786 &protocol,
11787 &state,
11788 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
11789 &mut writer,
11790 )
11791 .unwrap();
11792
11793 let html = writer.get_content();
11794
11795 assert!(
11796 html.contains(
11797 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
11798 ),
11799 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
11800 );
11801 }
11802
11803 #[test]
11804 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
11805 let mut fragments = HashMap::new();
11806 fragments.insert(
11807 "index.html".to_string(),
11808 FragmentList {
11809 fragments: vec![
11810 WebUIFragment::raw("<html><body><style>".to_string()),
11811 WebUIFragment::signal("tokens.light", true),
11812 WebUIFragment::raw("</style>".to_string()),
11813 structural_fragment("body_end"),
11814 WebUIFragment::raw("</body></html>".to_string()),
11815 ],
11816 contains_boundary: false,
11817 },
11818 );
11819 fragments.insert(
11820 "app-shell".to_string(),
11821 FragmentList {
11822 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
11823 contains_boundary: false,
11824 },
11825 );
11826 let index_fragments = fragments
11827 .get_mut("index.html")
11828 .expect("index fixture should exist");
11829 index_fragments
11830 .fragments
11831 .insert(1, WebUIFragment::component("app-shell"));
11832 let mut protocol = WebUIProtocol::new(fragments);
11833 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11834 protocol.components.insert(
11839 "app-shell".to_string(),
11840 webui_protocol::ComponentData {
11841 hydration_mode: StateProjectionMode::Keys as i32,
11842 hydration_keys: vec!["name".to_string()],
11843 ..Default::default()
11844 },
11845 );
11846 let state = test_json!({
11847 "name": "Alice",
11848 "tokens": {
11849 "light": "--color-brand: red;"
11850 }
11851 });
11852 let mut writer = TestWriter::new();
11853 let handler = WebUIHandler::with_plugin(|| {
11854 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11855 });
11856 handler.handle(
11857 &protocol,
11858 &state,
11859 &RenderOptions::new("index.html", "/"),
11860 &mut writer,
11861 )?;
11862 let output = writer.get_content();
11863
11864 assert!(output.contains("--color-brand: red;"));
11866 assert!(output.contains(r#""name":"Alice""#));
11868 assert!(!output.contains(r#""tokens""#));
11869 Ok(())
11870 }
11871
11872 #[test]
11873 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
11874 let mut fragments = HashMap::new();
11875 fragments.insert(
11876 "index.html".to_string(),
11877 FragmentList {
11878 fragments: vec![
11879 WebUIFragment::raw("<html><body>"),
11880 structural_fragment("body_end"),
11881 WebUIFragment::raw("</body></html>"),
11882 ],
11883 contains_boundary: false,
11884 },
11885 );
11886 let protocol = WebUIProtocol::new(fragments);
11887 let state = test_json!({
11888 "client": "visible",
11889 "serverOnly": "also preserved",
11890 });
11891 let handler = WebUIHandler::with_plugin(|| {
11892 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11893 });
11894 let mut writer = TestWriter::new();
11895 handler.handle(
11896 &protocol,
11897 &state,
11898 &RenderOptions::new("index.html", "/"),
11899 &mut writer,
11900 )?;
11901 let output = writer.get_content();
11902 assert!(output.contains(r#""client":"visible""#));
11903 assert!(output.contains(r#""serverOnly":"also preserved""#));
11904 Ok(())
11905 }
11906
11907 #[test]
11908 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
11909 let mut fragments = HashMap::new();
11910 fragments.insert(
11911 "index.html".to_string(),
11912 FragmentList {
11913 fragments: vec![
11914 WebUIFragment::raw("<html><body>"),
11915 WebUIFragment::component("app-shell"),
11916 structural_fragment("body_end"),
11917 WebUIFragment::raw("</body></html>"),
11918 ],
11919 contains_boundary: false,
11920 },
11921 );
11922 fragments.insert(
11923 "app-shell".to_string(),
11924 FragmentList {
11925 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11926 contains_boundary: false,
11927 },
11928 );
11929 let mut protocol = WebUIProtocol::new(fragments);
11930 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11931 protocol.components.insert(
11932 "app-shell".to_string(),
11933 webui_protocol::ComponentData {
11934 hydration_mode: StateProjectionMode::All as i32,
11935 ..Default::default()
11936 },
11937 );
11938 let state = test_json!({
11939 "known": "value",
11940 "possiblyInherited": "must not be dropped",
11941 });
11942 let handler = WebUIHandler::with_plugin(|| {
11943 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11944 });
11945 let mut writer = TestWriter::new();
11946 handler.handle(
11947 &protocol,
11948 &state,
11949 &RenderOptions::new("index.html", "/"),
11950 &mut writer,
11951 )?;
11952 let output = writer.get_content();
11953 assert!(output.contains(r#""known":"value""#));
11954 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
11955 Ok(())
11956 }
11957
11958 #[test]
11959 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
11960 let mut fragments = HashMap::new();
11961 fragments.insert(
11962 "index.html".to_string(),
11963 FragmentList {
11964 fragments: vec![
11965 WebUIFragment::raw("<html><body>"),
11966 WebUIFragment::component("app-shell"),
11967 structural_fragment("body_end"),
11968 WebUIFragment::raw("</body></html>"),
11969 ],
11970 contains_boundary: false,
11971 },
11972 );
11973 fragments.insert(
11974 "app-shell".to_string(),
11975 FragmentList {
11976 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
11977 contains_boundary: false,
11978 },
11979 );
11980 let mut protocol = WebUIProtocol::new(fragments);
11981 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11982 let state = test_json!({
11983 "known": "value",
11984 "serverOnly": "must not be dropped",
11985 });
11986 let handler = WebUIHandler::with_plugin(|| {
11987 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11988 });
11989 let mut writer = TestWriter::new();
11990 handler.handle(
11991 &protocol,
11992 &state,
11993 &RenderOptions::new("index.html", "/"),
11994 &mut writer,
11995 )?;
11996 let output = writer.get_content();
11997 assert!(output.contains(r#""known":"value""#));
11998 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
11999 Ok(())
12000 }
12001
12002 #[test]
12003 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12004 let mut fragments = HashMap::new();
12005 fragments.insert(
12006 "index.html".to_string(),
12007 FragmentList {
12008 fragments: vec![
12009 WebUIFragment::raw("<html><body>"),
12010 WebUIFragment::component("app-shell"),
12011 structural_fragment("body_end"),
12012 WebUIFragment::raw("</body></html>"),
12013 ],
12014 contains_boundary: false,
12015 },
12016 );
12017 fragments.insert(
12018 "app-shell".to_string(),
12019 FragmentList {
12020 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12021 contains_boundary: false,
12022 },
12023 );
12024 let mut protocol = WebUIProtocol::new(fragments);
12025 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12026 protocol.components.insert(
12027 "app-shell".to_string(),
12028 webui_protocol::ComponentData {
12029 hydration_mode: i32::MAX,
12030 ..Default::default()
12031 },
12032 );
12033 let state = test_json!({
12034 "known": "value",
12035 "serverOnly": "must not be dropped",
12036 });
12037 let handler = WebUIHandler::with_plugin(|| {
12038 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12039 });
12040 let mut writer = TestWriter::new();
12041 handler.handle(
12042 &protocol,
12043 &state,
12044 &RenderOptions::new("index.html", "/"),
12045 &mut writer,
12046 )?;
12047 let output = writer.get_content();
12048 assert!(output.contains(r#""known":"value""#));
12049 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12050 Ok(())
12051 }
12052
12053 #[test]
12054 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12055 let mut protocol = WebUIProtocol::new(HashMap::new());
12056 protocol.components.insert(
12057 "app-shell".to_string(),
12058 webui_protocol::ComponentData {
12059 navigation_keys: vec!["selected".to_string()],
12060 ..Default::default()
12061 },
12062 );
12063
12064 match collect_navigation_state(&protocol, ["app-shell"]) {
12065 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12066 StateSelection::Full | StateSelection::KeyIds(_) => {
12067 panic!("legacy navigation keys should remain owned and projected")
12068 }
12069 }
12070 }
12071
12072 #[test]
12073 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12074 let mut protocol = WebUIProtocol::new(HashMap::new());
12075 protocol.components.insert(
12076 "app-shell".to_string(),
12077 webui_protocol::ComponentData::default(),
12078 );
12079
12080 assert!(matches!(
12081 collect_navigation_state(&protocol, ["app-shell"]),
12082 StateSelection::Full
12083 ));
12084 }
12085
12086 #[test]
12087 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12088 let mut fragments = HashMap::new();
12089 fragments.insert(
12090 "index.html".to_string(),
12091 FragmentList {
12092 fragments: vec![
12093 WebUIFragment::raw("<html><body>".to_string()),
12094 structural_fragment("body_end"),
12095 WebUIFragment::raw("</body></html>".to_string()),
12096 ],
12097 contains_boundary: false,
12098 },
12099 );
12100 let mut protocol = WebUIProtocol::new(fragments);
12101 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12102 let state = test_json!({
12103 "title": "Legacy state",
12104 "serverOnly": "preserved",
12105 });
12106 let handler = WebUIHandler::with_plugin(|| {
12107 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12108 });
12109
12110 let mut writer = TestWriter::new();
12111 handler.handle(
12112 &protocol,
12113 &state,
12114 &RenderOptions::new("index.html", "/"),
12115 &mut writer,
12116 )?;
12117 assert!(writer.get_content().contains(r#""state":{}"#));
12118 assert!(!writer.get_content().contains("Legacy state"));
12119 assert!(!writer.get_content().contains("preserved"));
12120 Ok(())
12121 }
12122
12123 #[test]
12124 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12125 let mut fragments = HashMap::new();
12126 fragments.insert(
12127 "index.html".to_string(),
12128 FragmentList {
12129 fragments: vec![
12130 WebUIFragment::raw("<html><body>"),
12131 WebUIFragment::component("items-page"),
12132 structural_fragment("body_end"),
12133 WebUIFragment::raw("</body></html>"),
12134 ],
12135 contains_boundary: false,
12136 },
12137 );
12138 fragments.insert(
12139 "items-page".to_string(),
12140 FragmentList {
12141 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12142 contains_boundary: false,
12143 },
12144 );
12145 let mut protocol = WebUIProtocol::new(fragments);
12146 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12147 protocol.components.insert(
12148 "items-page".to_string(),
12149 webui_protocol::ComponentData {
12150 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12151 navigation_mode: Some(StateProjectionMode::Keys as i32),
12152 navigation_keys: vec!["items".into()],
12153 ..Default::default()
12154 },
12155 );
12156 let state = test_json!({
12157 "items": ["STATE_SENTINEL"],
12158 "serverOnly": "SECRET_SENTINEL",
12159 });
12160 let handler = WebUIHandler::with_plugin(|| {
12161 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12162 });
12163 let mut writer = TestWriter::new();
12164
12165 handler.handle(
12166 &protocol,
12167 &state,
12168 &RenderOptions::new("index.html", "/"),
12169 &mut writer,
12170 )?;
12171
12172 let output = writer.get_content();
12173 assert!(output.contains(r#""state":{}"#));
12174 assert!(!output.contains("STATE_SENTINEL"));
12175 assert!(!output.contains("SECRET_SENTINEL"));
12176 Ok(())
12177 }
12178
12179 #[test]
12180 fn write_selected_state_projects_and_escapes() {
12181 let state = test_json!({
12184 "drop": "secret",
12185 "keep": "</script><b>"
12186 });
12187 let keys = ["keep"];
12188 let selection = StateSelection::Keys(keys.to_vec());
12189 let mut sink = TestWriter::new();
12190 let mut scratch = Vec::new();
12191 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12192 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12193 }
12194
12195 #[test]
12196 fn write_selected_state_non_object_projection_emits_empty_object() {
12197 let state = test_json!("scalar state has nothing hydratable");
12198 let keys: [&str; 0] = [];
12199 let selection = StateSelection::Keys(keys.to_vec());
12200 let mut sink = TestWriter::new();
12201 let mut scratch = Vec::new();
12202 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12203 assert_eq!(sink.get_content(), "{}");
12204 }
12205
12206 #[test]
12207 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12208 let state = test_json!({
12209 "keptA": 1,
12210 "keptB": 2,
12211 "serverOnlyA": 3,
12212 "serverOnlyB": 4,
12213 });
12214 let keys = ["keptA", "keptA", "keptB", "missing"];
12215 let selection = StateSelection::Keys(keys.to_vec());
12216 let mut sink = TestWriter::new();
12217 let mut scratch = Vec::new();
12218 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12219 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12220 }
12221
12222 #[test]
12223 fn write_selected_state_map_first_matches_schema_first_output() {
12224 let state = test_json!({
12225 "keptA": 1,
12226 "keptB": 2,
12227 });
12228 let keys = ["keptA", "keptB", "missingA", "missingB"];
12229 let selection = StateSelection::Keys(keys.to_vec());
12230 let mut sink = TestWriter::new();
12231 let mut scratch = Vec::new();
12232 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12233 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12234 }
12235
12236 #[test]
12237 fn write_selected_state_full_preserves_and_escapes_state() {
12238 let state = test_json!({
12239 "serverOnly": "</script><b>",
12240 "value": 42,
12241 });
12242 let mut sink = TestWriter::new();
12243 let mut scratch = Vec::new();
12244 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12245 assert_eq!(
12246 sink.get_content(),
12247 r#"{"serverOnly":"<\/script><b>","value":42}"#
12248 );
12249 }
12250
12251 #[test]
12252 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12253 let preloads = vec![ComponentAssetStylePreload {
12254 root: "lazy-panel".to_string(),
12255 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12256 }];
12257
12258 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12259
12260 assert_eq!(
12261 manifest,
12262 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12263 );
12264 }
12265
12266 #[test]
12267 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12268 let mut sink = TestWriter::new();
12274 let mut scratch: Vec<u8> = Vec::new();
12275 assert_eq!(scratch.capacity(), 0);
12277
12278 let mut large = serde_json::Map::new();
12280 for index in 0..64 {
12281 large.insert(
12282 format!("component_{index:04}"),
12283 Value::String("x".repeat(48)),
12284 );
12285 }
12286 let large = Value::Object(large);
12287 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12288 let high_water = scratch.capacity();
12289 assert!(high_water > 0);
12290
12291 for seq in 0..8 {
12294 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12295 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12296 assert_eq!(
12297 scratch.capacity(),
12298 high_water,
12299 "scratch reallocated on checkpoint {seq}"
12300 );
12301 }
12302 }
12303
12304 #[test]
12305 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12306 let mut fragments = HashMap::new();
12307 fragments.insert(
12308 "index.html".to_string(),
12309 FragmentList {
12310 fragments: vec![
12311 WebUIFragment::raw("<html><body>"),
12312 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12313 path: "/".to_string(),
12314 fragment_id: "home-page".to_string(),
12315 exact: true,
12316 ..Default::default()
12317 }),
12318 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12319 path: "/admin".to_string(),
12320 fragment_id: "admin-page".to_string(),
12321 exact: true,
12322 ..Default::default()
12323 }),
12324 structural_fragment("body_end"),
12325 WebUIFragment::raw("</body></html>"),
12326 ],
12327
12328 contains_boundary: false,
12329 },
12330 );
12331 fragments.insert(
12332 "home-page".to_string(),
12333 FragmentList {
12334 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12335 contains_boundary: false,
12336 },
12337 );
12338 fragments.insert(
12339 "admin-page".to_string(),
12340 FragmentList {
12341 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12342 contains_boundary: false,
12343 },
12344 );
12345
12346 let mut protocol = WebUIProtocol::new(fragments);
12347 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12348 protocol.components.insert(
12349 "home-page".to_string(),
12350 webui_protocol::ComponentData {
12351 template_json: "{}".to_string(),
12352 hydration_mode: StateProjectionMode::Keys as i32,
12353 hydration_keys: vec!["homeTitle".to_string()],
12354 ..Default::default()
12355 },
12356 );
12357 protocol.components.insert(
12358 "admin-page".to_string(),
12359 webui_protocol::ComponentData {
12360 template_json: "{}".to_string(),
12361 hydration_mode: StateProjectionMode::Keys as i32,
12362 hydration_keys: vec!["adminToken".to_string()],
12363 ..Default::default()
12364 },
12365 );
12366 let state = test_json!({
12367 "homeTitle": "Welcome",
12368 "adminToken": "TOP_SECRET_SENTINEL",
12369 });
12370 let handler = WebUIHandler::with_plugin(|| {
12371 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12372 });
12373 let mut writer = TestWriter::new();
12374 handler.handle(
12375 &protocol,
12376 &state,
12377 &RenderOptions::new("index.html", "/"),
12378 &mut writer,
12379 )?;
12380 let output = writer.get_content();
12381 assert!(output.contains(r#""homeTitle":"Welcome""#));
12382 assert!(!output.contains("TOP_SECRET_SENTINEL"));
12383 assert!(!output.contains(r#""adminToken""#));
12384 Ok(())
12385 }
12386
12387 #[test]
12388 fn test_component_attr_name_aria() {
12389 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12391 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12392 assert_eq!(
12393 component_attr_name("aria-activedescendant"),
12394 "ariaActiveDescendant"
12395 );
12396 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12397 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12398 }
12399
12400 #[test]
12401 fn test_component_attr_name_html_global() {
12402 assert_eq!(component_attr_name("readonly"), "readOnly");
12403 assert_eq!(component_attr_name("tabindex"), "tabIndex");
12404 assert_eq!(component_attr_name("accesskey"), "accessKey");
12405 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12406 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12407 assert_eq!(component_attr_name("inputmode"), "inputMode");
12408 assert_eq!(component_attr_name("maxlength"), "maxLength");
12409 assert_eq!(component_attr_name("minlength"), "minLength");
12410 assert_eq!(component_attr_name("novalidate"), "noValidate");
12411 assert_eq!(component_attr_name("formaction"), "formAction");
12412 assert_eq!(component_attr_name("ismap"), "isMap");
12413 assert_eq!(component_attr_name("usemap"), "useMap");
12414 }
12415
12416 #[test]
12417 fn test_component_attr_name_strips_colon() {
12418 assert_eq!(component_attr_name(":readonly"), "readOnly");
12419 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12420 assert_eq!(component_attr_name(":data-title"), "dataTitle");
12421 }
12422
12423 #[test]
12424 fn test_component_attr_name_regular() {
12425 assert_eq!(component_attr_name("data-title"), "dataTitle");
12426 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12427 assert_eq!(component_attr_name("simple"), "simple");
12428 }
12429
12430 fn make_query_route_protocol() -> WebUIProtocol {
12433 use webui_protocol::WebUiFragmentRoute;
12434
12435 let mut fragments = HashMap::new();
12436
12437 fragments.insert(
12438 "index.html".to_string(),
12439 FragmentList {
12440 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12441 path: "/".into(),
12442 fragment_id: "app-shell".into(),
12443 exact: false,
12444 children: vec![
12445 WebUiFragmentRoute {
12446 path: "compose".into(),
12447 fragment_id: "compose-page".into(),
12448 exact: true,
12449 allowed_query: "action,to,subject".into(),
12450 keep_alive: false,
12451 ..Default::default()
12452 },
12453 WebUiFragmentRoute {
12454 path: "settings".into(),
12455 fragment_id: "settings-page".into(),
12456 exact: true,
12457 keep_alive: false,
12458 ..Default::default()
12459 },
12460 ],
12461 keep_alive: false,
12462 ..Default::default()
12463 })],
12464
12465 contains_boundary: false,
12466 },
12467 );
12468
12469 fragments.insert(
12470 "app-shell".to_string(),
12471 FragmentList {
12472 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
12473 contains_boundary: false,
12474 },
12475 );
12476 fragments.insert(
12477 "compose-page".to_string(),
12478 FragmentList {
12479 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
12480 contains_boundary: false,
12481 },
12482 );
12483 fragments.insert(
12484 "settings-page".to_string(),
12485 FragmentList {
12486 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
12487 contains_boundary: false,
12488 },
12489 );
12490
12491 WebUIProtocol::new(fragments)
12492 }
12493
12494 #[test]
12495 fn test_matched_route_omits_query_attr_from_dom() {
12496 let protocol = make_query_route_protocol();
12497 let state = test_json!({});
12498 let handler = WebUIHandler::new();
12499 let mut writer = TestWriter::new();
12500
12501 handler
12502 .handle(
12503 &protocol,
12504 &state,
12505 &RenderOptions::new("index.html", "/compose"),
12506 &mut writer,
12507 )
12508 .expect("render failed");
12509
12510 let html = writer.get_content();
12511 assert!(
12513 !html.contains(r#"query="action,to,subject""#),
12514 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
12515 );
12516 }
12517
12518 #[test]
12519 fn test_nonmatched_route_omits_query_attr_from_dom() {
12520 let protocol = make_query_route_protocol();
12521 let state = test_json!({});
12522 let handler = WebUIHandler::new();
12523 let mut writer = TestWriter::new();
12524
12525 handler
12526 .handle(
12527 &protocol,
12528 &state,
12529 &RenderOptions::new("index.html", "/settings"),
12530 &mut writer,
12531 )
12532 .expect("render failed");
12533
12534 let html = writer.get_content();
12535 assert!(
12537 !html.contains(r#"query="#),
12538 "hidden route should not have query attr: {html}"
12539 );
12540 }
12541
12542 #[test]
12543 fn test_route_without_query_has_no_query_attr() {
12544 let protocol = make_query_route_protocol();
12545 let state = test_json!({});
12546 let handler = WebUIHandler::new();
12547 let mut writer = TestWriter::new();
12548
12549 handler
12550 .handle(
12551 &protocol,
12552 &state,
12553 &RenderOptions::new("index.html", "/settings"),
12554 &mut writer,
12555 )
12556 .expect("render failed");
12557
12558 let html = writer.get_content();
12559 let settings_idx = html
12561 .find(r#"component="settings-page""#)
12562 .expect("settings route should exist");
12563 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
12564 assert!(
12565 !settings_tag.contains("query="),
12566 "route without allowed_query should not emit query attr: {settings_tag}"
12567 );
12568 }
12569
12570 fn build_head_body_protocol() -> WebUIProtocol {
12575 let mut fragments = HashMap::new();
12576 fragments.insert(
12577 "index.html".to_string(),
12578 FragmentList {
12579 fragments: vec![
12580 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12581 structural_fragment("head_end"),
12582 WebUIFragment::raw("</head><body>hello".to_string()),
12583 structural_fragment("body_end"),
12584 WebUIFragment::raw("</body></html>".to_string()),
12585 ],
12586 contains_boundary: false,
12587 },
12588 );
12589 WebUIProtocol::new(fragments)
12590 }
12591
12592 #[test]
12593 fn head_inject_emits_at_head_end_boundary() {
12594 let protocol = build_head_body_protocol();
12595 let state = test_json!({});
12596 let mut writer = TestWriter::new();
12597 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
12598 handle(&protocol, &state, &opts, &mut writer).unwrap();
12599 let html = writer.get_content();
12600 let inject_idx = html
12602 .find("<link rel=preload>")
12603 .expect("inject HTML missing");
12604 let head_close_idx = html.find("</head>").expect("</head> missing");
12605 assert!(
12606 inject_idx < head_close_idx,
12607 "head_inject must appear before </head>: {html}"
12608 );
12609 assert_eq!(html.matches("<link rel=preload>").count(), 1);
12611 }
12612
12613 #[test]
12614 fn component_render_policy_css_emits_once_in_head_with_nonce() {
12615 let mut protocol = build_head_body_protocol();
12616 protocol.component_render_css = concat!(
12617 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12618 "contain-intrinsic-block-size:auto 18rem;}"
12619 )
12620 .to_string();
12621 let state = test_json!({});
12622 let mut writer = TestWriter::new();
12623 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
12624 handle(&protocol, &state, &opts, &mut writer).unwrap();
12625 let html = writer.get_content();
12626
12627 let style = concat!(
12628 r#"<style data-webui-render-policy nonce="test-nonce">"#,
12629 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
12630 "contain-intrinsic-block-size:auto 18rem;}</style>"
12631 );
12632 let style_index = html.find(style).expect("render policy style missing");
12633 let head_close = html.find("</head>").expect("</head> missing");
12634 assert!(style_index < head_close);
12635 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
12636 }
12637
12638 #[test]
12639 fn body_inject_emits_at_body_end_boundary() {
12640 let protocol = build_head_body_protocol();
12641 let state = test_json!({});
12642 let mut writer = TestWriter::new();
12643 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
12644 handle(&protocol, &state, &opts, &mut writer).unwrap();
12645 let html = writer.get_content();
12646 let inject_idx = html
12647 .find("<script>lr</script>")
12648 .expect("inject HTML missing");
12649 let body_close_idx = html.find("</body>").expect("</body> missing");
12650 assert!(
12651 inject_idx < body_close_idx,
12652 "body_inject must appear before </body>: {html}"
12653 );
12654 assert_eq!(html.matches("<script>lr</script>").count(), 1);
12655 }
12656
12657 fn build_all_boundaries_protocol() -> WebUIProtocol {
12662 let mut fragments = HashMap::new();
12663 fragments.insert(
12664 "index.html".to_string(),
12665 FragmentList {
12666 fragments: vec![
12667 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
12668 structural_fragment("head_end"),
12669 WebUIFragment::raw("</head><body>".to_string()),
12670 structural_fragment("body_start"),
12671 WebUIFragment::raw("hello".to_string()),
12672 structural_fragment("body_end"),
12673 WebUIFragment::raw("</body></html>".to_string()),
12674 ],
12675 contains_boundary: false,
12676 },
12677 );
12678 WebUIProtocol::new(fragments)
12679 }
12680
12681 fn state_inject_options<'a>() -> RenderOptions<'a> {
12682 RenderOptions::new("index.html", "/")
12683 }
12684
12685 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
12686 let mut writer = TestWriter::new();
12687 handle(protocol, state, options, &mut writer).unwrap();
12688 writer.get_content().to_string()
12689 }
12690
12691 #[test]
12692 fn state_inject_emits_at_every_structural_boundary() {
12693 let protocol = build_all_boundaries_protocol();
12694 let state = test_json!({
12695 "$webui": {
12696 "headEnd": "<meta name=he>",
12697 "bodyStart": "<span id=bs></span>",
12698 "bodyEnd": "<script>be</script>",
12699 }
12700 });
12701 let html = render_with(&protocol, &state, &state_inject_options());
12702
12703 let head_end = html.find("<meta name=he>").expect("headEnd missing");
12704 let head_close = html.find("</head>").expect("</head> missing");
12705 let body_open = html.find("<body>").expect("<body> missing");
12706 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
12707 let hello = html.find("hello").expect("body content missing");
12708 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
12709 let body_close = html.find("</body>").expect("</body> missing");
12710
12711 assert!(
12712 head_end < head_close,
12713 "headEnd must precede </head>: {html}"
12714 );
12715 assert!(
12716 body_open < body_start && body_start < hello,
12717 "bodyStart must sit immediately after <body>: {html}"
12718 );
12719 assert!(
12720 hello < body_end && body_end < body_close,
12721 "bodyEnd must precede </body>: {html}"
12722 );
12723
12724 for needle in [
12725 "<meta name=he>",
12726 "<span id=bs></span>",
12727 "<script>be</script>",
12728 ] {
12729 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
12730 }
12731 }
12732
12733 #[test]
12734 fn state_inject_follows_render_options_inject() {
12735 let protocol = build_all_boundaries_protocol();
12736 let state = test_json!({
12737 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
12738 });
12739 let options = RenderOptions::new("index.html", "/")
12740 .with_head_inject("<!--opt-he-->")
12741 .with_body_inject("<!--opt-be-->");
12742 let html = render_with(&protocol, &state, &options);
12743
12744 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
12745 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
12746 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
12747 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
12748
12749 assert!(
12750 opt_he < state_he,
12751 "RenderOptions head_inject must precede the state-supplied value: {html}"
12752 );
12753 assert!(
12754 opt_be < state_be,
12755 "RenderOptions body_inject must precede the state-supplied value: {html}"
12756 );
12757 }
12758
12759 #[test]
12760 fn malformed_state_inject_values_are_inert() {
12761 let protocol = build_all_boundaries_protocol();
12762 for state in [
12765 test_json!({}),
12766 test_json!({ "$webui": "not-an-object" }),
12767 test_json!({ "$webui": [] }),
12768 test_json!({ "$webui": null }),
12769 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
12770 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
12771 ] {
12772 let html = render_with(&protocol, &state, &state_inject_options());
12773 assert!(
12774 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
12775 "malformed reserved state must render the document unchanged: {html}"
12776 );
12777 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
12778 assert!(!html.contains("42"), "non-string member leaked: {html}");
12779 }
12780 }
12781
12782 #[test]
12783 fn state_inject_never_reaches_the_hydration_payload() {
12784 let protocol = build_all_boundaries_protocol();
12785 let state = test_json!({
12786 "visible": "keep",
12787 "$webui": { "bodyEnd": "<script>secret</script>" }
12788 });
12789 let handler = WebUIHandler::with_plugin(|| {
12790 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12791 });
12792 let runtime = Protocol::new(protocol.clone());
12793 let mut writer = TestWriter::new();
12794 handler
12795 .render(&runtime, &state, &state_inject_options(), &mut writer)
12796 .unwrap();
12797 let html = writer.get_content();
12798
12799 let data_start = html
12800 .find(r#"<script type="application/json" id="webui-data""#)
12801 .expect("hydration block missing");
12802 let data_end = html[data_start..]
12803 .find("</script>")
12804 .map(|offset| data_start + offset)
12805 .expect("hydration block never closes");
12806 let payload = &html[data_start..data_end];
12807 assert!(
12808 !payload.contains("$webui"),
12809 "reserved key must be stripped from the hydration payload: {payload}"
12810 );
12811 assert!(
12812 payload.contains("visible"),
12813 "ordinary state must survive the filter: {payload}"
12814 );
12815 let inject = html
12818 .find("<script>secret</script>")
12819 .expect("inject missing");
12820 let body_close = html.find("</body>").expect("</body> missing");
12821 assert!(
12822 data_end < inject && inject < body_close,
12823 "state bodyEnd must follow the hydration block and precede </body>: {html}"
12824 );
12825 }
12826
12827 #[test]
12828 fn projected_hydration_strips_reserved_state_inject_key() {
12829 let mut fragments = HashMap::new();
12830 fragments.insert(
12831 "index.html".to_string(),
12832 FragmentList {
12833 fragments: vec![
12834 WebUIFragment::raw("<html><body>"),
12835 WebUIFragment::component("app-shell"),
12836 structural_fragment("body_end"),
12837 WebUIFragment::raw("</body></html>"),
12838 ],
12839 contains_boundary: false,
12840 },
12841 );
12842 fragments.insert(
12843 "app-shell".to_string(),
12844 FragmentList {
12845 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12846 contains_boundary: false,
12847 },
12848 );
12849 let mut document = WebUIProtocol::new(fragments);
12850 document.initial_state_strategy = InitialStateStrategy::Components as i32;
12851 document.components.insert(
12852 "app-shell".to_string(),
12853 webui_protocol::ComponentData {
12854 hydration_mode: StateProjectionMode::Keys as i32,
12855 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
12856 ..Default::default()
12857 },
12858 );
12859 let state = test_json!({
12860 "$webui": { "bodyEnd": "<script>secret</script>" },
12861 "serverOnly": "drop",
12862 "visible": "keep",
12863 });
12864 let handler = WebUIHandler::with_plugin(|| {
12865 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12866 });
12867 let mut writer = TestWriter::new();
12868
12869 handler
12870 .render(
12871 &Protocol::new(document),
12872 &state,
12873 &RenderOptions::new("index.html", "/"),
12874 &mut writer,
12875 )
12876 .unwrap();
12877 let html = writer.get_content();
12878 let data_start = html
12879 .find(r#"<script type="application/json" id="webui-data""#)
12880 .expect("hydration block missing");
12881 let data_end = html[data_start..]
12882 .find("</script>")
12883 .map(|offset| data_start + offset)
12884 .expect("hydration block never closes");
12885 let payload = &html[data_start..data_end];
12886
12887 assert!(
12888 !payload.contains(STATE_INJECT_KEY),
12889 "reserved key leaked: {payload}"
12890 );
12891 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
12892 assert!(!payload.contains("serverOnly"), "{payload}");
12893 assert!(html.contains("<script>secret</script>"), "{html}");
12894 }
12895
12896 #[test]
12897 fn write_selected_state_strips_reserved_key_from_full_state() {
12898 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
12899 let mut sink = TestWriter::new();
12900 let mut scratch = Vec::new();
12901 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12902 let json = sink.get_content();
12903 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
12904 assert!(
12905 json.contains("\"a\":1") && json.contains("\"z\":2"),
12906 "{json}"
12907 );
12908 }
12909
12910 #[test]
12911 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
12912 let mut protocol = WebUIProtocol::new(HashMap::new());
12915 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12916 protocol.fragments.insert(
12917 "keep-card".to_string(),
12918 webui_protocol::FragmentList::default(),
12919 );
12920 protocol.components.insert(
12921 "keep-card".to_string(),
12922 webui_protocol::ComponentData {
12923 hydration_mode: StateProjectionMode::Keys as i32,
12924 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
12925 ..Default::default()
12926 },
12927 );
12928 let protocol = Protocol::new(protocol);
12929 let index = protocol.component_reachability();
12930 let component = protocol.component_index()["keep-card"];
12931 let mut ids = Vec::new();
12932 assert!(!collect_hydration_key_ids_into(
12933 protocol.protocol(),
12934 index,
12935 [component],
12936 &mut ids
12937 ));
12938
12939 let state = test_json!({
12940 "$webui": { "bodyEnd": "<b>x</b>" },
12941 "keep": 1,
12942 });
12943 let mut sink = TestWriter::new();
12944 let mut scratch = Vec::new();
12945
12946 write_selected_state(
12947 &mut sink,
12948 &mut scratch,
12949 &state,
12950 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
12951 )
12952 .unwrap();
12953
12954 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
12955 }
12956
12957 #[test]
12958 fn write_selected_state_full_is_unchanged_without_reserved_key() {
12959 let state = test_json!({ "a": 1, "z": 2 });
12960 let mut sink = TestWriter::new();
12961 let mut scratch = Vec::new();
12962 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12963 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
12964 }
12965
12966 #[test]
12967 fn body_start_hook_dedupes_on_malformed_protocol() {
12968 let mut fragments = HashMap::new();
12969 fragments.insert(
12970 "index.html".to_string(),
12971 FragmentList {
12972 fragments: vec![
12973 WebUIFragment::raw("<html><head></head><body>".to_string()),
12974 structural_fragment("body_start"),
12975 structural_fragment("body_start"),
12976 WebUIFragment::raw("</body></html>".to_string()),
12977 ],
12978 contains_boundary: false,
12979 },
12980 );
12981 let protocol = WebUIProtocol::new(fragments);
12982 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
12983 let html = render_with(&protocol, &state, &state_inject_options());
12984 assert_eq!(
12985 html.matches("<i>once</i>").count(),
12986 1,
12987 "duplicate body_start signals must not duplicate the inject: {html}"
12988 );
12989 }
12990
12991 #[test]
12992 fn injects_are_no_op_when_unset() {
12993 let protocol = build_head_body_protocol();
12994 let state = test_json!({});
12995 let mut writer = TestWriter::new();
12996 handle(
12997 &protocol,
12998 &state,
12999 &RenderOptions::new("index.html", "/"),
13000 &mut writer,
13001 )
13002 .unwrap();
13003 let html = writer.get_content();
13004 assert!(!html.contains("<link rel=preload>"));
13005 assert!(!html.contains("<script>lr</script>"));
13006 }
13007
13008 #[test]
13009 fn empty_inject_string_treated_as_unset() {
13010 let protocol = build_head_body_protocol();
13011 let state = test_json!({});
13012 let mut writer = TestWriter::new();
13013 let opts = RenderOptions::new("index.html", "/")
13014 .with_head_inject("")
13015 .with_body_inject("");
13016 handle(&protocol, &state, &opts, &mut writer).unwrap();
13017 let html = writer.get_content();
13020 assert!(html.contains("</head>"));
13021 assert!(html.contains("</body>"));
13022 }
13023
13024 #[test]
13025 fn inject_html_is_passed_through_verbatim() {
13026 let protocol = build_head_body_protocol();
13030 let state = test_json!({});
13031 let mut writer = TestWriter::new();
13032 let opts =
13033 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13034 handle(&protocol, &state, &opts, &mut writer).unwrap();
13035 assert!(writer.get_content().contains("<script>var x=1;</script>"));
13036 }
13037
13038 #[test]
13045 fn injects_robust_against_marker_literals_in_content() {
13046 let mut fragments = HashMap::new();
13047 fragments.insert(
13051 "index.html".to_string(),
13052 FragmentList {
13053 fragments: vec![
13054 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13055 structural_fragment("head_end"),
13056 WebUIFragment::raw(
13057 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13058 ),
13059 structural_fragment("body_end"),
13060 WebUIFragment::raw("</body></html>".to_string()),
13061 ],
13062 contains_boundary: false,
13063 },
13064 );
13065 let protocol = WebUIProtocol::new(fragments);
13066 let state = test_json!({});
13067 let mut writer = TestWriter::new();
13068 let opts = RenderOptions::new("index.html", "/")
13069 .with_head_inject("<HEAD-INJ>")
13070 .with_body_inject("<BODY-INJ>");
13071 handle(&protocol, &state, &opts, &mut writer).unwrap();
13072 let html = writer.get_content();
13073 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13076 let head_close_idx = html.find("</head>").expect("</head> missing");
13077 assert!(head_inj_idx < head_close_idx);
13078 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13082 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13084 assert!(
13085 body_inj_idx < body_close_idx,
13086 "body_inject must precede the structural </body>: {html}"
13087 );
13088 assert!(html.contains("<!-- </body> </head> -->"));
13090 }
13091
13092 #[test]
13096 fn both_injects_fire_at_correct_boundaries() {
13097 let protocol = build_head_body_protocol();
13098 let state = test_json!({});
13099 let mut writer = TestWriter::new();
13100 let opts = RenderOptions::new("index.html", "/")
13101 .with_head_inject("<META-HEAD>")
13102 .with_body_inject("<SCRIPT-BODY>");
13103 handle(&protocol, &state, &opts, &mut writer).unwrap();
13104 let html = writer.get_content();
13105 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13106 let head_close = html.find("</head>").expect("</head> missing");
13107 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13108 let body_close = html.find("</body>").expect("</body> missing");
13109 assert!(head_idx < head_close, "head_inject before </head>");
13110 assert!(head_close < body_idx, "body_inject after </head>");
13111 assert!(body_idx < body_close, "body_inject before </body>");
13112 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13113 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13114 }
13115
13116 #[test]
13122 fn injects_dedupe_against_duplicate_signals() {
13123 let mut fragments = HashMap::new();
13124 fragments.insert(
13125 "index.html".to_string(),
13126 FragmentList {
13127 fragments: vec![
13128 WebUIFragment::raw("<html><head>".to_string()),
13129 structural_fragment("head_end"),
13130 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13133 structural_fragment("body_end"),
13134 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13136 ],
13137 contains_boundary: false,
13138 },
13139 );
13140 let protocol = WebUIProtocol::new(fragments);
13141 let state = test_json!({});
13142 let mut writer = TestWriter::new();
13143 let opts = RenderOptions::new("index.html", "/")
13144 .with_head_inject("<HINJ>")
13145 .with_body_inject("<BINJ>");
13146 handle(&protocol, &state, &opts, &mut writer).unwrap();
13147 let html = writer.get_content();
13148 assert_eq!(
13149 html.matches("<HINJ>").count(),
13150 1,
13151 "head_inject must emit exactly once even with duplicate head_end signals"
13152 );
13153 assert_eq!(
13154 html.matches("<BINJ>").count(),
13155 1,
13156 "body_inject must emit exactly once even with duplicate body_end signals"
13157 );
13158 }
13159
13160 #[test]
13164 fn injects_no_op_when_no_head_or_body_signals() {
13165 let mut fragments = HashMap::new();
13166 fragments.insert(
13167 "index.html".to_string(),
13168 FragmentList {
13169 fragments: vec![WebUIFragment::raw(
13170 "<my-component>hi</my-component>".to_string(),
13171 )],
13172 contains_boundary: false,
13173 },
13174 );
13175 let protocol = WebUIProtocol::new(fragments);
13176 let state = test_json!({});
13177 let mut writer = TestWriter::new();
13178 let opts = RenderOptions::new("index.html", "/")
13179 .with_head_inject("<HINJ>")
13180 .with_body_inject("<BINJ>");
13181 handle(&protocol, &state, &opts, &mut writer).unwrap();
13182 let html = writer.get_content();
13183 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13184 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13185 assert!(html.contains("<my-component>"));
13186 }
13187
13188 #[test]
13194 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13195 let protocol = std::sync::Arc::new(build_head_body_protocol());
13196 let state = std::sync::Arc::new(test_json!({}));
13197 let handler = std::sync::Arc::new(WebUIHandler::new());
13198
13199 const N_THREADS: usize = 16;
13200 let mut handles = Vec::with_capacity(N_THREADS);
13201 for tid in 0..N_THREADS {
13202 let h = std::sync::Arc::clone(&handler);
13203 let p = std::sync::Arc::clone(&protocol);
13204 let s = std::sync::Arc::clone(&state);
13205 handles.push(std::thread::spawn(move || {
13206 let head = format!("<HEAD-T{tid}>");
13207 let body = format!("<BODY-T{tid}>");
13208 let mut writer = TestWriter::new();
13209 let opts = RenderOptions::new("index.html", "/")
13210 .with_head_inject(&head)
13211 .with_body_inject(&body);
13212 h.handle(&p, &s, &opts, &mut writer).unwrap();
13213 let html = writer.get_content();
13214 assert_eq!(html.matches(&head).count(), 1);
13216 assert_eq!(html.matches(&body).count(), 1);
13217 for other in 0..N_THREADS {
13219 if other == tid {
13220 continue;
13221 }
13222 let other_head = format!("<HEAD-T{other}>");
13223 let other_body = format!("<BODY-T{other}>");
13224 assert!(
13225 !html.contains(&other_head),
13226 "tid {tid} saw {other}'s head_inject"
13227 );
13228 assert!(
13229 !html.contains(&other_body),
13230 "tid {tid} saw {other}'s body_inject"
13231 );
13232 }
13233 }));
13234 }
13235 for h in handles {
13236 h.join().expect("worker panicked");
13237 }
13238 }
13239
13240 #[test]
13245 fn large_inject_roundtrips_without_truncation() {
13246 let protocol = build_head_body_protocol();
13247 let state = test_json!({});
13248 let mut writer = TestWriter::new();
13249 let big = "x".repeat(1024 * 1024);
13250 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13251 handle(&protocol, &state, &opts, &mut writer).unwrap();
13252 let html = writer.get_content();
13253 assert!(
13254 html.contains(&big),
13255 "large head_inject must be present verbatim ({} bytes)",
13256 big.len()
13257 );
13258 assert_eq!(html.matches(&big).count(), 1);
13260 }
13261
13262 #[test]
13267 fn empty_nonce_treated_as_unset() {
13268 let protocol = build_head_body_protocol();
13269 let state = test_json!({});
13270 let mut writer = TestWriter::new();
13271 let opts = RenderOptions::new("index.html", "/").with_nonce("");
13272 handle(&protocol, &state, &opts, &mut writer).unwrap();
13273 assert!(
13274 !writer.get_content().contains("webui-nonce"),
13275 "empty nonce must not emit <meta name=\"webui-nonce\">"
13276 );
13277 }
13278
13279 #[test]
13297 fn empty_field_bypass_is_normalised_at_handler_init() {
13298 let protocol = build_head_body_protocol();
13299 let state = test_json!({});
13300
13301 let opts_with_empty_nonce = RenderOptions {
13303 nonce: Some(""),
13304 ..RenderOptions::new("index.html", "/")
13305 };
13306 let mut writer = TestWriter::new();
13307 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13308 let html = writer.get_content();
13309 assert!(
13310 !html.contains("webui-nonce"),
13311 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13312 );
13313 assert!(
13314 !html.contains("nonce=\"\""),
13315 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13316 );
13317
13318 let opts_with_empty_injects = RenderOptions {
13320 head_inject: Some(""),
13321 body_inject: Some(""),
13322 ..RenderOptions::new("index.html", "/")
13323 };
13324 let mut writer = TestWriter::new();
13325 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13326 }
13330
13331 #[test]
13364 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13365 let mut fragments = HashMap::new();
13366 fragments.insert(
13367 "index.html".to_string(),
13368 FragmentList {
13369 fragments: vec![
13370 WebUIFragment::raw("["),
13371 WebUIFragment::for_loop("item", "outer", "outer_body"),
13372 WebUIFragment::raw("]"),
13373 ],
13374 contains_boundary: false,
13375 },
13376 );
13377 fragments.insert(
13378 "outer_body".to_string(),
13379 FragmentList {
13380 fragments: vec![
13381 WebUIFragment::raw("(O="),
13382 WebUIFragment::signal("item.tag", false),
13383 WebUIFragment::for_loop("item", "inner", "inner_body"),
13384 WebUIFragment::raw(",O="),
13385 WebUIFragment::signal("item.tag", false),
13386 WebUIFragment::raw(")"),
13387 ],
13388 contains_boundary: false,
13389 },
13390 );
13391 fragments.insert(
13392 "inner_body".to_string(),
13393 FragmentList {
13394 fragments: vec![
13395 WebUIFragment::raw("[I="),
13396 WebUIFragment::signal("item.tag", false),
13397 WebUIFragment::raw("]"),
13398 ],
13399 contains_boundary: false,
13400 },
13401 );
13402 let protocol = WebUIProtocol::new(fragments);
13403 let state = test_json!({
13404 "outer": [{"tag": "A"}, {"tag": "B"}],
13405 "inner": [{"tag": "X"}, {"tag": "Y"}],
13406 });
13407 let mut writer = TestWriter::new();
13408 handle(
13409 &protocol,
13410 &state,
13411 &RenderOptions::new("index.html", "/"),
13412 &mut writer,
13413 )
13414 .unwrap();
13415 assert_eq!(
13427 writer.get_content(),
13428 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13429 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13430 );
13431 }
13432 #[derive(Default)]
13433 struct FlushTestWriter {
13434 output: String,
13435 flushes: Vec<usize>,
13436 fail_flush: bool,
13437 fail_flush_at: Option<usize>,
13438 flush_attempts: usize,
13439 ended: bool,
13440 }
13441
13442 impl ResponseWriter for FlushTestWriter {
13443 fn write(&mut self, content: &str) -> Result<()> {
13444 self.output.push_str(content);
13445 Ok(())
13446 }
13447
13448 fn end(&mut self) -> Result<()> {
13449 self.ended = true;
13450 Ok(())
13451 }
13452 }
13453
13454 impl FlushWriter for FlushTestWriter {
13455 fn flush(&mut self) -> Result<()> {
13456 let attempt = self.flush_attempts;
13457 self.flush_attempts += 1;
13458 if self.fail_flush || self.fail_flush_at == Some(attempt) {
13459 return Err(HandlerError::ClientDisconnected);
13460 }
13461 self.flushes.push(self.output.len());
13462 Ok(())
13463 }
13464 }
13465
13466 fn streaming_protocol(with_boundaries: bool) -> Protocol {
13467 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
13468 }
13469
13470 fn streaming_protocol_with_state_strategy(
13471 with_boundaries: bool,
13472 state_strategy: InitialStateStrategy,
13473 ) -> Protocol {
13474 let mut fragments = HashMap::new();
13475 let mut entry = vec![
13476 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
13477 structural_fragment("head_start"),
13478 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
13479 structural_fragment("head_end"),
13480 WebUIFragment::raw("</HEAD><body>"),
13481 structural_fragment("body_start"),
13482 ];
13483 if with_boundaries {
13484 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
13485 }
13486 entry.extend([
13487 WebUIFragment::raw("<my-counter"),
13488 structural_fragment("streaming_root:my-counter"),
13489 WebUIFragment::raw(">"),
13490 WebUIFragment::component("my-counter"),
13491 WebUIFragment::raw("</my-counter>"),
13492 ]);
13493 if with_boundaries {
13494 entry.push(WebUIFragment::boundary_end(0));
13495 }
13496 entry.extend([
13497 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
13498 structural_fragment("body_end"),
13499 WebUIFragment::raw("</body></html>"),
13500 ]);
13501 fragments.insert(
13502 "index.html".to_string(),
13503 FragmentList {
13504 fragments: entry,
13505 contains_boundary: with_boundaries,
13506 },
13507 );
13508 fragments.insert(
13509 "my-counter".to_string(),
13510 FragmentList {
13511 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
13512 contains_boundary: false,
13513 },
13514 );
13515
13516 let mut document = WebUIProtocol::new(fragments);
13517 document.initial_state_strategy = state_strategy as i32;
13518 document.components.insert(
13519 "my-counter".to_string(),
13520 webui_protocol::ComponentData {
13521 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
13522 template_functions: "[function(){return true}]".to_string(),
13523 hydration_mode: StateProjectionMode::Keys as i32,
13524 hydration_keys: vec!["count".to_string()],
13525 ..Default::default()
13526 },
13527 );
13528 Protocol::new(document)
13529 }
13530
13531 #[test]
13532 fn handler_error_stays_small() {
13533 assert!(
13540 std::mem::size_of::<HandlerError>()
13541 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
13542 "HandlerError grew to {} bytes",
13543 std::mem::size_of::<HandlerError>()
13544 );
13545 }
13546
13547 #[test]
13548 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
13549 let protocol = streaming_protocol(true);
13550 let handler = WebUIHandler::with_plugin(|| {
13551 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13552 });
13553 let state = test_json!({ "count": 1, "serverOnly": "secret" });
13554 let mut writer = FlushTestWriter::default();
13555
13556 handler
13557 .render_streaming(
13558 &protocol,
13559 &state,
13560 &RenderOptions::new("index.html", "/"),
13561 &mut writer,
13562 )
13563 .unwrap();
13564
13565 assert!(writer.ended);
13566 assert_eq!(
13567 writer.flushes.len(),
13568 3,
13569 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
13570 );
13571 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
13572 assert!(boundary_flush.contains("<!--wb:0-->"));
13573 assert!(boundary_flush.contains("<!--/wb:0-->"));
13574 assert!(
13575 boundary_flush.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
13576 "boundary flush: {boundary_flush}"
13577 );
13578 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
13579 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
13580 assert!(!boundary_flush.contains("slow tail"));
13581 assert!(!writer.output.contains("id=\"webui-data\""));
13582 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
13585 assert!(terminal_flush.contains("slow tail"));
13586 assert!(writer.output.contains("[2,1,4,0,{}]"));
13587 assert!(!writer.output.contains("[2,1,0,1,"));
13588 assert!(!writer.output.contains("[2,2,4,0,{}]"));
13589
13590 let marker = writer
13591 .output
13592 .find(STREAMING_MARKER)
13593 .expect("streaming marker");
13594 let attributed_head = writer
13595 .output
13596 .find("<HEAD data-shell=\"main\">")
13597 .expect("attributed mixed-case head");
13598 let authored_script = writer
13599 .output
13600 .find("src=\"/index.js\"")
13601 .expect("entry script");
13602 assert!(attributed_head < marker && marker < authored_script);
13603 }
13604
13605 #[test]
13606 fn streaming_terminal_tail_never_resends_full_state() {
13607 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
13608 let handler = WebUIHandler::with_plugin(|| {
13609 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13610 });
13611 let mut writer = FlushTestWriter::default();
13612
13613 handler
13614 .render_streaming(
13615 &protocol,
13616 &test_json!({ "count": 1, "serverOnly": "secret" }),
13617 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
13618 &mut writer,
13619 )
13620 .unwrap();
13621
13622 assert_eq!(
13623 writer.output.matches("serverOnly").count(),
13624 1,
13625 "full state belongs only to the interactive boundary"
13626 );
13627 assert!(writer.output.contains("[2,1,4,0,{}]"));
13628 }
13629
13630 #[test]
13634 fn state_inject_placement_matches_between_render_modes() {
13635 let entry = vec![
13636 WebUIFragment::raw("<html><head>"),
13637 structural_fragment("head_start"),
13638 structural_fragment("head_end"),
13639 WebUIFragment::raw("</head><body>"),
13640 structural_fragment("body_start"),
13641 WebUIFragment::raw("<main>static</main>"),
13642 structural_fragment("body_end"),
13643 WebUIFragment::raw("</body></html>"),
13644 ];
13645 let fragments = HashMap::from([(
13646 "index.html".to_string(),
13647 FragmentList {
13648 fragments: entry,
13649 contains_boundary: false,
13650 },
13651 )]);
13652 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13653 let state = test_json!({
13654 "$webui": {
13655 "headEnd": "<meta name=he>",
13656 "bodyStart": "<span id=bs></span>",
13657 "bodyEnd": "<script>be</script>",
13658 }
13659 });
13660 let options = RenderOptions::new("index.html", "/");
13661
13662 let mut ordinary = TestWriter::new();
13663 WebUIHandler::new()
13664 .render(&protocol, &state, &options, &mut ordinary)
13665 .unwrap();
13666 let ordinary_html = ordinary.get_content().to_string();
13667
13668 let mut streamed = FlushTestWriter::default();
13669 WebUIHandler::new()
13670 .render_streaming(&protocol, &state, &options, &mut streamed)
13671 .unwrap();
13672 let streamed_html = &streamed.output;
13673
13674 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
13675 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13676 let head_close = html.find("</head>").expect("</head> missing");
13677 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13678 let main = html.find("<main>static</main>").expect("content missing");
13679 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13680 let body_close = html.find("</body>").expect("</body> missing");
13681 assert!(head_end < head_close, "headEnd misplaced: {html}");
13682 assert!(body_start < main, "bodyStart misplaced: {html}");
13683 assert!(
13684 main < body_end && body_end < body_close,
13685 "bodyEnd misplaced: {html}"
13686 );
13687 }
13688
13689 assert!(
13692 streamed_html.contains(",4,0,{}]"),
13693 "streaming must still end in one empty terminal record: {streamed_html}"
13694 );
13695 }
13696
13697 #[test]
13698 fn streaming_state_inject_emits_and_strips_reserved_key() {
13699 let entry = vec![
13700 WebUIFragment::raw("<html><head>"),
13701 structural_fragment("head_start"),
13702 structural_fragment("head_end"),
13703 WebUIFragment::raw("</head><body>"),
13704 structural_fragment("body_start"),
13705 structural_fragment("body_end"),
13706 WebUIFragment::raw("</body></html>"),
13707 ];
13708 let fragments = HashMap::from([(
13709 "index.html".to_string(),
13710 FragmentList {
13711 fragments: entry,
13712 contains_boundary: false,
13713 },
13714 )]);
13715 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13716 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
13717
13718 let mut writer = FlushTestWriter::default();
13719 WebUIHandler::new()
13720 .render_streaming(
13721 &protocol,
13722 &state,
13723 &RenderOptions::new("index.html", "/"),
13724 &mut writer,
13725 )
13726 .unwrap();
13727 assert!(writer.output.contains("<script>be</script>"));
13728 assert!(!writer.output.contains("$webui"));
13729 }
13730
13731 #[test]
13732 fn static_streaming_document_uses_one_empty_terminal_record() {
13733 let fragments = HashMap::from([(
13734 "index.html".to_string(),
13735 FragmentList {
13736 fragments: vec![
13737 WebUIFragment::raw("<html><head>"),
13738 structural_fragment("head_start"),
13739 structural_fragment("head_end"),
13740 WebUIFragment::raw("</head><body>"),
13741 structural_fragment("body_start"),
13742 WebUIFragment::raw("<main>static</main>"),
13743 structural_fragment("body_end"),
13744 WebUIFragment::raw("</body></html>"),
13745 ],
13746 contains_boundary: false,
13747 },
13748 )]);
13749 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13750 let mut writer = FlushTestWriter::default();
13751
13752 WebUIHandler::new()
13753 .render_streaming(
13754 &protocol,
13755 &test_json!({ "serverOnly": "secret" }),
13756 &RenderOptions::new("index.html", "/"),
13757 &mut writer,
13758 )
13759 .unwrap();
13760
13761 assert_eq!(writer.flushes.len(), 1);
13762 assert!(writer.output.contains(STREAMING_MARKER));
13763 assert!(writer.output.contains("[2,0,4,0,{}]"));
13764 assert!(!writer.output.contains("serverOnly"));
13765 assert!(!writer.output.contains("id=\"webui-data\""));
13766 assert!(!writer.output.contains("<!--wb:"));
13767 }
13768
13769 #[test]
13770 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
13771 let protocol = streaming_protocol(true);
13772 let handler = WebUIHandler::with_plugin(|| {
13773 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13774 });
13775 let mut writer = FlushTestWriter::default();
13776 handler
13777 .render_streaming(
13778 &protocol,
13779 &test_json!({ "count": 1 }),
13780 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
13781 &mut writer,
13782 )
13783 .unwrap();
13784
13785 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
13786 let envelope = writer.output[boundary_end..]
13787 .find("data-webui-boundary nonce=\"test-nonce-123\"")
13788 .map(|index| index + boundary_end)
13789 .expect("nonce-bearing envelope");
13790 let functions = writer
13791 .output
13792 .find("templateFns")
13793 .expect("function side channel");
13794 let sentinel = writer
13795 .output
13796 .find("<webui-hydrate>")
13797 .expect("hydration sentinel");
13798 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
13799 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
13800 }
13801
13802 #[test]
13803 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
13804 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
13805 let mut fragments = vec![structural_fragment("head_start")];
13806 fragments.extend(boundaries);
13807 let fragments = HashMap::from([(
13808 "index.html".to_string(),
13809 FragmentList {
13810 fragments,
13811 contains_boundary: true,
13812 },
13813 )]);
13814 Protocol::new(WebUIProtocol::new(fragments))
13815 }
13816
13817 let handler = WebUIHandler::new();
13818 let mut writer = FlushTestWriter::default();
13819 let result = handler.render_streaming(
13820 &protocol(vec![WebUIFragment::boundary_end(0)]),
13821 &test_json!({}),
13822 &RenderOptions::new("index.html", "/"),
13823 &mut writer,
13824 );
13825 match result {
13826 Err(HandlerError::StreamingBoundary(err)) => {
13827 assert_eq!(err.signal, "boundary");
13828 assert!(err.reason.contains("no active occurrence"));
13829 }
13830 other => panic!("expected ordered-boundary error, got {other:?}"),
13831 }
13832
13833 let mut writer = FlushTestWriter::default();
13834 let result = handler.render_streaming(
13835 &protocol(vec![
13836 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
13837 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
13838 ]),
13839 &test_json!({}),
13840 &RenderOptions::new("index.html", "/"),
13841 &mut writer,
13842 );
13843 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
13844 }
13845
13846 #[test]
13847 fn streaming_render_propagates_boundary_flush_disconnect() {
13848 let protocol = streaming_protocol(true);
13849 let handler = WebUIHandler::new();
13850 let mut writer = FlushTestWriter {
13851 fail_flush: true,
13852 ..FlushTestWriter::default()
13853 };
13854 let result = handler.render_streaming(
13855 &protocol,
13856 &test_json!({}),
13857 &RenderOptions::new("index.html", "/"),
13858 &mut writer,
13859 );
13860 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
13861 assert!(!writer.ended);
13862 }
13863
13864 #[test]
13865 fn streaming_render_requires_structural_head_start_before_writing() {
13866 let fragments = HashMap::from([(
13867 "index.html".to_string(),
13868 FragmentList {
13869 fragments: vec![
13870 WebUIFragment::raw(
13871 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
13872 ),
13873 structural_fragment("head_end"),
13874 WebUIFragment::raw("</HEAD><body>"),
13875 structural_fragment("body_start"),
13876 structural_fragment("body_end"),
13877 ],
13878 contains_boundary: false,
13879 },
13880 )]);
13881 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13882 let mut writer = FlushTestWriter::default();
13883
13884 let result = WebUIHandler::new().render_streaming(
13885 &protocol,
13886 &test_json!({}),
13887 &RenderOptions::new("index.html", "/"),
13888 &mut writer,
13889 );
13890
13891 assert!(matches!(
13892 result,
13893 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
13894 ));
13895 assert_eq!(
13896 writer.output,
13897 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
13898 );
13899 assert!(writer.flushes.is_empty());
13900 assert!(!writer.ended);
13901 }
13902
13903 #[test]
13904 fn streaming_render_rejects_duplicate_head_start() {
13905 let fragments = HashMap::from([(
13906 "index.html".to_string(),
13907 FragmentList {
13908 fragments: vec![
13909 WebUIFragment::raw("<html><head>"),
13910 structural_fragment("head_start"),
13911 structural_fragment("head_start"),
13912 structural_fragment("head_end"),
13913 WebUIFragment::raw("</head><body>"),
13914 structural_fragment("body_start"),
13915 structural_fragment("body_end"),
13916 ],
13917 contains_boundary: false,
13918 },
13919 )]);
13920 let protocol = Protocol::new(WebUIProtocol::new(fragments));
13921 let mut writer = FlushTestWriter::default();
13922
13923 let result = WebUIHandler::new().render_streaming(
13924 &protocol,
13925 &test_json!({}),
13926 &RenderOptions::new("index.html", "/"),
13927 &mut writer,
13928 );
13929
13930 assert!(matches!(
13931 result,
13932 Err(HandlerError::DuplicateStreamingHeadStart)
13933 ));
13934 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
13935 assert!(writer.flushes.is_empty());
13936 }
13937
13938 #[test]
13939 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
13940 let handler = WebUIHandler::with_plugin(|| {
13941 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13942 });
13943 let state = test_json!({
13944 "count": 1,
13945 "head_start": "must not render",
13946 "boundary_start:0": "must not render",
13947 "boundary_end:0": "must not render",
13948 });
13949 let mut with_boundaries = TestWriter::new();
13950 let mut without_boundaries = TestWriter::new();
13951 handler
13952 .render(
13953 &streaming_protocol(true),
13954 &state,
13955 &RenderOptions::new("index.html", "/"),
13956 &mut with_boundaries,
13957 )
13958 .unwrap();
13959 handler
13960 .render(
13961 &streaming_protocol(false),
13962 &state,
13963 &RenderOptions::new("index.html", "/"),
13964 &mut without_boundaries,
13965 )
13966 .unwrap();
13967 assert_eq!(
13968 with_boundaries.get_content(),
13969 without_boundaries.get_content()
13970 );
13971 }
13972
13973 #[test]
13974 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
13975 for source in [
13976 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
13977 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
13978 ] {
13979 let mut parser = HtmlParser::new();
13980 parser
13981 .parse("index.html", source)
13982 .expect("parse head fixture");
13983 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
13984 let mut writer = TestWriter::new();
13985 WebUIHandler::new()
13986 .render(
13987 &protocol,
13988 &test_json!({ "theme": "light" }),
13989 &RenderOptions::new("index.html", "/"),
13990 &mut writer,
13991 )
13992 .unwrap();
13993
13994 assert_eq!(
13995 writer.get_content(),
13996 "<html><head><title>T</title></head><body>x</body></html>"
13997 );
13998 }
13999 }
14000
14001 #[test]
14002 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14003 let source =
14004 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14005 let mut parser = HtmlParser::new();
14006 parser.parse("index.html", source).expect("parse fixture");
14007 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14008
14009 let mut ordinary = TestWriter::new();
14010 WebUIHandler::new()
14011 .render(
14012 &protocol,
14013 &test_json!({}),
14014 &RenderOptions::new("index.html", "/"),
14015 &mut ordinary,
14016 )
14017 .unwrap();
14018 assert_eq!(ordinary.get_content(), source);
14019
14020 let mut streaming = FlushTestWriter::default();
14021 WebUIHandler::new()
14022 .render_streaming(
14023 &protocol,
14024 &test_json!({}),
14025 &RenderOptions::new("index.html", "/"),
14026 &mut streaming,
14027 )
14028 .unwrap();
14029 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14030 assert!(streaming.output.contains("</HEAD><BODY>x"));
14031 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14032 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14033 let title = streaming.output.find("<title>").expect("title");
14034 assert!(opening < marker && marker < title);
14035 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14036 }
14037
14038 #[test]
14041 fn unnamespaced_signal_is_ordinary_content() {
14042 let fragments = HashMap::from([(
14043 "index.html".to_string(),
14044 FragmentList {
14045 fragments: vec![
14046 WebUIFragment::raw("<html><head>"),
14047 structural_fragment("head_end"),
14048 WebUIFragment::raw("</head><body>"),
14049 WebUIFragment::signal("body_end".to_string(), false),
14050 structural_fragment("body_end"),
14051 WebUIFragment::raw("</body></html>"),
14052 ],
14053 contains_boundary: false,
14054 },
14055 )]);
14056 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14057 let mut writer = TestWriter::new();
14058 WebUIHandler::new()
14059 .render(
14060 &protocol,
14061 &test_json!({ "body_end": "content" }),
14062 &RenderOptions::new("index.html", "/"),
14063 &mut writer,
14064 )
14065 .expect("current protocol must render");
14066 assert!(writer.get_content().contains("content"));
14067 }
14068
14069 #[test]
14070 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14071 let source = concat!(
14072 "<html><head></head><body>",
14073 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14074 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14075 "</body></html>",
14076 );
14077 let state = test_json!({
14078 "head_start": "hs",
14079 "head_end": "he",
14080 "body_start": "bs",
14081 "body_end": "be",
14082 "boundary_start:0": "b0s",
14083 "boundary_end:0": "b0e",
14084 "streaming_root:forged": "root",
14085 });
14086 let mut parser = HtmlParser::new();
14087 parser.parse("index.html", source).expect("parse fixture");
14088 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14089 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14090
14091 let mut ordinary = TestWriter::new();
14092 WebUIHandler::new()
14093 .render(
14094 &protocol,
14095 &state,
14096 &RenderOptions::new("index.html", "/"),
14097 &mut ordinary,
14098 )
14099 .unwrap();
14100 assert_eq!(ordinary.get_content(), expected);
14101
14102 let mut streaming = FlushTestWriter::default();
14103 WebUIHandler::new()
14104 .render_streaming(
14105 &protocol,
14106 &state,
14107 &RenderOptions::new("index.html", "/"),
14108 &mut streaming,
14109 )
14110 .unwrap();
14111 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14112 assert!(!streaming.output.contains("<!--wb:"));
14113 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14114 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14115 }
14116
14117 #[test]
14118 fn streaming_rejects_structural_signals_after_terminal() {
14119 let fragments = HashMap::from([(
14120 "index.html".to_string(),
14121 FragmentList {
14122 fragments: vec![
14123 WebUIFragment::raw("<html><head>"),
14124 structural_fragment("head_start"),
14125 structural_fragment("head_end"),
14126 WebUIFragment::raw("</head><body>"),
14127 structural_fragment("body_start"),
14128 structural_fragment("body_end"),
14129 WebUIFragment::raw("</body>"),
14130 structural_fragment("boundary_start:0"),
14131 WebUIFragment::raw("</html>"),
14132 ],
14133 contains_boundary: false,
14134 },
14135 )]);
14136 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14137 let mut streaming = FlushTestWriter::default();
14138 let result = WebUIHandler::new().render_streaming(
14139 &protocol,
14140 &test_json!({}),
14141 &RenderOptions::new("index.html", "/"),
14142 &mut streaming,
14143 );
14144
14145 match result {
14146 Err(HandlerError::StreamingBoundary(error)) => {
14147 assert_eq!(error.signal, "boundary_start:0");
14148 assert!(
14149 error
14150 .reason
14151 .contains("structural signal arrived after body_end"),
14152 "reason: {}",
14153 error.reason
14154 );
14155 }
14156 other => panic!("expected post-terminal rejection, got {other:?}"),
14157 }
14158 assert_eq!(
14159 streaming.output,
14160 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body></body>"
14161 );
14162 assert!(!streaming.output.contains("<!--wb:0-->"));
14163
14164 let mut ordinary = TestWriter::new();
14165 WebUIHandler::new()
14166 .render(
14167 &protocol,
14168 &test_json!({}),
14169 &RenderOptions::new("index.html", "/"),
14170 &mut ordinary,
14171 )
14172 .unwrap();
14173 assert_eq!(
14174 ordinary.get_content(),
14175 "<html><head></head><body></body></html>"
14176 );
14177 }
14178
14179 fn disjoint_streaming_protocol_ext(
14180 hosts: &[&str],
14181 emit_root_signal: bool,
14182 include_styles: bool,
14183 ) -> Protocol {
14184 let mut fragments = HashMap::new();
14185 let mut entry = vec![
14186 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14187 structural_fragment("head_start"),
14188 structural_fragment("head_end"),
14189 WebUIFragment::raw("</head><body>"),
14190 structural_fragment("body_start"),
14191 ];
14192 for (sequence, host) in hosts.iter().enumerate() {
14193 entry.push(WebUIFragment::boundary(
14194 sequence as u32,
14195 "index.html",
14196 format!("boundary-{sequence}"),
14197 None,
14198 ));
14199 entry.push(WebUIFragment::raw(format!("<{host}")));
14200 if emit_root_signal {
14201 entry.push(structural_fragment(format!("streaming_root:{host}")));
14202 }
14203 entry.push(WebUIFragment::raw(">"));
14204 entry.push(WebUIFragment::component(*host));
14205 entry.push(WebUIFragment::raw(format!("</{host}>")));
14206 entry.push(WebUIFragment::boundary_end(sequence as u32));
14207 }
14208 entry.push(structural_fragment("body_end"));
14209 entry.push(WebUIFragment::raw("</body></html>"));
14210 fragments.insert(
14211 "index.html".to_string(),
14212 FragmentList {
14213 fragments: entry,
14214 contains_boundary: true,
14215 },
14216 );
14217 fragments.insert(
14218 "comp-a".to_string(),
14219 FragmentList {
14220 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14221 contains_boundary: false,
14222 },
14223 );
14224 fragments.insert(
14225 "comp-b".to_string(),
14226 FragmentList {
14227 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14228 contains_boundary: false,
14229 },
14230 );
14231
14232 let mut document = WebUIProtocol::new(fragments);
14233 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14234 document.components.insert(
14235 "comp-a".to_string(),
14236 webui_protocol::ComponentData {
14237 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14238 hydration_mode: StateProjectionMode::Keys as i32,
14239 hydration_keys: vec!["a_count".to_string()],
14240 ..Default::default()
14241 },
14242 );
14243 document.components.insert(
14244 "comp-b".to_string(),
14245 webui_protocol::ComponentData {
14246 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14247 hydration_mode: StateProjectionMode::Keys as i32,
14248 hydration_keys: vec!["b_count".to_string()],
14249 ..Default::default()
14250 },
14251 );
14252 if include_styles {
14253 document.set_css_strategy(webui_protocol::CssStrategy::Style);
14254 document
14255 .components
14256 .get_mut("comp-a")
14257 .expect("comp-a component")
14258 .css = ".comp-a{color:red}".to_string();
14259 document
14260 .components
14261 .get_mut("comp-b")
14262 .expect("comp-b component")
14263 .css = ".comp-b{color:blue}".to_string();
14264 document.populate_style_closures(&["index.html"]);
14265 }
14266 Protocol::new(document)
14267 }
14268
14269 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14270 disjoint_streaming_protocol_ext(hosts, true, false)
14271 }
14272
14273 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14274 disjoint_streaming_protocol_ext(hosts, true, true)
14275 }
14276
14277 fn streaming_root_validation_protocol(
14278 mut host_fragments: Vec<WebUIFragment>,
14279 tail: Vec<WebUIFragment>,
14280 ) -> Protocol {
14281 let mut entry = vec![
14282 WebUIFragment::raw("<html><head>"),
14283 structural_fragment("head_start"),
14284 structural_fragment("head_end"),
14285 WebUIFragment::raw("</head><body>"),
14286 structural_fragment("body_start"),
14287 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14288 ];
14289 entry.append(&mut host_fragments);
14290 entry.extend(tail);
14291 let fragments = HashMap::from([
14292 (
14293 "index.html".to_string(),
14294 FragmentList {
14295 fragments: entry,
14296 contains_boundary: true,
14297 },
14298 ),
14299 (
14300 "comp-a".to_string(),
14301 FragmentList {
14302 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14303 contains_boundary: false,
14304 },
14305 ),
14306 (
14307 "comp-b".to_string(),
14308 FragmentList {
14309 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14310 contains_boundary: false,
14311 },
14312 ),
14313 ]);
14314 Protocol::new(WebUIProtocol::new(fragments))
14315 }
14316
14317 fn completed_streaming_tail() -> Vec<WebUIFragment> {
14318 vec![
14319 WebUIFragment::raw("</comp-a>"),
14320 WebUIFragment::boundary_end(0),
14321 structural_fragment("body_end"),
14322 WebUIFragment::raw("</body></html>"),
14323 ]
14324 }
14325
14326 fn assert_streaming_root_error(
14327 protocol: &Protocol,
14328 expected_signal: &str,
14329 expected_reason: &str,
14330 ) {
14331 let mut writer = FlushTestWriter::default();
14332 let result = WebUIHandler::new().render_streaming(
14333 protocol,
14334 &test_json!({}),
14335 &RenderOptions::new("index.html", "/"),
14336 &mut writer,
14337 );
14338 match result {
14339 Err(HandlerError::StreamingBoundary(error)) => {
14340 assert_eq!(error.signal, expected_signal);
14341 assert!(
14342 error.reason.contains(expected_reason),
14343 "reason: {}",
14344 error.reason
14345 );
14346 }
14347 other => panic!("expected streaming-root rejection, got {other:?}"),
14348 }
14349 }
14350
14351 #[test]
14352 fn streaming_root_signal_injects_data_ws_inside_boundary() {
14353 let protocol = disjoint_streaming_protocol(&["comp-a"]);
14354 let handler = WebUIHandler::with_plugin(|| {
14355 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14356 });
14357 let mut writer = FlushTestWriter::default();
14358 handler
14359 .render_streaming(
14360 &protocol,
14361 &test_json!({ "a_count": 1 }),
14362 &RenderOptions::new("index.html", "/"),
14363 &mut writer,
14364 )
14365 .unwrap();
14366 assert!(
14369 writer.output.contains("<comp-a data-ws>"),
14370 "streamed host must carry data-ws: {}",
14371 writer.output
14372 );
14373 }
14374
14375 #[test]
14376 fn streaming_root_signal_preserves_ordinary_output_bytes() {
14377 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14380 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14381 let state = test_json!({ "a_count": 1, "b_count": 2 });
14382 let plugin = || {
14383 WebUIHandler::with_plugin(
14384 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14385 )
14386 };
14387 let mut with_writer = TestWriter::new();
14388 let mut without_writer = TestWriter::new();
14389 plugin()
14390 .render(
14391 &with_signal,
14392 &state,
14393 &RenderOptions::new("index.html", "/"),
14394 &mut with_writer,
14395 )
14396 .unwrap();
14397 plugin()
14398 .render(
14399 &without_signal,
14400 &state,
14401 &RenderOptions::new("index.html", "/"),
14402 &mut without_writer,
14403 )
14404 .unwrap();
14405 let dom_prefix = |content: &str| -> String {
14410 content
14411 .split_once(r#"<script type="application/json" id="webui-data""#)
14412 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14413 };
14414 assert_eq!(
14415 dom_prefix(&with_writer.get_content()),
14416 dom_prefix(&without_writer.get_content())
14417 );
14418 assert!(!with_writer.get_content().contains("data-ws"));
14419 assert!(with_writer.get_content().contains("<comp-a>"));
14420 }
14421
14422 #[test]
14423 fn streaming_component_without_root_signal_is_rejected() {
14424 let protocol = streaming_root_validation_protocol(
14425 vec![
14426 WebUIFragment::raw("<comp-a>"),
14427 WebUIFragment::component("comp-a"),
14428 ],
14429 completed_streaming_tail(),
14430 );
14431
14432 assert_streaming_root_error(
14433 &protocol,
14434 "comp-a",
14435 "has no compiler-owned streaming_root signal",
14436 );
14437 }
14438
14439 #[test]
14440 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
14441 let duplicate = streaming_root_validation_protocol(
14442 vec![
14443 WebUIFragment::raw("<comp-a"),
14444 structural_fragment("streaming_root:comp-a"),
14445 structural_fragment("streaming_root:comp-a"),
14446 WebUIFragment::raw(">"),
14447 WebUIFragment::component("comp-a"),
14448 ],
14449 completed_streaming_tail(),
14450 );
14451 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
14452
14453 let after_tag_close = streaming_root_validation_protocol(
14454 vec![
14455 WebUIFragment::raw("<comp-a>"),
14456 structural_fragment("streaming_root:comp-a"),
14457 WebUIFragment::component("comp-a"),
14458 ],
14459 completed_streaming_tail(),
14460 );
14461 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
14462
14463 let detached_from_opening = streaming_root_validation_protocol(
14464 vec![
14465 WebUIFragment::raw("not an opening tag"),
14466 structural_fragment("streaming_root:comp-a"),
14467 WebUIFragment::raw(">"),
14468 WebUIFragment::component("comp-a"),
14469 ],
14470 completed_streaming_tail(),
14471 );
14472 assert_streaming_root_error(
14473 &detached_from_opening,
14474 "comp-a",
14475 "unclosed component opening-tag close",
14476 );
14477 }
14478
14479 #[test]
14480 fn mismatched_streaming_root_signal_is_rejected() {
14481 let protocol = streaming_root_validation_protocol(
14482 vec![
14483 WebUIFragment::raw("<comp-a"),
14484 structural_fragment("streaming_root:comp-b"),
14485 WebUIFragment::raw(">"),
14486 WebUIFragment::component("comp-a"),
14487 ],
14488 completed_streaming_tail(),
14489 );
14490
14491 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
14492 }
14493
14494 #[test]
14495 fn pending_streaming_root_is_rejected_at_structural_ends() {
14496 let protocol = streaming_root_validation_protocol(
14497 vec![
14498 WebUIFragment::raw("<comp-a"),
14499 structural_fragment("streaming_root:comp-a"),
14500 WebUIFragment::raw(">"),
14501 ],
14502 vec![structural_fragment("boundary_end:0")],
14503 );
14504 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
14505 }
14506
14507 #[test]
14508 fn streaming_root_outside_boundary_is_rejected() {
14509 let fragments = HashMap::from([
14510 (
14511 "index.html".to_string(),
14512 FragmentList {
14513 fragments: vec![
14514 WebUIFragment::raw("<html><head>"),
14515 structural_fragment("head_start"),
14516 structural_fragment("head_end"),
14517 WebUIFragment::raw("</head><body>"),
14518 structural_fragment("body_start"),
14519 WebUIFragment::raw("<comp-a"),
14520 structural_fragment("streaming_root:comp-a"),
14521 WebUIFragment::raw(">"),
14522 WebUIFragment::component("comp-a"),
14523 WebUIFragment::raw("</comp-a>"),
14524 structural_fragment("body_end"),
14525 WebUIFragment::raw("</body></html>"),
14526 ],
14527 contains_boundary: false,
14528 },
14529 ),
14530 (
14531 "comp-a".to_string(),
14532 FragmentList {
14533 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14534 contains_boundary: false,
14535 },
14536 ),
14537 ]);
14538 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14539 let mut writer = FlushTestWriter::default();
14540 let result = WebUIHandler::new().render_streaming(
14541 &protocol,
14542 &test_json!({}),
14543 &RenderOptions::new("index.html", "/"),
14544 &mut writer,
14545 );
14546 match result {
14547 Err(HandlerError::StreamingBoundary(err)) => {
14548 assert_eq!(err.signal, "comp-a");
14549 assert!(
14550 err.reason
14551 .contains("no boundary or generated component span"),
14552 "reason: {}",
14553 err.reason
14554 );
14555 }
14556 other => panic!("expected outside-boundary rejection, got {other:?}"),
14557 }
14558 }
14559
14560 fn parser_route_protocol(with_boundary: bool) -> Protocol {
14561 let mut parser = HtmlParser::with_options(DomStrategy::Light);
14562 parser
14563 .component_registry_mut()
14564 .register_component(ComponentRegistration::new(
14565 "route-page",
14566 "<p>route content</p>",
14567 None,
14568 true,
14569 ))
14570 .expect("register route component");
14571 let route = r#"<route path="/" component="route-page" exact />"#;
14572 let html = if with_boundary {
14573 format!(
14574 "<html><head></head><body>\
14575 <boundary name=\"route\">{route}</boundary>\
14576 </body></html>"
14577 )
14578 } else {
14579 format!("<html><head></head><body>{route}</body></html>")
14580 };
14581 parser
14582 .parse("index.html", &html)
14583 .expect("parse route streaming fixture");
14584 let mut document = WebUIProtocol::new(parser.into_fragment_records());
14585 document.components.insert(
14586 "route-page".to_string(),
14587 webui_protocol::ComponentData::default(),
14588 );
14589 Protocol::new(document)
14590 }
14591
14592 #[test]
14593 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
14594 let protocol = parser_route_protocol(true);
14595 let mut writer = FlushTestWriter::default();
14596 WebUIHandler::new()
14597 .render_streaming(
14598 &protocol,
14599 &test_json!({}),
14600 &RenderOptions::new("index.html", "/"),
14601 &mut writer,
14602 )
14603 .unwrap();
14604
14605 assert!(
14606 writer
14607 .output
14608 .contains("<route-page data-ws><p>route content</p></route-page>"),
14609 "matched route host must be deferred before upgrade: {}",
14610 writer.output
14611 );
14612 }
14613
14614 #[test]
14615 fn streaming_parser_route_host_outside_boundary_is_rejected() {
14616 let protocol = parser_route_protocol(false);
14617 let mut writer = FlushTestWriter::default();
14618 let result = WebUIHandler::new().render_streaming(
14619 &protocol,
14620 &test_json!({}),
14621 &RenderOptions::new("index.html", "/"),
14622 &mut writer,
14623 );
14624
14625 match result {
14626 Err(HandlerError::StreamingBoundary(error)) => {
14627 assert_eq!(error.signal, "route-page");
14628 assert!(
14629 error
14630 .reason
14631 .contains("no boundary or generated component span"),
14632 "reason: {}",
14633 error.reason
14634 );
14635 }
14636 other => panic!("expected route boundary rejection, got {other:?}"),
14637 }
14638 assert!(
14639 !writer.output.contains("<route-page>"),
14640 "an unmarked custom-element opening must never be completed"
14641 );
14642 }
14643
14644 #[test]
14645 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
14646 let entry = vec![
14647 WebUIFragment::raw("<html><head>"),
14648 structural_fragment("head_start"),
14649 structural_fragment("head_end"),
14650 WebUIFragment::raw("</head><body>"),
14651 structural_fragment("body_start"),
14652 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14653 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
14654 path: "/account".into(),
14655 fragment_id: "account-shell".into(),
14656 exact: false,
14657 ..Default::default()
14658 }),
14659 WebUIFragment::boundary_end(0),
14660 structural_fragment("body_end"),
14661 WebUIFragment::raw("</body></html>"),
14662 ];
14663 let fragments = HashMap::from([
14664 (
14665 "index.html".to_string(),
14666 FragmentList {
14667 fragments: entry,
14668 contains_boundary: true,
14669 },
14670 ),
14671 (
14672 "account-shell".to_string(),
14673 FragmentList {
14674 fragments: vec![WebUIFragment::route_from(
14675 webui_protocol::WebUiFragmentRoute {
14676 path: "./details".into(),
14677 fragment_id: "details-page".into(),
14678 exact: true,
14679 pending_component: "details-loading".into(),
14680 error_component: "details-error".into(),
14681 ..Default::default()
14682 },
14683 )],
14684 contains_boundary: false,
14685 },
14686 ),
14687 (
14688 "details-page".to_string(),
14689 FragmentList {
14690 fragments: vec![WebUIFragment::raw("<p>details</p>")],
14691 contains_boundary: false,
14692 },
14693 ),
14694 (
14695 "details-loading".to_string(),
14696 FragmentList {
14697 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
14698 contains_boundary: false,
14699 },
14700 ),
14701 (
14702 "details-error".to_string(),
14703 FragmentList {
14704 fragments: vec![WebUIFragment::raw("<p>error</p>")],
14705 contains_boundary: false,
14706 },
14707 ),
14708 ]);
14709 let mut document = WebUIProtocol::new(fragments);
14710 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14711 for name in [
14712 "account-shell",
14713 "details-page",
14714 "details-loading",
14715 "details-error",
14716 ] {
14717 document.components.insert(
14718 name.to_string(),
14719 webui_protocol::ComponentData {
14720 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14721 ..Default::default()
14722 },
14723 );
14724 }
14725 let protocol = Protocol::new(document);
14726 let handler = WebUIHandler::with_plugin(|| {
14727 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14728 });
14729 let mut writer = FlushTestWriter::default();
14730
14731 handler
14732 .render_streaming(
14733 &protocol,
14734 &test_json!({}),
14735 &RenderOptions::new("index.html", "/account/details"),
14736 &mut writer,
14737 )
14738 .unwrap();
14739
14740 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
14741 assert!(
14742 first.contains(r#""details-loading":"#),
14743 "relative-route pending metadata missing: {first}"
14744 );
14745 assert!(
14746 first.contains(r#""details-error":"#),
14747 "relative-route error metadata missing: {first}"
14748 );
14749 }
14750
14751 #[test]
14752 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
14753 let entry = vec![
14754 WebUIFragment::raw("<html><head>"),
14755 structural_fragment("head_start"),
14756 structural_fragment("head_end"),
14757 WebUIFragment::raw("</head><body>"),
14758 structural_fragment("body_start"),
14759 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14760 WebUIFragment::raw("<static-shell"),
14761 structural_fragment("streaming_root:static-shell"),
14762 WebUIFragment::raw(">"),
14763 WebUIFragment::component("static-shell"),
14764 WebUIFragment::raw("</static-shell>"),
14765 WebUIFragment::raw("<route-shell"),
14766 structural_fragment("streaming_root:route-shell"),
14767 WebUIFragment::raw(">"),
14768 WebUIFragment::component("route-shell"),
14769 WebUIFragment::raw("</route-shell>"),
14770 WebUIFragment::boundary_end(0),
14771 structural_fragment("body_end"),
14772 WebUIFragment::raw("</body></html>"),
14773 ];
14774 let fragments = HashMap::from([
14775 (
14776 "index.html".to_string(),
14777 FragmentList {
14778 fragments: entry,
14779 contains_boundary: true,
14780 },
14781 ),
14782 (
14783 "static-shell".to_string(),
14784 FragmentList {
14785 fragments: vec![WebUIFragment::if_cond(
14786 ConditionExpr::identifier("show_hidden"),
14787 "static-hidden-if",
14788 )],
14789 contains_boundary: false,
14790 },
14791 ),
14792 (
14793 "static-hidden-if".to_string(),
14794 FragmentList {
14795 fragments: vec![WebUIFragment::component("static-hidden")],
14796 contains_boundary: false,
14797 },
14798 ),
14799 (
14800 "static-hidden".to_string(),
14801 FragmentList {
14802 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
14803 contains_boundary: false,
14804 },
14805 ),
14806 (
14807 "route-shell".to_string(),
14808 FragmentList {
14809 fragments: vec![WebUIFragment::route_from(
14810 webui_protocol::WebUiFragmentRoute {
14811 path: "/account".into(),
14812 fragment_id: "route-page".into(),
14813 exact: true,
14814 ..Default::default()
14815 },
14816 )],
14817 contains_boundary: false,
14818 },
14819 ),
14820 (
14821 "route-page".to_string(),
14822 FragmentList {
14823 fragments: vec![WebUIFragment::raw("<p>route</p>")],
14824 contains_boundary: false,
14825 },
14826 ),
14827 ]);
14828 let mut document = WebUIProtocol::new(fragments);
14829 document.set_css_strategy(webui_protocol::CssStrategy::Link);
14830 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14831 for (name, keys) in [
14832 ("static-shell", vec!["show_hidden", "static_count"]),
14833 ("static-hidden", vec!["hidden_count"]),
14834 ("route-shell", vec!["route_shell_count"]),
14835 ("route-page", vec!["route_count"]),
14836 ] {
14837 document.components.insert(
14838 name.to_string(),
14839 webui_protocol::ComponentData {
14840 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
14841 hydration_mode: StateProjectionMode::Keys as i32,
14842 hydration_keys: keys.into_iter().map(str::to_string).collect(),
14843 css_href: format!("/{name}.css"),
14844 ..Default::default()
14845 },
14846 );
14847 }
14848 document.populate_style_closures(&["index.html"]);
14849 let protocol = Protocol::new(document);
14850 assert_eq!(
14851 protocol
14852 .component_reachability()
14853 .is_route_dependent(protocol.component_index()["route-shell"]),
14854 Some(true),
14855 "fixture must exercise request-aware checkpoint reachability"
14856 );
14857 let handler = WebUIHandler::with_plugin(|| {
14858 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14859 });
14860 let mut writer = FlushTestWriter::default();
14861
14862 handler
14863 .render_streaming(
14864 &protocol,
14865 &test_json!({
14866 "show_hidden": false,
14867 "static_count": 1,
14868 "hidden_count": 2,
14869 "route_shell_count": 3,
14870 "route_count": 4,
14871 "unrelated": "private",
14872 }),
14873 &RenderOptions::new("index.html", "/account"),
14874 &mut writer,
14875 )
14876 .unwrap();
14877
14878 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
14879 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14880 assert!(
14881 checkpoint.contains(&format!(r#""{template}":"#)),
14882 "{template} metadata missing: {checkpoint}"
14883 );
14884 }
14885 for state in [
14886 r#""show_hidden":false"#,
14887 r#""static_count":1"#,
14888 r#""hidden_count":2"#,
14889 r#""route_shell_count":3"#,
14890 r#""route_count":4"#,
14891 ] {
14892 assert!(
14893 checkpoint.contains(state),
14894 "{state} state missing: {checkpoint}"
14895 );
14896 }
14897 assert!(
14898 !checkpoint.contains("unrelated"),
14899 "checkpoint leaked unrelated state: {checkpoint}"
14900 );
14901 assert!(
14902 checkpoint.contains(
14903 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
14904 ),
14905 "checkpoint metadata lost source order: {checkpoint}"
14906 );
14907 assert!(
14908 checkpoint.contains(r#""componentStyles":"#)
14909 && checkpoint.contains(r#""strategy":"link""#)
14910 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
14911 "checkpoint is missing tree-local style metadata: {checkpoint}"
14912 );
14913 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
14914 assert_eq!(
14915 writer
14916 .output
14917 .matches(&format!(r#"data-webui-resource="{resource}""#))
14918 .count(),
14919 1,
14920 "Document delivery state must persist across streamed checkpoints"
14921 );
14922 }
14923 }
14924
14925 #[test]
14926 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
14927 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
14932 let handler = WebUIHandler::with_plugin(|| {
14933 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14934 });
14935 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
14936 let mut writer = FlushTestWriter::default();
14937 handler
14938 .render_streaming(
14939 &protocol,
14940 &state,
14941 &RenderOptions::new("index.html", "/"),
14942 &mut writer,
14943 )
14944 .unwrap();
14945
14946 assert_eq!(
14947 writer.flushes.len(),
14948 5,
14949 "bootstrap, three boundary commits, and one terminal"
14950 );
14951 let segment = |index: usize| -> &str {
14952 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
14953 };
14954 let b0 = segment(0);
14955 let b1 = segment(1);
14956 let b2 = segment(2);
14957
14958 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
14960 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
14961 assert!(
14962 b0.contains(r#"[2,0,0,0,{"declarationId":0,"componentStyles":"#),
14963 "b0: {b0}"
14964 );
14965 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
14966 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
14967 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
14968
14969 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
14971 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
14972 assert!(
14973 b1.contains(r#"[2,1,0,1,{"declarationId":1,"componentStyles":"#),
14974 "b1: {b1}"
14975 );
14976 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
14977 assert!(
14978 !b1.contains(r#""templates":{"comp-a"#),
14979 "b1 re-sent comp-a: {b1}"
14980 );
14981 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
14982
14983 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
14985 assert!(
14986 b2.contains(r#"[2,2,0,2,{"declarationId":2,"componentStyles":"#),
14987 "b2: {b2}"
14988 );
14989 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
14990 assert!(
14991 !b2.contains(r#""templates""#),
14992 "b2 re-sent a template: {b2}"
14993 );
14994
14995 assert!(!writer.output.contains("serverOnly"));
14997 }
14998
14999 #[test]
15000 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15001 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15002 let handler = WebUIHandler::with_plugin(|| {
15003 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15004 });
15005 let mut writer = FlushTestWriter::default();
15006 handler
15007 .render_streaming(
15008 &protocol,
15009 &test_json!({ "a_count": 1, "b_count": 2 }),
15010 &RenderOptions::new("index.html", "/"),
15011 &mut writer,
15012 )
15013 .unwrap();
15014
15015 let segment = |index: usize| -> &str {
15016 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15017 };
15018 let checkpoint_styles = |checkpoint: &str| -> Value {
15019 let script = checkpoint
15020 .rfind(r#"<script type="application/json" data-webui-boundary"#)
15021 .unwrap();
15022 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15023 let payload_end =
15024 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15025 let record: Value =
15026 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15027 record[4]["componentStyles"].clone()
15028 };
15029
15030 let first = checkpoint_styles(segment(0));
15031 assert!(first["resources"]["comp-a"].is_object());
15032 assert!(first["resources"]["comp-b"].is_object());
15033 assert!(first["closures"]["index.html"].is_array());
15034 assert!(first["closures"]["comp-a"].is_array());
15035
15036 let second = checkpoint_styles(segment(1));
15037 assert_eq!(second["resources"], test_json!({}));
15038 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15039
15040 let repeated = checkpoint_styles(segment(2));
15041 assert_eq!(repeated["resources"], test_json!({}));
15042 assert_eq!(repeated["closures"], test_json!({}));
15043 for resource in ["comp-a", "comp-b"] {
15044 assert_eq!(
15045 [&first, &second, &repeated]
15046 .iter()
15047 .filter(|styles| {
15048 styles["resources"]
15049 .as_object()
15050 .is_some_and(|resources| resources.contains_key(resource))
15051 })
15052 .count(),
15053 1,
15054 "{resource} style metadata was serialized more than once"
15055 );
15056 }
15057 }
15058
15059 #[test]
15060 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15061 let entry = vec![
15062 WebUIFragment::raw("<html><head>"),
15063 structural_fragment("head_start"),
15064 structural_fragment("head_end"),
15065 WebUIFragment::raw("</head><body>"),
15066 structural_fragment("body_start"),
15067 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15068 WebUIFragment::raw("<comp-a"),
15069 structural_fragment("streaming_root:comp-a"),
15070 WebUIFragment::raw(">"),
15071 WebUIFragment::component("comp-a"),
15072 WebUIFragment::raw("</comp-a>"),
15073 WebUIFragment::boundary_end(0),
15074 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15075 WebUIFragment::raw("<comp-hidden"),
15076 structural_fragment("streaming_root:comp-hidden"),
15077 WebUIFragment::raw(">"),
15078 WebUIFragment::component("comp-hidden"),
15079 WebUIFragment::raw("</comp-hidden>"),
15080 WebUIFragment::boundary_end(1),
15081 structural_fragment("body_end"),
15082 WebUIFragment::raw("</body></html>"),
15083 ];
15084 let fragments = HashMap::from([
15085 (
15086 "index.html".to_string(),
15087 FragmentList {
15088 fragments: entry,
15089 contains_boundary: true,
15090 },
15091 ),
15092 (
15093 "comp-a".to_string(),
15094 FragmentList {
15095 fragments: vec![WebUIFragment::if_cond(
15096 ConditionExpr::identifier("show_hidden"),
15097 "hidden-if",
15098 )],
15099 contains_boundary: false,
15100 },
15101 ),
15102 (
15103 "hidden-if".to_string(),
15104 FragmentList {
15105 fragments: vec![WebUIFragment::component("comp-hidden")],
15106 contains_boundary: false,
15107 },
15108 ),
15109 (
15110 "comp-hidden".to_string(),
15111 FragmentList {
15112 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15113 contains_boundary: false,
15114 },
15115 ),
15116 ]);
15117 let mut document = WebUIProtocol::new(fragments);
15118 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15119 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15120 document.components.insert(
15121 "comp-a".to_string(),
15122 webui_protocol::ComponentData {
15123 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15124 hydration_mode: StateProjectionMode::Keys as i32,
15125 hydration_keys: vec!["show_hidden".to_string()],
15126 ..Default::default()
15127 },
15128 );
15129 document.components.insert(
15130 "comp-hidden".to_string(),
15131 webui_protocol::ComponentData {
15132 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15133 css: ".hidden{display:block}".to_string(),
15134 hydration_mode: StateProjectionMode::Keys as i32,
15135 hydration_keys: vec!["hidden_count".to_string()],
15136 ..Default::default()
15137 },
15138 );
15139 document.populate_style_closures(&["index.html"]);
15140 let protocol = Protocol::new(document);
15141 let handler = WebUIHandler::with_plugin(|| {
15142 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15143 });
15144 let mut writer = FlushTestWriter::default();
15145
15146 handler
15147 .render_streaming(
15148 &protocol,
15149 &test_json!({
15150 "show_hidden": false,
15151 "hidden_count": 7,
15152 "unrelated": "private",
15153 }),
15154 &RenderOptions::new("index.html", "/"),
15155 &mut writer,
15156 )
15157 .unwrap();
15158
15159 let boundary = |index: usize| -> &str {
15160 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15161 };
15162 let first = boundary(0);
15163 let second = boundary(1);
15164
15165 assert!(first.contains(r#""comp-a":"#), "first: {first}");
15166 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15167 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15168 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15169 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15170 assert!(
15171 first.contains(r#"<script type="importmap""#),
15172 "first: {first}"
15173 );
15174 assert!(
15175 !first.contains("unrelated"),
15176 "first leaked unrelated state: {first}"
15177 );
15178
15179 assert!(
15180 !second.contains(r#""templates""#),
15181 "second re-sent metadata: {second}"
15182 );
15183 assert!(
15184 !second.contains(r#"<script type="importmap""#),
15185 "second re-sent CSS: {second}"
15186 );
15187 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15188 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15189 assert_eq!(
15190 writer
15191 .output
15192 .matches(r#"<script type="importmap">"#)
15193 .count(),
15194 1,
15195 "import-map output: {}",
15196 writer.output
15197 );
15198 }
15199
15200 #[test]
15201 fn streaming_flush_count_is_boundaries_plus_one() {
15202 for &count in &[1usize, 3, 10, 100] {
15203 let hosts: Vec<&str> = (0..count)
15204 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15205 .collect();
15206 let protocol = disjoint_streaming_protocol(&hosts);
15207 let handler = WebUIHandler::with_plugin(|| {
15208 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15209 });
15210 let mut writer = FlushTestWriter::default();
15211 handler
15212 .render_streaming(
15213 &protocol,
15214 &test_json!({ "a_count": 1, "b_count": 2 }),
15215 &RenderOptions::new("index.html", "/"),
15216 &mut writer,
15217 )
15218 .unwrap();
15219 assert_eq!(
15220 writer.flushes.len(),
15221 count + 2,
15222 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15223 );
15224 }
15225 }
15226
15227 #[test]
15228 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15229 let mut fragments = HashMap::new();
15230 fragments.insert("comp-a".to_string(), FragmentList::default());
15231 fragments.insert("comp-b".to_string(), FragmentList::default());
15232 let mut protocol = WebUIProtocol::new(fragments);
15233 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15234 protocol.components.insert(
15235 "comp-a".to_string(),
15236 webui_protocol::ComponentData {
15237 hydration_mode: StateProjectionMode::Keys as i32,
15238 hydration_keys: vec!["a".to_string(), "shared".to_string()],
15239 ..Default::default()
15240 },
15241 );
15242 protocol.components.insert(
15243 "comp-b".to_string(),
15244 webui_protocol::ComponentData {
15245 hydration_mode: StateProjectionMode::Keys as i32,
15246 hydration_keys: vec!["b".to_string(), "shared".to_string()],
15247 ..Default::default()
15248 },
15249 );
15250
15251 let protocol = Protocol::new(protocol);
15252 let component_index = protocol.component_index();
15253 let reachability = protocol.component_reachability();
15254 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15255 assert!(!collect_hydration_key_ids_into(
15256 protocol.protocol(),
15257 reachability,
15258 [component_index["comp-a"]],
15259 &mut scratch,
15260 ));
15261 let keys = scratch
15262 .iter()
15263 .filter_map(|id| reachability.hydration_key(*id))
15264 .collect::<Vec<_>>();
15265 assert_eq!(keys, ["a", "shared"]);
15266 let pointer = scratch.as_ptr();
15267 let capacity = scratch.capacity();
15268
15269 assert!(!collect_hydration_key_ids_into(
15270 protocol.protocol(),
15271 reachability,
15272 [component_index["comp-b"]],
15273 &mut scratch,
15274 ));
15275 let keys = scratch
15276 .iter()
15277 .filter_map(|id| reachability.hydration_key(*id))
15278 .collect::<Vec<_>>();
15279 assert_eq!(keys, ["b", "shared"]);
15280 assert_eq!(scratch.as_ptr(), pointer);
15281 assert_eq!(scratch.capacity(), capacity);
15282 }
15283}