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::fmt::{self, Write as _};
48use std::sync::Arc;
49use streaming::{
50 consume_streaming_component_root, ensure_no_pending_streaming_root,
51 prepare_generated_streaming_root, record_checkpoint_tag, streaming_template_already_sent,
52 validate_pending_streaming_root, validate_streaming_root_opening, ComponentHostOrigin,
53 StreamingRenderState,
54};
55pub use streaming::{
56 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, BufferSink, SessionOptions,
57 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
58 MAX_BOUNDARY_OCCURRENCES, MAX_CONTINUATION_DEPTH, MAX_KEYED_INSTANCES, MAX_SPAN_NESTING,
59};
60use thiserror::Error;
61use webui_expressions::{evaluate_with_resolver, ExpressionError};
62use webui_protocol::{
63 web_ui_fragment::Fragment, ComponentAssetStylePreload, ComponentWorkPolicy, FragmentList,
64 InitialStateStrategy, StateProjectionMode, WebUIFragment, WebUIProtocol,
65};
66use webui_state::find_value_by_dotted_path_ref;
67
68#[derive(Debug, Error)]
70pub enum HandlerError {
71 #[error("Rendering error: {0}")]
72 Rendering(String),
73
74 #[error("invalid state JSON: {0}")]
80 InvalidState(String),
81
82 #[error("Rendering invariant error: {0}")]
83 Invariant(String),
84
85 #[error("Missing fragment: {0}")]
86 MissingFragment(String),
87
88 #[error("Missing data field: {0}")]
89 MissingData(String),
90
91 #[error("Type error: {0}")]
92 TypeError(String),
93
94 #[error("Protocol error: {0}")]
95 Protocol(#[from] webui_protocol::ProtocolError),
96
97 #[error("Evaluation error: {0}")]
98 Evaluation(String),
99
100 #[error("I/O error: {0}")]
101 Io(#[from] std::io::Error),
102
103 #[error("Writer error: {0}")]
104 Writer(String),
105
106 #[error("Plugin data error: {0}")]
107 PluginData(String),
108
109 #[error("client disconnected")]
116 ClientDisconnected,
117
118 #[error("streaming flush timed out")]
125 StreamTimeout,
126
127 #[error("invalid streaming boundary signal `{}`: {}", .0.signal, .0.reason)]
134 StreamingBoundary(Box<StreamingBoundaryError>),
135
136 #[error("streaming render ended before `body_end`; no terminal boundary record was emitted")]
138 MissingStreamingBodyEnd,
139
140 #[error(
142 "streaming protocol is missing the required `head_start` signal before `{before}`; \
143 rebuild the protocol with streaming-boundary parser support"
144 )]
145 MissingStreamingHeadStart {
146 before: &'static str,
148 },
149
150 #[error("streaming protocol emitted duplicate `head_start` signals")]
152 DuplicateStreamingHeadStart,
153}
154
155#[derive(Debug)]
162pub struct StreamingBoundaryError {
163 pub signal: String,
165 pub reason: String,
167}
168
169pub type Result<T> = std::result::Result<T, HandlerError>;
170
171#[cold]
172#[inline(never)]
173fn invalid_fragment_range_error(
174 range: &std::ops::Range<usize>,
175 fragment_count: usize,
176) -> HandlerError {
177 HandlerError::Invariant(format!(
178 "fragment range {}..{} exceeds fragment count {fragment_count}",
179 range.start, range.end
180 ))
181}
182
183#[cold]
184#[inline(never)]
185fn route_style_plan_missing_error() -> HandlerError {
186 HandlerError::Invariant(
187 "matched route style targets were computed without a route chain".to_string(),
188 )
189}
190
191#[cold]
192#[inline(never)]
193fn route_style_plan_length_error(routes: usize, targets: usize) -> HandlerError {
194 HandlerError::Invariant(format!(
195 "matched route style target count {targets} does not match route count {routes}"
196 ))
197}
198
199pub trait ResponseWriter {
201 fn write(&mut self, content: &str) -> Result<()>;
203
204 #[doc(hidden)]
206 fn write_attribute(&mut self, name: &str, value: &str) -> Result<()> {
207 self.write(" ")?;
208 self.write(name)?;
209 self.write("=\"")?;
210 self.write(value)?;
211 self.write("\"")
212 }
213
214 #[doc(hidden)]
216 fn write_boolean_attribute(&mut self, name: &str) -> Result<()> {
217 self.write(" ")?;
218 self.write(name)
219 }
220
221 fn end(&mut self) -> Result<()>;
223
224 #[doc(hidden)]
232 fn stream_flush(&mut self) -> Result<()> {
233 Ok(())
234 }
235
236 #[doc(hidden)]
237 fn stream_begin_component(&mut self) -> Result<()> {
238 Err(HandlerError::Invariant(
239 "component opening buffering requires a streaming sink".to_string(),
240 ))
241 }
242
243 #[doc(hidden)]
244 fn stream_mark_component_root(&mut self) -> Result<()> {
245 Err(HandlerError::Invariant(
246 "component root buffering requires a streaming sink".to_string(),
247 ))
248 }
249
250 #[doc(hidden)]
251 fn stream_commit_component(
252 &mut self,
253 span_id: Option<u32>,
254 enclosing_span_id: Option<u32>,
255 deferred: bool,
256 ) -> Result<()> {
257 let _ = (span_id, enclosing_span_id, deferred);
258 Err(HandlerError::Invariant(
259 "component opening commit requires a streaming sink".to_string(),
260 ))
261 }
262}
263
264struct StateWithoutSelectedKeys<'a> {
270 value: &'a Value,
271 keys: KeyView<'a>,
272}
273
274impl Serialize for StateWithoutSelectedKeys<'_> {
275 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
276 where
277 S: serde::Serializer,
278 {
279 let Value::Object(map) = self.value else {
280 return serializer.serialize_map(Some(0))?.end();
281 };
282 let mut out = serializer.serialize_map(None)?;
283 for (key, value) in map {
284 if key != STATE_INJECT_KEY && !self.keys.contains(key) {
285 out.serialize_entry(key, value)?;
286 }
287 }
288 out.end()
289 }
290}
291
292pub trait FlushWriter: ResponseWriter {
297 fn flush(&mut self) -> Result<()>;
299}
300
301pub struct RenderOptions<'a> {
306 pub entry_id: &'a str,
308 pub request_path: &'a str,
310 pub nonce: Option<&'a str>,
314 pub head_inject: Option<&'a str>,
322 pub body_inject: Option<&'a str>,
327}
328
329pub const STATE_INJECT_KEY: &str = "$webui";
345
346#[derive(Clone, Copy, Default)]
353pub(crate) struct StateInject<'state> {
354 pub(crate) head_end: Option<&'state str>,
355 pub(crate) body_start: Option<&'state str>,
356 pub(crate) body_end: Option<&'state str>,
357}
358
359impl<'state> StateInject<'state> {
360 pub(crate) fn resolve(state: &'state Value) -> Self {
367 let Some(Value::Object(map)) = state.get(STATE_INJECT_KEY) else {
368 return Self::default();
369 };
370 let field = |name: &str| {
371 map.get(name)
372 .and_then(Value::as_str)
373 .filter(|html| !html.is_empty())
374 };
375 Self {
376 head_end: field("headEnd"),
377 body_start: field("bodyStart"),
378 body_end: field("bodyEnd"),
379 }
380 }
381}
382
383impl<'a> RenderOptions<'a> {
384 #[must_use]
386 pub fn new(entry_id: &'a str, request_path: &'a str) -> Self {
387 Self {
388 entry_id,
389 request_path,
390 nonce: None,
391 head_inject: None,
392 body_inject: None,
393 }
394 }
395
396 #[must_use]
400 pub fn with_nonce(mut self, nonce: &'a str) -> Self {
401 self.nonce = if nonce.is_empty() { None } else { Some(nonce) };
402 self
403 }
404
405 #[must_use]
420 pub fn with_head_inject(mut self, html: &'a str) -> Self {
421 self.head_inject = if html.is_empty() { None } else { Some(html) };
422 self
423 }
424
425 #[must_use]
435 pub fn with_body_inject(mut self, html: &'a str) -> Self {
436 self.body_inject = if html.is_empty() { None } else { Some(html) };
437 self
438 }
439}
440
441pub struct WebUIHandler {
446 plugin_factory: Option<fn() -> Box<dyn HandlerPlugin>>,
447}
448
449#[derive(Clone, Copy, Eq, PartialEq)]
450enum StyleClosureInstall {
451 Static,
452 Routed,
453}
454
455pub(crate) struct ShadowStyleRoot {
456 component_index: u32,
457 static_closure_emitted: bool,
458 routed_resources: Vec<u32>,
459}
460
461struct LoopBinding<'protocol, 'state> {
462 name: &'protocol str,
463 value: &'state Value,
464}
465
466#[derive(Clone, Copy)]
467struct VisibleLoopScope {
468 start: usize,
469 end: usize,
470}
471
472impl VisibleLoopScope {
473 const EMPTY: Self = Self { start: 0, end: 0 };
474}
475
476#[derive(Clone, Copy)]
477struct LocalValueSources<'ctx, 'protocol, 'state> {
478 owned: &'ctx HashMap<String, Value>,
479 borrowed: &'ctx BorrowedScope<'protocol, 'state>,
480}
481
482pub(crate) type RouteChildren<'protocol> = Cow<'protocol, [webui_protocol::WebUiFragmentRoute]>;
484
485#[derive(Default)]
486struct BorrowedScope<'protocol, 'state> {
487 inline: [Option<(&'protocol str, &'state Value)>; INLINE_SCOPE_SLOTS],
488 inline_len: usize,
489 overflow: Vec<(&'protocol str, &'state Value)>,
490}
491
492impl<'protocol, 'state> BorrowedScope<'protocol, 'state> {
493 fn get(&self, name: &str) -> Option<&'state Value> {
494 if self.inline_len == 0 {
495 return None;
496 }
497 if let Some((entry_name, value)) = self.inline[0].as_ref() {
498 if *entry_name == name {
499 return Some(*value);
500 }
501 }
502 for (entry_name, value) in self.inline[1..self.inline_len].iter().flatten() {
503 if *entry_name == name {
504 return Some(*value);
505 }
506 }
507 self.overflow
508 .iter()
509 .find_map(|(entry_name, value)| (*entry_name == name).then_some(*value))
510 }
511
512 fn insert(&mut self, name: &'protocol str, value: &'state Value) -> Option<&'state Value> {
513 for (entry_name, current) in self.inline[..self.inline_len].iter_mut().flatten() {
514 if *entry_name == name {
515 return Some(std::mem::replace(current, value));
516 }
517 }
518 for (entry_name, current) in &mut self.overflow {
519 if *entry_name == name {
520 return Some(std::mem::replace(current, value));
521 }
522 }
523 if self.inline_len < INLINE_SCOPE_SLOTS {
524 self.inline[self.inline_len] = Some((name, value));
525 self.inline_len += 1;
526 } else {
527 self.overflow.push((name, value));
528 }
529 None
530 }
531
532 fn remove(&mut self, name: &str) -> Option<&'state Value> {
533 if let Some(index) = self.inline[..self.inline_len].iter().position(|entry| {
534 entry
535 .as_ref()
536 .is_some_and(|(entry_name, _)| *entry_name == name)
537 }) {
538 let removed = self.inline[index].take().map(|(_, value)| value);
539 if let Some(entry) = self.overflow.pop() {
540 self.inline[index] = Some(entry);
541 } else {
542 self.inline_len -= 1;
543 self.inline[index] = self.inline[self.inline_len].take();
544 }
545 return removed;
546 }
547 let index = self
548 .overflow
549 .iter()
550 .position(|(entry_name, _)| *entry_name == name)?;
551 Some(self.overflow.swap_remove(index).1)
552 }
553
554 fn clear(&mut self) {
555 for entry in &mut self.inline[..self.inline_len] {
556 *entry = None;
557 }
558 self.inline_len = 0;
559 self.overflow.clear();
560 }
561
562 fn clone_into_owned(&self, target: &mut HashMap<String, Value>) {
563 for (name, value) in self.inline[..self.inline_len].iter().flatten() {
564 target.insert((*name).to_owned(), (*value).clone());
565 }
566 for (name, value) in &self.overflow {
567 target.insert((*name).to_owned(), (*value).clone());
568 }
569 }
570}
571
572#[derive(Clone, Copy)]
578pub(crate) struct RenderFragmentList<'protocol> {
579 fragments: &'protocol [WebUIFragment],
580 metadata: &'protocol [RenderFragmentMetadata],
581 attr_names: &'protocol str,
582 contains_boundary: bool,
583 has_routes: bool,
586}
587
588impl<'protocol> RenderFragmentList<'protocol> {
589 fn target(self, index: usize) -> Option<usize> {
591 let target = self.metadata.get(index)?.target;
592 (target != NO_RENDER_SLOT).then_some(target as usize)
593 }
594
595 fn component_attr_name(self, index: usize) -> Option<&'protocol str> {
597 let prepared = self.metadata.get(index)?;
598 if prepared.attr_start == NO_ATTR_NAME {
599 return None;
600 }
601 let start = prepared.attr_start as usize;
602 self.attr_names
603 .get(start..start + prepared.attr_len as usize)
604 }
605}
606
607const NO_RENDER_SLOT: u32 = u32::MAX;
609const NO_ATTR_NAME: u32 = u32::MAX;
611
612#[derive(Clone, Copy)]
617struct RenderFragmentMetadata {
618 target: u32,
622 attr_start: u32,
624 attr_len: u32,
625}
626
627pub(crate) struct RenderFragmentIndex {
634 ids: Box<[Arc<str>]>,
635 metadata: Box<[RenderFragmentMetadata]>,
636 ranges: Box<[u32]>,
638 attr_names: Box<str>,
640 route_presence: Box<[u64]>,
642}
643
644pub(crate) struct ResolvedRenderFragmentIndex<'protocol> {
649 cache: [Cell<Option<&'protocol FragmentList>>; INLINE_RENDER_FRAGMENT_LISTS],
650 spilled: OnceCell<Box<[Cell<Option<&'protocol FragmentList>>]>>,
654 index: &'protocol RenderFragmentIndex,
655 protocol: &'protocol WebUIProtocol,
656}
657
658impl RenderFragmentIndex {
659 pub(crate) fn new(
666 protocol: &WebUIProtocol,
667 ids: &[Arc<str>],
668 slots: &HashMap<Arc<str>, u32>,
669 ) -> Self {
670 let total_fragments = protocol
671 .fragments
672 .values()
673 .map(|list| list.fragments.len())
674 .sum();
675 let mut metadata = Vec::with_capacity(total_fragments);
676 let mut ranges = Vec::with_capacity(ids.len() + 1);
677 let mut attr_names = String::with_capacity(total_fragments * 8);
680 let mut route_presence = vec![0u64; ids.len().div_ceil(64)];
681
682 for (slot, id) in ids.iter().enumerate() {
683 #[allow(clippy::cast_possible_truncation)]
685 ranges.push(metadata.len() as u32);
686 let Some(list) = protocol.fragments.get(id.as_ref()) else {
687 continue;
688 };
689 let mut has_routes = false;
690 for fragment in &list.fragments {
691 let inner = fragment.fragment.as_ref();
692 if matches!(inner, Some(Fragment::Route(_))) {
693 has_routes = true;
694 }
695 let target = fragment_target_id(fragment)
696 .and_then(|target| slots.get(target).copied())
697 .unwrap_or(NO_RENDER_SLOT);
698 let (attr_start, attr_len) = match inner {
699 Some(Fragment::Attribute(attribute)) if !attribute.attr_skip => {
703 #[allow(clippy::cast_possible_truncation)]
704 let start = attr_names.len() as u32;
705 let before = attr_names.len();
706 push_component_attr_name(&mut attr_names, component_attr_source(attribute));
707 #[allow(clippy::cast_possible_truncation)]
708 let len = (attr_names.len() - before) as u32;
709 (start, len)
710 }
711 _ => (NO_ATTR_NAME, 0),
712 };
713 metadata.push(RenderFragmentMetadata {
714 target,
715 attr_start,
716 attr_len,
717 });
718 }
719 if has_routes {
720 route_presence[slot / 64] |= 1u64 << (slot % 64);
721 }
722 }
723 #[allow(clippy::cast_possible_truncation)]
724 ranges.push(metadata.len() as u32);
725
726 Self {
727 ids: ids.to_vec().into_boxed_slice(),
728 metadata: metadata.into_boxed_slice(),
729 ranges: ranges.into_boxed_slice(),
730 attr_names: attr_names.into_boxed_str(),
731 route_presence: route_presence.into_boxed_slice(),
732 }
733 }
734
735 pub(crate) fn index(&self, id: &str) -> Option<usize> {
737 self.ids
738 .binary_search_by(|candidate| candidate.as_ref().cmp(id))
739 .ok()
740 }
741
742 pub(crate) fn resolve<'protocol>(
743 &'protocol self,
744 protocol: &'protocol WebUIProtocol,
745 ) -> ResolvedRenderFragmentIndex<'protocol> {
746 ResolvedRenderFragmentIndex {
747 cache: std::array::from_fn(|_| Cell::new(None)),
748 spilled: OnceCell::new(),
749 index: self,
750 protocol,
751 }
752 }
753}
754
755impl<'protocol> ResolvedRenderFragmentIndex<'protocol> {
756 pub(crate) fn index(&self, id: &str) -> Option<usize> {
757 self.index.index(id)
758 }
759
760 fn slot(&self, index: usize) -> Option<&Cell<Option<&'protocol FragmentList>>> {
762 if index < INLINE_RENDER_FRAGMENT_LISTS {
763 return self.cache.get(index);
764 }
765 let spilled = self.spilled.get_or_init(|| {
766 let overflow = self
767 .index
768 .ids
769 .len()
770 .saturating_sub(INLINE_RENDER_FRAGMENT_LISTS);
771 (0..overflow).map(|_| Cell::new(None)).collect()
772 });
773 spilled.get(index - INLINE_RENDER_FRAGMENT_LISTS)
774 }
775
776 pub(crate) fn list(&self, index: usize) -> Option<RenderFragmentList<'protocol>> {
777 let id = self.index.ids.get(index)?;
778 let fragment_list = match self.slot(index) {
779 Some(slot) => match slot.get() {
780 Some(cached) => cached,
781 None => {
782 let resolved = self.protocol.fragments.get(id.as_ref())?;
783 slot.set(Some(resolved));
784 resolved
785 }
786 },
787 None => self.protocol.fragments.get(id.as_ref())?,
788 };
789 let start = *self.index.ranges.get(index)? as usize;
790 let end = *self.index.ranges.get(index + 1)? as usize;
791 let metadata = self.index.metadata.get(start..end)?;
792 let has_routes = self
793 .index
794 .route_presence
795 .get(index / 64)
796 .is_some_and(|word| word & (1u64 << (index % 64)) != 0);
797 Some(RenderFragmentList {
798 fragments: &fragment_list.fragments,
799 metadata,
800 attr_names: &self.index.attr_names,
801 contains_boundary: fragment_list.contains_boundary,
802 has_routes,
803 })
804 }
805
806 #[cfg(test)]
807 pub(crate) fn list_by_id(&self, id: &str) -> Option<RenderFragmentList<'protocol>> {
808 self.index(id).and_then(|index| self.list(index))
809 }
810}
811
812fn component_attr_source(attribute: &webui_protocol::WebUIFragmentAttribute) -> &str {
819 let takes_complex_branch = attribute.condition_tree.is_none()
820 && attribute.template.is_empty()
821 && !attribute.value.is_empty()
822 && !attribute.raw_value
823 && attribute.complex;
824 if takes_complex_branch {
825 attribute.name.strip_prefix(':').unwrap_or(&attribute.name)
826 } else {
827 &attribute.name
828 }
829}
830
831fn descend_into<'protocol>(
837 children: &mut RouteChildren<'protocol>,
838 index: usize,
839) -> RouteChildren<'protocol> {
840 match children {
841 Cow::Borrowed(routes) => match routes.get(index) {
842 Some(route) => Cow::Borrowed(&route.children),
843 None => Cow::Borrowed(&[]),
844 },
845 Cow::Owned(routes) => match routes.get_mut(index) {
846 Some(route) => Cow::Owned(std::mem::take(&mut route.children)),
847 None => Cow::Borrowed(&[]),
848 },
849 }
850}
851
852fn fragment_target_id(fragment: &WebUIFragment) -> Option<&str> {
854 match fragment.fragment.as_ref()? {
855 Fragment::Component(component) => Some(&component.fragment_id),
856 Fragment::ForLoop(for_loop) => Some(&for_loop.fragment_id),
857 Fragment::IfCond(if_cond) => Some(&if_cond.fragment_id),
858 Fragment::Attribute(attribute) if !attribute.template.is_empty() => {
859 Some(&attribute.template)
860 }
861 _ => None,
862 }
863}
864
865pub(crate) struct WebUIProcessContext<'protocol, 'state, 'output> {
867 pub(crate) protocol: &'protocol WebUIProtocol,
868 pub(crate) render_fragments: ResolvedRenderFragmentIndex<'protocol>,
872 pub(crate) component_asset_style_manifest: &'protocol str,
875 pub(crate) component_asset_style_links: &'protocol str,
877 pub(crate) state: &'state Value,
878 pub(crate) writer: &'output mut dyn ResponseWriter,
879 pub(crate) local_vars: HashMap<String, Value>,
880 local_borrowed_vars: BorrowedScope<'protocol, 'state>,
882 loop_vars: Vec<LoopBinding<'protocol, 'state>>,
884 visible_loop_scope: VisibleLoopScope,
887 pub(crate) component_attrs: HashMap<String, Value>,
889 component_borrowed_attrs: BorrowedScope<'protocol, 'state>,
891 collecting_component_attrs: bool,
895 pub(crate) request_path: &'protocol str,
898 pub(crate) route_base: Cow<'protocol, str>,
903 pub(crate) rendered_components: HashSet<String>,
907 pub(crate) plugin: Option<Box<dyn HandlerPlugin>>,
909 pub(crate) route_children: RouteChildren<'protocol>,
917 pub(crate) entry_id: &'protocol str,
920 pub(crate) nonce: Option<&'protocol str>,
923 pub(crate) component_index: &'protocol HashMap<String, u32>,
926 pub(crate) style_resource_index: &'protocol HashMap<String, u32>,
928 pub(crate) style_resources_requiring_escape: &'protocol HashSet<String>,
933 pub(crate) style_chunk_index: HashMap<&'protocol str, u32>,
939 pub(crate) css_strategy: webui_protocol::CssStrategy,
941 pub(crate) head_inject: Option<&'protocol str>,
947 pub(crate) body_inject: Option<&'protocol str>,
951 pub(crate) state_inject: StateInject<'state>,
956 pub(crate) head_end_emitted: bool,
963 pub(crate) body_start_emitted: bool,
968 pub(crate) component_asset_styles_emitted: bool,
971 pub(crate) body_end_emitted: bool,
976 pub(crate) route_index: &'protocol CompiledRouteIndex,
978 pub(crate) route_chain_index: usize,
982 pub(crate) route_chain: Option<Vec<crate::route_handler::RouteChainEntry>>,
985 pub(crate) route_document_style_targets: Vec<bool>,
989 pub(crate) reachable_components: Option<Vec<String>>,
995 pub(crate) streaming: Option<&'output mut StreamingRenderState<'protocol>>,
997 pub(crate) json_scratch: Vec<u8>,
1004 pub(crate) scope_pool: Vec<HashMap<String, Value>>,
1010 pub(crate) document_style_resources: HashSet<String>,
1013 pub(crate) shadow_style_roots: Vec<ShadowStyleRoot>,
1016 borrowed_scope_pool: Vec<BorrowedScope<'protocol, 'state>>,
1018}
1019
1020pub(crate) const STRUCTURAL_SIGNAL_PREFIX: &str = "}}}webui:";
1023
1024pub(crate) fn structural_signal_value(
1025 signal: &webui_protocol::WebUIFragmentSignal,
1026) -> Option<&str> {
1027 if !signal.raw {
1028 return None;
1029 }
1030 signal.value.strip_prefix(STRUCTURAL_SIGNAL_PREFIX)
1031}
1032
1033pub(crate) fn write_interaction_marker(
1034 tag: &str,
1035 context: &mut WebUIProcessContext<'_, '_, '_>,
1036) -> Result<()> {
1037 if matches!(
1038 context
1039 .protocol
1040 .components
1041 .get(tag)
1042 .and_then(|component| ComponentWorkPolicy::try_from(component.work_policy).ok()),
1043 Some(ComponentWorkPolicy::Interaction | ComponentWorkPolicy::LazyRenderInteraction)
1044 ) {
1045 context.writer.write(" data-webui-interaction")?;
1046 }
1047 Ok(())
1048}
1049
1050fn doctype_prefix_end(raw: &str) -> Option<usize> {
1053 const PREFIX: &[u8] = b"<!doctype";
1054
1055 let bytes = raw.as_bytes();
1056 let mut cursor = usize::from(bytes.starts_with(b"\xEF\xBB\xBF")) * 3;
1057 while bytes
1058 .get(cursor)
1059 .is_some_and(|byte| byte.is_ascii_whitespace())
1060 {
1061 cursor += 1;
1062 }
1063
1064 let prefix_end = cursor.checked_add(PREFIX.len())?;
1065 if !bytes.get(cursor..prefix_end)?.eq_ignore_ascii_case(PREFIX) {
1066 return None;
1067 }
1068 let separator = *bytes.get(prefix_end)?;
1069 if separator != b'>' && !separator.is_ascii_whitespace() {
1070 return None;
1071 }
1072
1073 let mut quote = None;
1074 for (offset, byte) in bytes[prefix_end..].iter().copied().enumerate() {
1075 match quote {
1076 Some(active) if byte == active => quote = None,
1077 Some(_) => {}
1078 None if matches!(byte, b'\'' | b'"') => quote = Some(byte),
1079 None if byte == b'>' => return Some(prefix_end + offset + 1),
1080 None => {}
1081 }
1082 }
1083 None
1084}
1085
1086const SCOPE_POOL_CAP: usize = 8;
1089const INLINE_SCOPE_SLOTS: usize = 4;
1091const INLINE_RENDER_FRAGMENT_LISTS: usize = 8;
1102const COMPONENT_ASSET_MANIFEST_ID: &str = "webui-component-assets";
1103
1104struct ComponentAssetStyleManifest<'a>(&'a [ComponentAssetStylePreload]);
1105
1106impl Serialize for ComponentAssetStyleManifest<'_> {
1107 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1108 where
1109 S: serde::Serializer,
1110 {
1111 let mut map = serializer.serialize_map(Some(self.0.len()))?;
1112 for entry in self.0 {
1113 map.serialize_entry(&entry.root, &entry.style_hrefs)?;
1114 }
1115 map.end()
1116 }
1117}
1118
1119fn serialize_component_asset_style_manifest(
1120 preloads: &[ComponentAssetStylePreload],
1121) -> std::result::Result<String, serde_json::Error> {
1122 if preloads.is_empty() {
1123 return Ok(String::new());
1124 }
1125 let json = serde_json::to_string(&ComponentAssetStyleManifest(preloads))?;
1126 if json.contains("</") {
1127 Ok(json.replace("</", "<\\/"))
1128 } else {
1129 Ok(json)
1130 }
1131}
1132
1133fn serialize_component_asset_style_links(preloads: &[ComponentAssetStylePreload]) -> String {
1134 let href_count = preloads.iter().map(|entry| entry.style_hrefs.len()).sum();
1135 let mut seen = HashSet::with_capacity(href_count);
1136 let mut links = String::with_capacity(href_count.saturating_mul(48));
1137 for entry in preloads {
1138 for href in &entry.style_hrefs {
1139 if !seen.insert(href.as_str()) {
1140 continue;
1141 }
1142 links.push_str("<link rel=\"stylesheet\" href=\"");
1143 push_escaped_html_attribute(&mut links, href);
1144 links.push_str("\">");
1145 }
1146 }
1147 links
1148}
1149
1150fn push_escaped_html_attribute(output: &mut String, value: &str) {
1151 let mut start = 0;
1152 for (index, ch) in value.char_indices() {
1153 let escaped = match ch {
1154 '&' => Some("&"),
1155 '"' => Some("""),
1156 '<' => Some("<"),
1157 '>' => Some(">"),
1158 _ => None,
1159 };
1160 let Some(entity) = escaped else {
1161 continue;
1162 };
1163 if start < index {
1164 output.push_str(&value[start..index]);
1165 }
1166 output.push_str(entity);
1167 start = index + ch.len_utf8();
1168 }
1169 if start < value.len() {
1170 output.push_str(&value[start..]);
1171 }
1172}
1173
1174fn take_scope_map(pool: &mut Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
1177 pool.pop().unwrap_or_default()
1178}
1179
1180fn recycle_scope_map(pool: &mut Vec<HashMap<String, Value>>, mut map: HashMap<String, Value>) {
1184 if pool.len() < SCOPE_POOL_CAP {
1185 map.clear();
1186 pool.push(map);
1187 }
1188}
1189
1190fn take_borrowed_scope<'protocol, 'state>(
1191 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1192) -> BorrowedScope<'protocol, 'state> {
1193 pool.pop().unwrap_or_default()
1194}
1195
1196fn recycle_borrowed_scope<'protocol, 'state>(
1197 pool: &mut Vec<BorrowedScope<'protocol, 'state>>,
1198 mut scope: BorrowedScope<'protocol, 'state>,
1199) {
1200 if pool.len() < SCOPE_POOL_CAP {
1201 scope.clear();
1202 pool.push(scope);
1203 }
1204}
1205
1206pub(crate) enum WebUiBootstrapState<'a> {
1207 Complete {
1208 value: &'a Value,
1209 selection: StateSelection<'a>,
1210 },
1211 Reference {
1212 record_sequence: usize,
1213 value: &'a Value,
1214 delta: Option<StateSelection<'a>>,
1215 },
1216}
1217
1218pub(crate) struct WebUiBootstrap<'a, ComponentStyles: Serialize + ?Sized = Value> {
1219 pub(crate) declaration_id: Option<u32>,
1220 pub(crate) enclosing_span_instance_id: Option<u32>,
1221 pub(crate) state: WebUiBootstrapState<'a>,
1222 pub(crate) chain: &'a [Value],
1223 pub(crate) inventory: &'a str,
1224 pub(crate) nonce: Option<&'a str>,
1225 pub(crate) css_hrefs: &'a [&'a str],
1226 pub(crate) style_specs: &'a [&'a str],
1227 pub(crate) component_styles: &'a ComponentStyles,
1228 pub(crate) templates: &'a [WebUiTemplatePayload<'a>],
1229}
1230
1231#[cfg(test)]
1241fn component_attr_name(name: &str) -> String {
1242 let mut buffer = String::new();
1243 push_component_attr_name(&mut buffer, name);
1244 buffer
1245}
1246
1247fn push_component_attr_name(buffer: &mut String, name: &str) {
1256 let stripped = name.strip_prefix(':').unwrap_or(name);
1257 if let Some(property) = webui_protocol::attrs::attribute_to_property(stripped) {
1262 buffer.push_str(property);
1263 return;
1264 }
1265 buffer.reserve(stripped.len());
1266 let mut capitalize_next = false;
1267 for ch in stripped.chars() {
1268 if ch == '-' {
1269 capitalize_next = true;
1270 } else if capitalize_next {
1271 buffer.extend(ch.to_uppercase());
1272 capitalize_next = false;
1273 } else {
1274 buffer.push(ch);
1275 }
1276 }
1277}
1278
1279#[cold]
1280#[inline(never)]
1281fn missing_component_attr_name_error() -> HandlerError {
1282 HandlerError::Invariant("prepared component attribute name is missing".to_string())
1283}
1284
1285struct ScalarBuffer {
1293 buf: [u8; 48],
1294 len: usize,
1295}
1296
1297impl ScalarBuffer {
1298 #[inline]
1299 const fn new() -> Self {
1300 Self {
1301 buf: [0; 48],
1302 len: 0,
1303 }
1304 }
1305
1306 #[inline]
1307 fn as_str(&self) -> Option<&str> {
1308 std::str::from_utf8(&self.buf[..self.len]).ok()
1309 }
1310}
1311
1312impl fmt::Write for ScalarBuffer {
1313 #[inline]
1314 fn write_str(&mut self, s: &str) -> fmt::Result {
1315 let bytes = s.as_bytes();
1316 let end = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
1317 let slot = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
1318 slot.copy_from_slice(bytes);
1319 self.len = end;
1320 Ok(())
1321 }
1322}
1323
1324fn format_plain_json_scalar<'buf>(
1331 buffer: &'buf mut ScalarBuffer,
1332 value: &Value,
1333) -> Option<&'buf str> {
1334 match value {
1335 Value::Bool(true) => Some("true"),
1336 Value::Bool(false) => Some("false"),
1337 Value::Null => Some("null"),
1338 Value::Number(number) => {
1339 write!(buffer, "{number}").ok()?;
1342 buffer.as_str()
1343 }
1344 _ => None,
1345 }
1346}
1347
1348fn write_plain_json_scalar(writer: &mut dyn ResponseWriter, value: &Value) -> Result<bool> {
1352 let mut buffer = ScalarBuffer::new();
1353 match format_plain_json_scalar(&mut buffer, value) {
1354 Some(rendered) => {
1355 writer.write(rendered)?;
1356 Ok(true)
1357 }
1358 None => Ok(false),
1359 }
1360}
1361
1362pub(crate) fn write_usize(writer: &mut dyn ResponseWriter, mut n: usize) -> Result<()> {
1364 if n == 0 {
1365 return writer.write("0");
1366 }
1367 let mut buf = [0u8; 20];
1369 let mut pos = buf.len();
1370 while n > 0 {
1371 pos -= 1;
1372 #[allow(clippy::cast_possible_truncation)]
1374 let digit = (n % 10) as u8;
1375 buf[pos] = b'0' + digit;
1376 n /= 10;
1377 }
1378 match std::str::from_utf8(&buf[pos..]) {
1380 Ok(s) => writer.write(s),
1381 Err(_) => writer.write("0"),
1382 }
1383}
1384
1385pub(crate) fn write_script_safe_json<T>(
1386 writer: &mut dyn ResponseWriter,
1387 scratch: &mut Vec<u8>,
1388 value: &T,
1389) -> Result<()>
1390where
1391 T: Serialize + ?Sized,
1392{
1393 scratch.clear();
1401 serde_json::to_writer(&mut *scratch, value)
1402 .map_err(|error| HandlerError::Rendering(format!("failed to serialize JSON: {error}")))?;
1403 let json = std::str::from_utf8(scratch)
1404 .map_err(|error| HandlerError::Rendering(format!("invalid JSON UTF-8: {error}")))?;
1405 write_script_safe_json_str(writer, json)
1406}
1407
1408fn write_script_safe_json_str(writer: &mut dyn ResponseWriter, json: &str) -> Result<()> {
1409 let mut start = 0;
1410 while start < json.len() {
1411 let rest = &json[start..];
1412 let Some(offset) = rest.find("</") else {
1413 writer.write(rest)?;
1414 return Ok(());
1415 };
1416
1417 if offset > 0 {
1418 writer.write(&rest[..offset])?;
1419 }
1420 writer.write("<\\/")?;
1421 start += offset + 2;
1422 }
1423 Ok(())
1424}
1425
1426fn write_json_field_name(
1427 writer: &mut dyn ResponseWriter,
1428 wrote_field: &mut bool,
1429 name: &str,
1430) -> Result<()> {
1431 if *wrote_field {
1432 writer.write(",")?;
1433 }
1434 *wrote_field = true;
1435 writer.write("\"")?;
1436 writer.write(name)?;
1437 writer.write("\":")
1438}
1439
1440fn write_json_field<T>(
1441 writer: &mut dyn ResponseWriter,
1442 scratch: &mut Vec<u8>,
1443 wrote_field: &mut bool,
1444 name: &str,
1445 value: &T,
1446) -> Result<()>
1447where
1448 T: Serialize + ?Sized,
1449{
1450 write_json_field_name(writer, wrote_field, name)?;
1451 write_script_safe_json(writer, scratch, value)
1452}
1453
1454struct ProjectedState<'a> {
1472 value: &'a Value,
1473 keys: KeyView<'a>,
1474}
1475
1476impl Serialize for ProjectedState<'_> {
1477 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1478 where
1479 S: serde::Serializer,
1480 {
1481 let Value::Object(map) = self.value else {
1482 return serializer.serialize_map(Some(0))?.end();
1483 };
1484
1485 let mut out = serializer.serialize_map(None)?;
1486 if self.keys.len() < map.len() {
1487 let mut previous = None;
1488 for key in self.keys.iter() {
1489 if key == STATE_INJECT_KEY {
1490 continue;
1491 }
1492 if previous == Some(key) {
1493 continue;
1494 }
1495 previous = Some(key);
1496 if let Some(value) = map.get(key) {
1497 out.serialize_entry(key, value)?;
1498 }
1499 }
1500 } else {
1501 for (key, value) in map {
1502 if key == STATE_INJECT_KEY {
1503 continue;
1504 }
1505 if self.keys.contains(key.as_str()) {
1506 out.serialize_entry(key, value)?;
1507 }
1508 }
1509 }
1510 out.end()
1511 }
1512}
1513
1514struct StateWithoutReservedKey<'a> {
1535 value: &'a Value,
1536}
1537
1538impl Serialize for StateWithoutReservedKey<'_> {
1539 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1540 where
1541 S: serde::Serializer,
1542 {
1543 let Value::Object(map) = self.value else {
1544 return self.value.serialize(serializer);
1545 };
1546 let mut out = serializer.serialize_map(Some(map.len().saturating_sub(1)))?;
1547 for (key, value) in map {
1548 if key == STATE_INJECT_KEY {
1549 continue;
1550 }
1551 out.serialize_entry(key, value)?;
1552 }
1553 out.end()
1554 }
1555}
1556
1557fn write_full_state(
1562 writer: &mut dyn ResponseWriter,
1563 scratch: &mut Vec<u8>,
1564 state: &Value,
1565) -> Result<()> {
1566 if matches!(state, Value::Object(map) if map.contains_key(STATE_INJECT_KEY)) {
1567 return write_script_safe_json(writer, scratch, &StateWithoutReservedKey { value: state });
1568 }
1569 write_script_safe_json(writer, scratch, state)
1570}
1571
1572pub(crate) fn write_selected_state(
1573 writer: &mut dyn ResponseWriter,
1574 scratch: &mut Vec<u8>,
1575 state: &Value,
1576 selection: &StateSelection<'_>,
1577) -> Result<()> {
1578 let keys = match selection {
1579 StateSelection::Full => return write_full_state(writer, scratch, state),
1580 StateSelection::Keys(keys) => KeyView::Borrowed(keys.as_slice()),
1581 StateSelection::KeyIds(selection) => KeyView::Ids(*selection),
1582 StateSelection::FullExceptKeyIds(selection) => {
1583 return write_script_safe_json(
1584 writer,
1585 scratch,
1586 &StateWithoutSelectedKeys {
1587 value: state,
1588 keys: KeyView::Ids(*selection),
1589 },
1590 );
1591 }
1592 };
1593 if keys.is_empty() {
1594 return writer.write("{}");
1595 }
1596
1597 debug_assert!(
1602 keys.iter()
1603 .zip(keys.iter().skip(1))
1604 .all(|(left, right)| left <= right),
1605 "hydration keys must be sorted for binary-search projection"
1606 );
1607 if let Value::Object(map) = state {
1608 let selects_entire_map =
1609 keys.len() == map.len() && keys.iter().eq(map.keys().map(String::as_str));
1610 if selects_entire_map {
1611 return write_full_state(writer, scratch, state);
1612 }
1613 }
1614 write_script_safe_json(writer, scratch, &ProjectedState { value: state, keys })
1615}
1616
1617pub(crate) const INITIAL_KEY_CAPACITY: usize = 16;
1620
1621pub(crate) enum StateSelection<'a> {
1623 Full,
1625 Keys(Vec<&'a str>),
1627 KeyIds(HydrationKeySelection<'a>),
1634 FullExceptKeyIds(HydrationKeySelection<'a>),
1636}
1637
1638#[derive(Clone, Copy)]
1640pub(crate) struct HydrationKeySelection<'a> {
1641 pub(crate) ids: &'a [u32],
1642 pub(crate) index: &'a ComponentReachabilityIndex,
1643}
1644
1645impl<'a> HydrationKeySelection<'a> {
1646 fn key(self, position: usize) -> Option<&'a str> {
1647 let id = self.ids.get(position).copied()?;
1648 self.index.hydration_key(id)
1649 }
1650}
1651
1652#[derive(Clone, Copy)]
1654enum KeyView<'a> {
1655 Borrowed(&'a [&'a str]),
1656 Ids(HydrationKeySelection<'a>),
1657}
1658
1659impl<'a> KeyView<'a> {
1660 fn len(self) -> usize {
1661 match self {
1662 Self::Borrowed(keys) => keys.len(),
1663 Self::Ids(selection) => selection.ids.len(),
1664 }
1665 }
1666
1667 fn is_empty(self) -> bool {
1668 self.len() == 0
1669 }
1670
1671 fn get(self, position: usize) -> Option<&'a str> {
1672 match self {
1673 Self::Borrowed(keys) => keys.get(position).copied(),
1674 Self::Ids(selection) => selection.key(position),
1675 }
1676 }
1677
1678 fn iter(self) -> impl Iterator<Item = &'a str> {
1679 (0..self.len()).map_while(move |position| self.get(position))
1680 }
1681
1682 fn contains(self, key: &str) -> bool {
1685 match self {
1686 Self::Borrowed(keys) => keys
1687 .binary_search_by(|candidate| str::cmp(candidate, key))
1688 .is_ok(),
1689 Self::Ids(selection) => selection
1690 .ids
1691 .binary_search_by(|id| {
1692 selection
1693 .index
1694 .hydration_key(*id)
1695 .map_or(std::cmp::Ordering::Less, |candidate| {
1696 str::cmp(candidate, key)
1697 })
1698 })
1699 .is_ok(),
1700 }
1701 }
1702}
1703
1704#[derive(Clone, Copy)]
1705enum ComponentStateSurface {
1706 Hydration,
1707 Navigation,
1708}
1709
1710pub(crate) fn collect_hydration_state<'a, 'b>(
1716 protocol: &'a WebUIProtocol,
1717 components: impl IntoIterator<Item = &'b str>,
1718) -> StateSelection<'a> {
1719 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1720 return StateSelection::Full;
1721 }
1722 collect_component_state(protocol, components, ComponentStateSurface::Hydration)
1723}
1724
1725pub(crate) fn collect_hydration_key_ids_into(
1733 protocol: &WebUIProtocol,
1734 index: &ComponentReachabilityIndex,
1735 components: impl IntoIterator<Item = u32>,
1736 ids: &mut Vec<u32>,
1737) -> bool {
1738 ids.clear();
1739 if protocol.initial_state_strategy != InitialStateStrategy::Components as i32 {
1740 return true;
1741 }
1742 for component in components {
1743 if index.extend_hydration_keys(component, ids) {
1744 ids.clear();
1745 return true;
1746 }
1747 }
1748 ids.sort_unstable();
1749 ids.dedup();
1750 false
1751}
1752
1753pub(crate) fn collect_navigation_state<'a, 'b>(
1755 protocol: &'a WebUIProtocol,
1756 components: impl IntoIterator<Item = &'b str>,
1757) -> StateSelection<'a> {
1758 collect_component_state(protocol, components, ComponentStateSurface::Navigation)
1759}
1760
1761fn collect_component_state<'a, 'b>(
1762 protocol: &'a WebUIProtocol,
1763 components: impl IntoIterator<Item = &'b str>,
1764 surface: ComponentStateSurface,
1765) -> StateSelection<'a> {
1766 let mut keys = Vec::with_capacity(INITIAL_KEY_CAPACITY);
1767 if collect_component_state_into(protocol, components, surface, &mut keys) {
1768 StateSelection::Full
1769 } else {
1770 StateSelection::Keys(keys)
1771 }
1772}
1773
1774fn collect_component_state_into<'a, 'b>(
1777 protocol: &'a WebUIProtocol,
1778 components: impl IntoIterator<Item = &'b str>,
1779 surface: ComponentStateSurface,
1780 keys: &mut Vec<&'a str>,
1781) -> bool {
1782 keys.clear();
1783 for name in components {
1784 let Some(component) = protocol.components.get(name) else {
1785 return true;
1786 };
1787 let (mode, component_keys) = match surface {
1788 ComponentStateSurface::Hydration => {
1789 (component.hydration_mode, &component.hydration_keys)
1790 }
1791 ComponentStateSurface::Navigation => (
1792 match component.navigation_mode {
1793 Some(mode) => mode,
1794 None if !component.navigation_keys.is_empty() => {
1795 StateProjectionMode::Keys as i32
1796 }
1797 None => return true,
1798 },
1799 &component.navigation_keys,
1800 ),
1801 };
1802 if mode == StateProjectionMode::All as i32 {
1803 return true;
1804 }
1805 if mode == StateProjectionMode::Keys as i32
1806 || (mode == StateProjectionMode::None as i32 && !component_keys.is_empty())
1807 {
1808 keys.extend(component_keys.iter().map(String::as_str));
1809 } else if mode != StateProjectionMode::None as i32 {
1810 return true;
1811 }
1812 }
1813 keys.sort_unstable();
1814 keys.dedup();
1815 false
1816}
1817
1818pub(crate) fn write_webui_bootstrap<ComponentStyles: Serialize + ?Sized>(
1819 writer: &mut dyn ResponseWriter,
1820 scratch: &mut Vec<u8>,
1821 bootstrap: WebUiBootstrap<'_, ComponentStyles>,
1822) -> Result<()> {
1823 let mut wrote_field = false;
1824
1825 writer.write("{")?;
1826 if let Some(declaration_id) = bootstrap.declaration_id {
1827 write_json_field(
1828 writer,
1829 scratch,
1830 &mut wrote_field,
1831 "declarationId",
1832 &declaration_id,
1833 )?;
1834 }
1835 if let Some(span_id) = bootstrap.enclosing_span_instance_id {
1836 write_json_field(
1837 writer,
1838 scratch,
1839 &mut wrote_field,
1840 "enclosingSpanInstanceId",
1841 &span_id,
1842 )?;
1843 }
1844 if !bootstrap.chain.is_empty() {
1845 write_json_field(writer, scratch, &mut wrote_field, "chain", bootstrap.chain)?;
1846 }
1847 write_json_field(
1850 writer,
1851 scratch,
1852 &mut wrote_field,
1853 "componentStyles",
1854 bootstrap.component_styles,
1855 )?;
1856 if !bootstrap.css_hrefs.is_empty() {
1857 write_json_field(
1858 writer,
1859 scratch,
1860 &mut wrote_field,
1861 "css",
1862 bootstrap.css_hrefs,
1863 )?;
1864 }
1865 write_json_field(
1866 writer,
1867 scratch,
1868 &mut wrote_field,
1869 "inventory",
1870 bootstrap.inventory,
1871 )?;
1872 if let Some(nonce) = bootstrap.nonce {
1873 write_json_field(writer, scratch, &mut wrote_field, "nonce", nonce)?;
1874 }
1875 match bootstrap.state {
1876 WebUiBootstrapState::Complete { value, selection } => {
1877 write_json_field_name(writer, &mut wrote_field, "state")?;
1878 write_selected_state(writer, scratch, value, &selection)?;
1879 }
1880 WebUiBootstrapState::Reference {
1881 record_sequence,
1882 value,
1883 delta,
1884 } => {
1885 write_json_field(
1886 writer,
1887 scratch,
1888 &mut wrote_field,
1889 "stateRef",
1890 &record_sequence,
1891 )?;
1892 if let Some(delta) = delta {
1893 write_json_field_name(writer, &mut wrote_field, "stateDelta")?;
1894 write_selected_state(writer, scratch, value, &delta)?;
1895 }
1896 }
1897 }
1898 if !bootstrap.style_specs.is_empty() {
1899 write_json_field(
1900 writer,
1901 scratch,
1902 &mut wrote_field,
1903 "styles",
1904 bootstrap.style_specs,
1905 )?;
1906 }
1907 if bootstrap
1908 .templates
1909 .iter()
1910 .any(|template| !template.template_json.is_empty())
1911 {
1912 write_json_field_name(writer, &mut wrote_field, "templates")?;
1913 write_webui_template_json_map(writer, scratch, bootstrap.templates)?;
1914 }
1915 writer.write("}")
1916}
1917
1918fn write_webui_data_block(
1919 writer: &mut dyn ResponseWriter,
1920 scratch: &mut Vec<u8>,
1921 bootstrap: WebUiBootstrap<'_>,
1922) -> Result<()> {
1923 writer.write("<script type=\"application/json\" id=\"webui-data\"")?;
1924 if let Some(nonce) = bootstrap.nonce {
1925 writer.write(" nonce=\"")?;
1926 writer.write(nonce)?;
1927 writer.write("\"")?;
1928 }
1929
1930 writer.write(">")?;
1931 write_webui_bootstrap(writer, scratch, bootstrap)?;
1932 writer.write("</script>\n")
1933}
1934
1935fn write_component_asset_style_manifest(
1936 writer: &mut dyn ResponseWriter,
1937 manifest: &str,
1938 nonce: Option<&str>,
1939) -> Result<()> {
1940 if manifest.is_empty() {
1941 return Ok(());
1942 }
1943 writer.write("<script type=\"application/json\" id=\"")?;
1944 writer.write(COMPONENT_ASSET_MANIFEST_ID)?;
1945 writer.write("\"")?;
1946 if let Some(nonce) = nonce {
1947 writer.write(" nonce=\"")?;
1948 writer.write(&crate::html_encode::encode_safe(nonce))?;
1949 writer.write("\"")?;
1950 }
1951 writer.write(">")?;
1952 writer.write(manifest)?;
1953 writer.write("</script>")
1954}
1955
1956fn write_component_asset_styles(context: &mut WebUIProcessContext<'_, '_, '_>) -> Result<()> {
1957 if context.component_asset_styles_emitted {
1958 return Ok(());
1959 }
1960 context.component_asset_styles_emitted = true;
1961 context.writer.write(context.component_asset_style_links)?;
1962 write_component_asset_style_manifest(
1963 context.writer,
1964 context.component_asset_style_manifest,
1965 context.nonce,
1966 )
1967}
1968
1969fn write_webui_template_json_map(
1970 writer: &mut dyn ResponseWriter,
1971 scratch: &mut Vec<u8>,
1972 templates: &[WebUiTemplatePayload<'_>],
1973) -> Result<()> {
1974 writer.write("{")?;
1975 let mut wrote = false;
1976 for template in templates {
1977 if template.template_json.is_empty() {
1978 continue;
1979 }
1980 if wrote {
1981 writer.write(",")?;
1982 }
1983 wrote = true;
1984 write_script_safe_json(writer, scratch, template.tag_name)?;
1985 writer.write(":")?;
1986 write_script_safe_json_str(writer, template.template_json)?;
1987 }
1988 writer.write("}")
1989}
1990
1991fn resolve_value_from_sources<'ctx, 'state>(
1992 path: &str,
1993 loop_vars: &'ctx [LoopBinding<'_, 'state>],
1994 visible_loop_scope: VisibleLoopScope,
1995 local_values: LocalValueSources<'ctx, '_, 'state>,
1996 state: &'state Value,
1997) -> Option<Cow<'ctx, Value>>
1998where
1999 'state: 'ctx,
2000{
2001 if let Some(binding) = loop_vars
2002 .get(visible_loop_scope.start..visible_loop_scope.end)
2003 .and_then(|bindings| bindings.last())
2004 {
2005 let name = binding.name;
2006 if path.len() == name.len() && path == name {
2007 return Some(Cow::Borrowed(binding.value));
2008 }
2009 if path.len() > name.len()
2010 && path.as_bytes().get(name.len()) == Some(&b'.')
2011 && path.starts_with(name)
2012 {
2013 if let Some(value) =
2014 find_value_by_dotted_path_ref(&path[name.len() + 1..], binding.value)
2015 {
2016 return Some(value);
2017 }
2018 return find_value_by_dotted_path_ref(path, state);
2019 }
2020 }
2021
2022 if let Some(first_part) = path.split('.').next() {
2023 if let Some(local_value) = local_values.borrowed.get(first_part) {
2024 if first_part.len() == path.len() {
2025 return Some(Cow::Borrowed(local_value));
2026 }
2027 let remaining = &path[first_part.len() + 1..];
2028 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2029 return Some(value);
2030 }
2031 return find_value_by_dotted_path_ref(path, state);
2032 }
2033 if let Some(local_value) = local_values.owned.get(first_part) {
2034 if first_part.len() == path.len() {
2035 return Some(Cow::Borrowed(local_value));
2036 }
2037 let remaining = &path[first_part.len() + 1..];
2038 if let Some(value) = find_value_by_dotted_path_ref(remaining, local_value) {
2039 return Some(value);
2040 }
2041 return find_value_by_dotted_path_ref(path, state);
2042 }
2043
2044 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2045 .iter()
2046 .rev()
2047 {
2048 if binding.name != first_part {
2049 continue;
2050 }
2051 if first_part.len() == path.len() {
2052 return Some(Cow::Borrowed(binding.value));
2053 }
2054 let remaining = &path[first_part.len() + 1..];
2055 if let Some(value) = find_value_by_dotted_path_ref(remaining, binding.value) {
2056 return Some(value);
2057 }
2058 return find_value_by_dotted_path_ref(path, state);
2059 }
2060 }
2061
2062 find_value_by_dotted_path_ref(path, state)
2063}
2064
2065fn resolve_borrowed_collection<'state>(
2066 path: &str,
2067 loop_vars: &[LoopBinding<'_, 'state>],
2068 visible_loop_scope: VisibleLoopScope,
2069 local_values: LocalValueSources<'_, '_, 'state>,
2070 state: &'state Value,
2071) -> Option<&'state [Value]> {
2072 let first_part = path.split('.').next()?;
2073 if let Some(binding) = loop_vars
2074 .get(visible_loop_scope.start..visible_loop_scope.end)
2075 .and_then(|bindings| bindings.last())
2076 .filter(|binding| binding.name == first_part)
2077 {
2078 if first_part.len() == path.len() {
2079 return binding.value.as_array().map(Vec::as_slice);
2080 }
2081 let remaining = &path[first_part.len() + 1..];
2082 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2083 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2084 Some(Cow::Owned(_)) => None,
2085 None => borrowed_state_array(path, state),
2086 };
2087 }
2088 if let Some(local_value) = local_values.borrowed.get(first_part) {
2089 let local_match = if first_part.len() == path.len() {
2090 Some(Cow::Borrowed(local_value))
2091 } else {
2092 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2093 };
2094 return match local_match {
2095 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2096 Some(Cow::Owned(_)) => None,
2097 None => borrowed_state_array(path, state),
2098 };
2099 }
2100 if let Some(local_value) = local_values.owned.get(first_part) {
2101 let local_match = if first_part.len() == path.len() {
2102 Some(Cow::Borrowed(local_value))
2103 } else {
2104 find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value)
2105 };
2106 if local_match.is_some() {
2107 return None;
2108 }
2109 return borrowed_state_array(path, state);
2110 }
2111
2112 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2113 .iter()
2114 .rev()
2115 {
2116 if binding.name != first_part {
2117 continue;
2118 }
2119 if first_part.len() == path.len() {
2120 return binding.value.as_array().map(Vec::as_slice);
2121 }
2122 let remaining = &path[first_part.len() + 1..];
2123 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2124 Some(Cow::Borrowed(value)) => value.as_array().map(Vec::as_slice),
2125 Some(Cow::Owned(_)) => None,
2126 None => borrowed_state_array(path, state),
2127 };
2128 }
2129
2130 borrowed_state_array(path, state)
2131}
2132
2133fn resolve_state_backed_value<'state>(
2134 path: &str,
2135 loop_vars: &[LoopBinding<'_, 'state>],
2136 visible_loop_scope: VisibleLoopScope,
2137 local_values: LocalValueSources<'_, '_, 'state>,
2138 state: &'state Value,
2139) -> Option<&'state Value> {
2140 let first_part = path.split('.').next()?;
2141 if let Some(binding) = loop_vars
2142 .get(visible_loop_scope.start..visible_loop_scope.end)
2143 .and_then(|bindings| bindings.last())
2144 .filter(|binding| binding.name == first_part)
2145 {
2146 if first_part.len() == path.len() {
2147 return Some(binding.value);
2148 }
2149 let remaining = &path[first_part.len() + 1..];
2150 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2151 Some(Cow::Borrowed(value)) => Some(value),
2152 Some(Cow::Owned(_)) => None,
2153 None => borrowed_state_value(path, state),
2154 };
2155 }
2156 if let Some(local_value) = local_values.borrowed.get(first_part) {
2157 if first_part.len() == path.len() {
2158 return Some(local_value);
2159 }
2160 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2161 Some(Cow::Borrowed(value)) => Some(value),
2162 Some(Cow::Owned(_)) => None,
2163 None => borrowed_state_value(path, state),
2164 };
2165 }
2166 if let Some(local_value) = local_values.owned.get(first_part) {
2167 if first_part.len() == path.len() {
2168 return None;
2169 }
2170 return match find_value_by_dotted_path_ref(&path[first_part.len() + 1..], local_value) {
2171 Some(_) => None,
2172 None => borrowed_state_value(path, state),
2173 };
2174 }
2175
2176 for binding in loop_vars[visible_loop_scope.start..visible_loop_scope.end]
2177 .iter()
2178 .rev()
2179 {
2180 if binding.name != first_part {
2181 continue;
2182 }
2183 if first_part.len() == path.len() {
2184 return Some(binding.value);
2185 }
2186 let remaining = &path[first_part.len() + 1..];
2187 return match find_value_by_dotted_path_ref(remaining, binding.value) {
2188 Some(Cow::Borrowed(value)) => Some(value),
2189 Some(Cow::Owned(_)) => None,
2190 None => borrowed_state_value(path, state),
2191 };
2192 }
2193
2194 borrowed_state_value(path, state)
2195}
2196
2197fn borrowed_state_value<'state>(path: &str, state: &'state Value) -> Option<&'state Value> {
2198 match find_value_by_dotted_path_ref(path, state) {
2199 Some(Cow::Borrowed(value)) => Some(value),
2200 Some(Cow::Owned(_)) | None => None,
2201 }
2202}
2203
2204fn borrowed_state_array<'state>(path: &str, state: &'state Value) -> Option<&'state [Value]> {
2205 match find_value_by_dotted_path_ref(path, state)? {
2206 Cow::Borrowed(value) => value.as_array().map(Vec::as_slice),
2207 Cow::Owned(_) => None,
2208 }
2209}
2210
2211impl WebUIHandler {
2212 pub fn new() -> Self {
2214 Self {
2215 plugin_factory: None,
2216 }
2217 }
2218
2219 pub fn with_plugin(factory: fn() -> Box<dyn HandlerPlugin>) -> Self {
2224 Self {
2225 plugin_factory: Some(factory),
2226 }
2227 }
2228
2229 #[cfg(test)]
2230 fn handle(
2231 &self,
2232 document: &WebUIProtocol,
2233 state: &Value,
2234 options: &RenderOptions<'_>,
2235 writer: &mut dyn ResponseWriter,
2236 ) -> Result<()> {
2237 let protocol = Protocol::new(document.clone());
2238 self.render(&protocol, state, options, writer)
2239 }
2240
2241 fn process_fragment_target<'data>(
2247 &self,
2248 target: Option<usize>,
2249 fragment_id: &str,
2250 context: &mut WebUIProcessContext<'data, '_, '_>,
2251 ) -> Result<()> {
2252 let Some(index) = target.or_else(|| context.render_fragments.index(fragment_id)) else {
2253 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2254 };
2255 let Some(fragment_list) = context.render_fragments.list(index) else {
2256 return Err(HandlerError::MissingFragment(fragment_id.to_string()));
2257 };
2258 self.process_fragment(fragment_list, context)
2259 }
2260
2261 fn process_fragment<'data>(
2266 &self,
2267 fragment_list: RenderFragmentList<'data>,
2268 context: &mut WebUIProcessContext<'data, '_, '_>,
2269 ) -> Result<()> {
2270 self.process_fragment_from(fragment_list, 0, context)
2271 }
2272
2273 fn process_fragment_from<'data>(
2274 &self,
2275 fragment_list: RenderFragmentList<'data>,
2276 start: usize,
2277 context: &mut WebUIProcessContext<'data, '_, '_>,
2278 ) -> Result<()> {
2279 let fragments = fragment_list.fragments;
2280 let best_route = if fragment_list.has_routes {
2286 route_renderer::find_best_route_match(
2287 fragments,
2288 context.request_path,
2289 &context.route_base,
2290 context.route_index,
2291 )
2292 } else {
2293 None
2294 };
2295 self.process_fragment_range(fragment_list, start..fragments.len(), &best_route, context)
2296 }
2297
2298 fn process_fragment_range<'data>(
2299 &self,
2300 fragment_list: RenderFragmentList<'data>,
2301 range: std::ops::Range<usize>,
2302 best_route: &Option<(String, route_matcher::RouteMatch)>,
2303 context: &mut WebUIProcessContext<'data, '_, '_>,
2304 ) -> Result<()> {
2305 let fragments = fragment_list.fragments;
2306 let Some(selected) = fragments.get(range.clone()) else {
2307 return Err(invalid_fragment_range_error(&range, fragments.len()));
2308 };
2309 for (offset, item) in selected.iter().enumerate() {
2310 let index = range.start + offset;
2311 if context.streaming.is_some() {
2312 validate_pending_streaming_root(item, context)?;
2313 validate_streaming_root_opening(&fragments[..index], item)?;
2314 }
2315 match item.fragment.as_ref() {
2319 Some(Fragment::Raw(raw)) => {
2320 context.writer.write(&raw.value)?;
2321 }
2322 Some(Fragment::Component(component)) => {
2323 self.process_component(
2324 &component.fragment_id,
2325 fragment_list.target(index),
2326 ComponentHostOrigin::ParserProduced,
2327 context,
2328 )?;
2329 }
2330 Some(Fragment::ForLoop(for_loop)) => {
2331 self.process_for_loop(for_loop, fragment_list.target(index), context)?;
2332 }
2333 Some(Fragment::Signal(signal)) => {
2334 self.process_signal(signal, context)?;
2335 }
2336 Some(Fragment::IfCond(if_cond)) => {
2337 self.process_if(if_cond, fragment_list.target(index), context)?;
2338 }
2339 Some(Fragment::Attribute(attr)) => {
2340 self.process_attribute(
2341 attr,
2342 fragment_list.target(index),
2343 fragment_list.component_attr_name(index),
2344 context,
2345 )?;
2346 }
2347 Some(Fragment::Plugin(plugin_frag)) => {
2348 if let Some(p) = &mut context.plugin {
2349 p.on_element_data(&plugin_frag.data, context.writer)?;
2350 }
2351 }
2352 Some(Fragment::Route(route_frag)) => {
2353 self.process_route(route_frag, best_route, context)?;
2354 }
2355 Some(Fragment::Outlet(_)) => {
2356 self.process_outlet(context)?;
2357 }
2358 Some(Fragment::Boundary(_)) => {}
2359 None => {}
2360 }
2361 }
2362 ensure_no_pending_streaming_root(context, "the end of the containing fragment")
2363 }
2364
2365 fn process_outlet<'protocol>(
2371 &self,
2372 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2373 ) -> Result<()> {
2374 let mut children = std::mem::take(&mut context.route_children);
2380 if children.is_empty() {
2381 return Ok(());
2382 }
2383
2384 let request_segments = route_matcher::split_request_path(context.request_path);
2386 let mut best: Option<(usize, route_matcher::RouteMatch)> = None;
2387 for (idx, child) in children.iter().enumerate() {
2388 if let Some(m) = route_matcher::match_route_indexed_with_segments(
2389 context.route_index,
2390 &child.path,
2391 &context.route_base,
2392 &request_segments,
2393 child.exact,
2394 ) {
2395 let is_better = best
2396 .as_ref()
2397 .is_none_or(|(_, prev)| m.specificity > prev.specificity);
2398 if is_better {
2399 best = Some((idx, m));
2400 }
2401 }
2402 }
2403
2404 let after_match = best.as_ref().map_or(0, |(idx, _)| idx + 1);
2405 if let Some((idx, ref rm)) = best {
2406 route_renderer::write_hidden_routes(context.writer, &children[..idx])?;
2407 let descended = descend_into(&mut children, idx);
2408 let Some(matched_child) = children.get(idx) else {
2409 return Ok(());
2410 };
2411 let comp = &matched_child.fragment_id;
2412
2413 if !comp.is_empty() {
2414 let saved_route_base = (rm.consumed_segments > 0).then(|| {
2415 let base = route_matcher::compute_route_base(
2416 context.request_path,
2417 rm.consumed_segments,
2418 );
2419 std::mem::replace(&mut context.route_base, Cow::Owned(base))
2420 });
2421 context.route_children = descended;
2422
2423 context.writer.write("<webui-route")?;
2425 context.writer.write(" path=\"")?;
2426 context.writer.write(&matched_child.path)?;
2427 context.writer.write("\"")?;
2428 context.writer.write(" component=\"")?;
2429 context.writer.write(comp)?;
2430 context.writer.write("\"")?;
2431 if matched_child.exact {
2432 context.writer.write(" exact")?;
2433 }
2434 route_renderer::write_route_navigation_attrs(context.writer, matched_child)?;
2435 let ri = context.route_chain_index;
2437 context.route_chain_index += 1;
2438 context.writer.write(" data-ri=\"")?;
2439 write_usize(context.writer, ri)?;
2440 context.writer.write("\" active>")?;
2441
2442 if !Self::component_owns_css_tree(comp, context.protocol) {
2443 self.emit_component_style_closure(comp, StyleClosureInstall::Routed, context)?;
2444 }
2445 if !matched_child.content_fragment_id.is_empty() {
2446 self.process_fragment_target(
2447 None,
2448 &matched_child.content_fragment_id,
2449 context,
2450 )?;
2451 }
2452
2453 context.writer.write("<")?;
2454 context.writer.write(comp)?;
2455 if let Some(p) = &context.plugin {
2456 p.write_route_component_state(context.state, context.writer)?;
2457 }
2458 write_interaction_marker(comp, context)?;
2459 prepare_generated_streaming_root(comp, context)?;
2460 context.writer.write(">")?;
2461
2462 self.process_component(comp, None, ComponentHostOrigin::HandlerGenerated, context)?;
2463
2464 context.writer.write("</")?;
2465 context.writer.write(comp)?;
2466 context.writer.write(">")?;
2467 context.writer.write("</webui-route>")?;
2468
2469 if let Some(saved) = saved_route_base {
2470 context.route_base = saved;
2471 }
2472 context.route_children = Cow::Borrowed(&[]);
2476 }
2477 }
2478
2479 route_renderer::write_hidden_routes(context.writer, &children[after_match..])
2480 }
2481
2482 fn emit_css_module_importmap(
2493 &self,
2494 specifier: &str,
2495 css: &str,
2496 context: &mut WebUIProcessContext,
2497 ) -> Result<()> {
2498 let tag = crate::css_module::build_importmap_tag(specifier, css, context.nonce);
2499 context.writer.write(&tag)?;
2500 Ok(())
2501 }
2502
2503 fn emit_component_style_closure(
2505 &self,
2506 root: &str,
2507 install: StyleClosureInstall,
2508 context: &mut WebUIProcessContext,
2509 ) -> Result<()> {
2510 if context.protocol.style_closures.is_empty() {
2511 return Ok(());
2512 }
2513 let shadow_static_closure = if install == StyleClosureInstall::Routed {
2514 match context.shadow_style_roots.last() {
2515 Some(shadow_root) => {
2516 if !shadow_root.static_closure_emitted {
2517 return Err(HandlerError::Invariant(
2518 "routed styles reached a ShadowRoot before its static style hook"
2519 .to_string(),
2520 ));
2521 }
2522 let root_name = context
2523 .component_index
2524 .iter()
2525 .find_map(|(name, index)| {
2526 (*index == shadow_root.component_index).then_some(name.as_str())
2527 })
2528 .ok_or_else(|| {
2529 HandlerError::Invariant(
2530 "active Shadow style root lost its protocol index".to_string(),
2531 )
2532 })?;
2533 Some(
2534 context
2535 .protocol
2536 .style_closures
2537 .get(root_name)
2538 .ok_or_else(|| {
2539 HandlerError::Invariant(format!(
2540 "component style closure metadata is missing Shadow root `{root_name}`"
2541 ))
2542 })?,
2543 )
2544 }
2545 None => None,
2546 }
2547 } else {
2548 None
2549 };
2550 let closure = context.protocol.style_closures.get(root).ok_or_else(|| {
2551 HandlerError::Invariant(format!(
2552 "component style closure metadata is missing root `{root}`"
2553 ))
2554 })?;
2555 let is_document_tree = context.shadow_style_roots.is_empty();
2556 let strategy = context.css_strategy;
2557
2558 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2563 let mut emitted_resources = HashSet::with_capacity(unit_count);
2564
2565 for position in 0..unit_count {
2566 let unit = context
2567 .protocol
2568 .style_closure_unit(closure, &context.style_chunk_index, position)
2569 .ok_or_else(|| {
2570 HandlerError::Invariant(format!(
2571 "component style closure `{root}` references out-of-range unit {position}"
2572 ))
2573 })?;
2574 let (name, chunk) = (unit.name, unit.chunk);
2575 if !emitted_resources.insert(name) {
2576 continue;
2577 }
2578 let resource = unit.resource.ok_or_else(|| match chunk {
2579 Some(index) => HandlerError::Invariant(format!(
2580 "component style closure `{root}` references missing style chunk {index}"
2581 )),
2582 None => HandlerError::Invariant(format!(
2583 "component style closure `{root}` references missing resource `{name}`"
2584 )),
2585 })?;
2586 if is_document_tree && !context.document_style_resources.insert(name.to_string()) {
2587 continue;
2588 }
2589 if let Some(static_closure) = shadow_static_closure {
2590 let resource_index = match chunk {
2591 Some(index) => {
2592 if static_closure.style_chunks.contains(&index) {
2593 continue;
2594 }
2595 index
2596 }
2597 None => {
2598 if static_closure.component_tags.iter().any(|tag| tag == name) {
2599 continue;
2600 }
2601 context.component_index.get(name).copied().ok_or_else(|| {
2602 HandlerError::Invariant(format!(
2603 "component style resource `{name}` is missing its protocol index"
2604 ))
2605 })?
2606 }
2607 };
2608 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2609 HandlerError::Invariant(
2610 "active Shadow style root disappeared during routed style delivery"
2611 .to_string(),
2612 )
2613 })?;
2614 if shadow_root.routed_resources.contains(&resource_index) {
2615 continue;
2616 }
2617 shadow_root.routed_resources.push(resource_index);
2618 }
2619
2620 match strategy {
2621 webui_protocol::CssStrategy::Link => {
2622 context.writer.write("<link rel=\"stylesheet\" href=\"")?;
2623 context
2624 .writer
2625 .write(&crate::html_encode::encode_safe(resource))?;
2626 context.writer.write("\" data-webui-resource=\"")?;
2627 context
2628 .writer
2629 .write(&crate::html_encode::encode_safe(name))?;
2630 context.writer.write("\" data-webui-strategy=\"link\">")?;
2631 }
2632 strategy => {
2633 context.writer.write("<style")?;
2634 if let Some(nonce) = context.nonce {
2635 context.writer.write(" nonce=\"")?;
2636 context
2637 .writer
2638 .write(&crate::html_encode::encode_safe(nonce))?;
2639 context.writer.write("\"")?;
2640 }
2641 context.writer.write(" data-webui-resource=\"")?;
2642 context
2643 .writer
2644 .write(&crate::html_encode::encode_safe(name))?;
2645 context.writer.write("\" data-webui-strategy=\"")?;
2646 context
2647 .writer
2648 .write(if strategy == webui_protocol::CssStrategy::Module {
2649 "module"
2650 } else {
2651 "style"
2652 })?;
2653 context.writer.write("\">")?;
2654 if context.style_resources_requiring_escape.contains(name) {
2655 crate::html_encode::write_style_text(context.writer, resource)?;
2656 } else {
2657 context.writer.write(resource)?;
2658 }
2659 context.writer.write("</style>")?;
2660 }
2661 }
2662 }
2663 Ok(())
2664 }
2665
2666 pub(crate) fn ensure_request_route_chain(context: &mut WebUIProcessContext) {
2667 if context.route_chain.is_none() {
2668 let plan = crate::route_handler::collect_route_chain_plan(
2669 context.protocol,
2670 context.entry_id,
2671 context.request_path,
2672 context.route_index,
2673 );
2674 context.route_document_style_targets = plan.document_style_targets;
2675 context.route_chain = Some(plan.entries);
2676 }
2677 }
2678
2679 fn emit_active_route_styles(
2680 &self,
2681 preloaded: &mut Vec<u32>,
2682 context: &mut WebUIProcessContext,
2683 ) -> Result<()> {
2684 Self::ensure_request_route_chain(context);
2685 let Some(chain) = context.route_chain.take() else {
2686 return Err(route_style_plan_missing_error());
2687 };
2688 let document_targets = std::mem::take(&mut context.route_document_style_targets);
2689 if chain.len() != document_targets.len() {
2690 let error = route_style_plan_length_error(chain.len(), document_targets.len());
2691 context.route_document_style_targets = document_targets;
2692 context.route_chain = Some(chain);
2693 return Err(error);
2694 }
2695
2696 let result = (|| {
2697 for (entry, targets_document) in chain.iter().zip(&document_targets) {
2698 if *targets_document {
2699 self.emit_component_style_closure(
2700 &entry.component,
2701 StyleClosureInstall::Static,
2702 context,
2703 )?;
2704 continue;
2705 }
2706
2707 if context.css_strategy != webui_protocol::CssStrategy::Link {
2708 continue;
2709 }
2710 self.emit_component_style_preloads(&entry.component, preloaded, context)?;
2711 }
2712 Ok(())
2713 })();
2714
2715 context.route_document_style_targets = document_targets;
2716 context.route_chain = Some(chain);
2717 result
2718 }
2719
2720 fn emit_component_style_preloads(
2721 &self,
2722 root: &str,
2723 preloaded: &mut Vec<u32>,
2724 context: &mut WebUIProcessContext,
2725 ) -> Result<()> {
2726 let Some(closure) = context.protocol.style_closures.get(root) else {
2727 return Ok(());
2728 };
2729 let unit_count = WebUIProtocol::style_closure_unit_count(closure);
2731 for position in 0..unit_count {
2732 let Some(unit) =
2733 context
2734 .protocol
2735 .style_closure_unit(closure, &context.style_chunk_index, position)
2736 else {
2737 continue;
2738 };
2739 let Some(href) = unit.resource else {
2740 continue;
2741 };
2742 let name = unit.name;
2743 if context.document_style_resources.contains(name) {
2744 continue;
2745 }
2746 let Some(&resource_index) = context.style_resource_index.get(name) else {
2747 continue;
2748 };
2749 if preloaded.contains(&resource_index) {
2750 continue;
2751 }
2752 context
2753 .writer
2754 .write("<link rel=\"preload\" as=\"style\" href=\"")?;
2755 context
2756 .writer
2757 .write(&crate::html_encode::encode_safe(href))?;
2758 context.writer.write("\">")?;
2759 preloaded.push(resource_index);
2760 }
2761 Ok(())
2762 }
2763
2764 fn emit_reachable_shadow_preloads(
2765 &self,
2766 preloaded: &mut Vec<u32>,
2767 context: &mut WebUIProcessContext,
2768 ) -> Result<()> {
2769 if context.css_strategy != webui_protocol::CssStrategy::Link {
2770 return Ok(());
2771 }
2772 let reachable = context.reachable_components.take().unwrap_or_else(|| {
2773 crate::route_handler::collect_reachable_component_order_for_request(
2774 context.protocol,
2775 context.entry_id,
2776 context.request_path,
2777 context.route_index,
2778 )
2779 });
2780 let result = (|| {
2781 for component in &reachable {
2782 if context.protocol.component_uses_shadow_dom(component) {
2783 self.emit_component_style_preloads(component, preloaded, context)?;
2784 }
2785 }
2786 Ok(())
2787 })();
2788 context.reachable_components = Some(reachable);
2789 result
2790 }
2791
2792 fn process_shadow_style_signal(
2793 &self,
2794 root: &str,
2795 context: &mut WebUIProcessContext,
2796 ) -> Result<()> {
2797 let Some(root_index) = context.component_index.get(root).copied() else {
2798 return Err(HandlerError::Invariant(format!(
2799 "Shadow style hook `{root}` references an unknown component"
2800 )));
2801 };
2802 if context
2803 .shadow_style_roots
2804 .last()
2805 .map(|shadow_root| shadow_root.component_index)
2806 != Some(root_index)
2807 {
2808 return Err(HandlerError::Invariant(format!(
2809 "Shadow style hook `{root}` does not match the active component root"
2810 )));
2811 }
2812 self.emit_component_style_closure(root, StyleClosureInstall::Static, context)?;
2813 let shadow_root = context.shadow_style_roots.last_mut().ok_or_else(|| {
2814 HandlerError::Invariant(
2815 "active Shadow style root disappeared after its static style hook".to_string(),
2816 )
2817 })?;
2818 shadow_root.static_closure_emitted = true;
2819 Ok(())
2820 }
2821
2822 fn emit_css_module(&self, fragment_id: &str, context: &mut WebUIProcessContext) -> Result<()> {
2831 if context.css_strategy != webui_protocol::CssStrategy::Module {
2832 return Ok(());
2833 }
2834 let metadata_already_streamed = context.streaming.as_ref().is_some_and(|streaming| {
2835 streaming_template_already_sent(streaming, context.component_index, fragment_id)
2836 });
2837 if !metadata_already_streamed && !context.rendered_components.contains(fragment_id) {
2838 if let Some(css) = context
2839 .protocol
2840 .components
2841 .get(fragment_id)
2842 .map(|c| c.css.as_str())
2843 .filter(|s| !s.is_empty())
2844 {
2845 self.emit_css_module_importmap(fragment_id, css, context)?;
2846 }
2847 }
2848 Ok(())
2849 }
2850
2851 fn process_route<'protocol>(
2853 &self,
2854 route_frag: &'protocol webui_protocol::WebUiFragmentRoute,
2855 best_route: &Option<(String, route_matcher::RouteMatch)>,
2856 context: &mut WebUIProcessContext<'protocol, '_, '_>,
2857 ) -> Result<()> {
2858 let is_matched = best_route
2859 .as_ref()
2860 .is_some_and(|(best_key, _)| *best_key == route_frag.fragment_id);
2861
2862 context.writer.write("<webui-route")?;
2863 context.writer.write(" path=\"")?;
2864 context.writer.write(&route_frag.path)?;
2865 context.writer.write("\"")?;
2866 if !route_frag.fragment_id.is_empty() {
2867 context.writer.write(" component=\"")?;
2868 context.writer.write(&route_frag.fragment_id)?;
2869 context.writer.write("\"")?;
2870 }
2871 if route_frag.exact {
2872 context.writer.write(" exact")?;
2873 }
2874 route_renderer::write_route_navigation_attrs(context.writer, route_frag)?;
2875
2876 if is_matched {
2877 let ri = context.route_chain_index;
2879 context.route_chain_index += 1;
2880 context.writer.write(" data-ri=\"")?;
2881 write_usize(context.writer, ri)?;
2882 context.writer.write("\" active>")?;
2883
2884 let saved_route_base = best_route.as_ref().map(|(_, rm)| {
2885 let base =
2886 route_matcher::compute_route_base(context.request_path, rm.consumed_segments);
2887 std::mem::replace(&mut context.route_base, Cow::Owned(base))
2888 });
2889 let saved_route_children = std::mem::replace(
2890 &mut context.route_children,
2891 Cow::Borrowed(&route_frag.children),
2892 );
2893
2894 if !route_frag.content_fragment_id.is_empty() {
2895 self.process_fragment_target(None, &route_frag.content_fragment_id, context)?;
2896 }
2897
2898 if !route_frag.fragment_id.is_empty() {
2899 if !Self::component_owns_css_tree(&route_frag.fragment_id, context.protocol) {
2900 self.emit_component_style_closure(
2901 &route_frag.fragment_id,
2902 StyleClosureInstall::Routed,
2903 context,
2904 )?;
2905 }
2906 context.writer.write("<")?;
2907 context.writer.write(&route_frag.fragment_id)?;
2908 if let Some(p) = &context.plugin {
2909 p.write_route_component_state(context.state, context.writer)?;
2910 }
2911 write_interaction_marker(&route_frag.fragment_id, context)?;
2912 prepare_generated_streaming_root(&route_frag.fragment_id, context)?;
2913 context.writer.write(">")?;
2914
2915 self.process_component(
2916 &route_frag.fragment_id,
2917 None,
2918 ComponentHostOrigin::HandlerGenerated,
2919 context,
2920 )?;
2921
2922 context.writer.write("</")?;
2923 context.writer.write(&route_frag.fragment_id)?;
2924 context.writer.write(">")?;
2925 }
2926 if let Some(saved) = saved_route_base {
2927 context.route_base = saved;
2928 }
2929 context.route_children = saved_route_children;
2930 } else {
2931 context.writer.write(" style=\"display:none\">")?;
2932 }
2933
2934 context.writer.write("</webui-route>")?;
2935 Ok(())
2936 }
2937
2938 fn process_component(
2940 &self,
2941 fragment_id: &str,
2942 target: Option<usize>,
2943 origin: ComponentHostOrigin,
2944 context: &mut WebUIProcessContext,
2945 ) -> Result<()> {
2946 if context.streaming.is_some() {
2947 consume_streaming_component_root(fragment_id, origin, context)?;
2948 record_checkpoint_tag(context, fragment_id);
2951 }
2952
2953 if !context.rendered_components.contains(fragment_id) {
2959 self.emit_css_module(fragment_id, context)?;
2960 context.rendered_components.insert(fragment_id.to_string());
2961 }
2962
2963 let owns_css_tree = Self::component_owns_css_tree(fragment_id, context.protocol);
2964 if owns_css_tree {
2965 Self::push_shadow_style_root(fragment_id, context)?;
2966 }
2967
2968 let saved_local_vars = std::mem::take(&mut context.local_vars);
2970 let saved_local_borrowed_vars = std::mem::take(&mut context.local_borrowed_vars);
2971 let saved_component_attrs = std::mem::replace(
2975 &mut context.component_attrs,
2976 take_scope_map(&mut context.scope_pool),
2977 );
2978 let saved_component_borrowed_attrs = std::mem::replace(
2979 &mut context.component_borrowed_attrs,
2980 take_borrowed_scope(&mut context.borrowed_scope_pool),
2981 );
2982 context.local_vars = saved_component_attrs;
2983 context.local_borrowed_vars = saved_component_borrowed_attrs;
2984 context.collecting_component_attrs = false;
2985 let saved_loop_scope = context.visible_loop_scope;
2986 context.visible_loop_scope = VisibleLoopScope {
2987 start: context.loop_vars.len(),
2988 end: context.loop_vars.len(),
2989 };
2990
2991 if let Some(p) = &mut context.plugin {
2992 p.push_scope();
2993 }
2994
2995 let render_result = self.process_fragment_target(target, fragment_id, context);
2996 context.visible_loop_scope = saved_loop_scope;
2997
2998 if owns_css_tree {
2999 Self::pop_shadow_style_root(fragment_id, context)?;
3000 }
3001 render_result?;
3002
3003 if let Some(p) = &mut context.plugin {
3004 p.pop_scope();
3005 }
3006
3007 let used_locals = std::mem::replace(&mut context.local_vars, saved_local_vars);
3010 recycle_scope_map(&mut context.scope_pool, used_locals);
3011 let used_borrowed_locals =
3012 std::mem::replace(&mut context.local_borrowed_vars, saved_local_borrowed_vars);
3013 recycle_borrowed_scope(&mut context.borrowed_scope_pool, used_borrowed_locals);
3014 context.component_attrs.clear();
3017 context.component_borrowed_attrs.clear();
3018
3019 Ok(())
3020 }
3021
3022 #[inline]
3023 pub(crate) fn component_owns_css_tree(component: &str, protocol: &WebUIProtocol) -> bool {
3024 !protocol.style_closures.is_empty()
3025 && protocol
3026 .components
3027 .get(component)
3028 .is_some_and(|data| data.uses_shadow_dom)
3029 }
3030
3031 pub(crate) fn push_shadow_style_root(
3032 component: &str,
3033 context: &mut WebUIProcessContext,
3034 ) -> Result<()> {
3035 if !context.protocol.style_closures.contains_key(component) {
3036 return Err(HandlerError::Invariant(format!(
3037 "component style closure metadata is missing Shadow root `{component}`"
3038 )));
3039 }
3040 let root_index = context
3041 .component_index
3042 .get(component)
3043 .copied()
3044 .ok_or_else(|| {
3045 HandlerError::Invariant(format!(
3046 "Shadow component `{component}` is missing its protocol index"
3047 ))
3048 })?;
3049 context.shadow_style_roots.push(ShadowStyleRoot {
3050 component_index: root_index,
3051 static_closure_emitted: false,
3052 routed_resources: Vec::new(),
3053 });
3054 Ok(())
3055 }
3056
3057 pub(crate) fn pop_shadow_style_root(
3058 component: &str,
3059 context: &mut WebUIProcessContext,
3060 ) -> Result<()> {
3061 context.shadow_style_roots.pop().ok_or_else(|| {
3062 HandlerError::Invariant(format!(
3063 "Shadow component `{component}` lost its active style root"
3064 ))
3065 })?;
3066 Ok(())
3067 }
3068
3069 fn resolve_value_owned(
3071 &self,
3072 path: &str,
3073 context: &WebUIProcessContext<'_, '_, '_>,
3074 ) -> Option<Value> {
3075 resolve_value_from_sources(
3076 path,
3077 &context.loop_vars,
3078 context.visible_loop_scope,
3079 LocalValueSources {
3080 owned: &context.local_vars,
3081 borrowed: &context.local_borrowed_vars,
3082 },
3083 context.state,
3084 )
3085 .map(Cow::into_owned)
3086 }
3087
3088 fn evaluate_condition(
3095 &self,
3096 condition: &webui_protocol::ConditionExpr,
3097 context: &WebUIProcessContext,
3098 ) -> Result<bool> {
3099 let loop_vars = &context.loop_vars;
3100 let visible_loop_scope = context.visible_loop_scope;
3101 let local_values = LocalValueSources {
3102 owned: &context.local_vars,
3103 borrowed: &context.local_borrowed_vars,
3104 };
3105 let state = context.state;
3106 match evaluate_with_resolver(condition, |path| {
3107 resolve_value_from_sources(path, loop_vars, visible_loop_scope, local_values, state)
3108 }) {
3109 Ok(result) => Ok(result),
3110 Err(ExpressionError::MissingValue(_)) => Ok(false),
3111 Err(e) => Err(HandlerError::Evaluation(e.to_string())),
3112 }
3113 }
3114
3115 fn process_for_loop<'protocol, 'state>(
3121 &self,
3122 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3123 target: Option<usize>,
3124 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3125 ) -> Result<()> {
3126 if let Some(items) = resolve_borrowed_collection(
3127 &for_loop.collection,
3128 &context.loop_vars,
3129 context.visible_loop_scope,
3130 LocalValueSources {
3131 owned: &context.local_vars,
3132 borrowed: &context.local_borrowed_vars,
3133 },
3134 context.state,
3135 ) {
3136 return self.process_borrowed_for_loop(for_loop, target, items, context);
3137 }
3138 self.process_owned_for_loop(for_loop, target, context)
3139 }
3140
3141 fn process_borrowed_for_loop<'protocol, 'state>(
3142 &self,
3143 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3144 target: Option<usize>,
3145 items: &'state [Value],
3146 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3147 ) -> Result<()> {
3148 if let Some(plugin) = &mut context.plugin {
3149 plugin.on_for_start(&for_loop.fragment_id, context.writer)?;
3150 }
3151
3152 let item_name = for_loop.item.as_str();
3153 let saved_value = context.local_vars.remove(item_name);
3154 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3155 let saved_scope = context.visible_loop_scope;
3156 for (index, item) in items.iter().enumerate() {
3157 if let Some(plugin) = &mut context.plugin {
3158 plugin.on_repeat_item_start(index, context.writer)?;
3159 plugin.push_scope();
3160 }
3161
3162 context.loop_vars.push(LoopBinding {
3163 name: item_name,
3164 value: item,
3165 });
3166 context.visible_loop_scope.end = context.loop_vars.len();
3167 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3168 context.loop_vars.pop();
3169 context.visible_loop_scope = saved_scope;
3170
3171 if let Some(plugin) = &mut context.plugin {
3172 plugin.pop_scope();
3173 plugin.on_repeat_item_end(index, context.writer)?;
3174 }
3175 }
3176 if let Some(value) = saved_value {
3177 context.local_vars.insert(item_name.to_string(), value);
3178 }
3179 if let Some(value) = saved_borrowed_value {
3180 context.local_borrowed_vars.insert(item_name, value);
3181 }
3182
3183 if let Some(plugin) = &mut context.plugin {
3184 plugin.on_for_end(&for_loop.fragment_id, context.writer)?;
3185 }
3186 Ok(())
3187 }
3188
3189 fn process_owned_for_loop<'protocol, 'state>(
3190 &self,
3191 for_loop: &'protocol webui_protocol::WebUIFragmentFor,
3192 target: Option<usize>,
3193 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3194 ) -> Result<()> {
3195 let collection_name = &for_loop.collection;
3196
3197 let items = match self.resolve_value_owned(collection_name, context) {
3200 Some(Value::Array(arr)) => arr,
3201 Some(_) => {
3202 return Err(HandlerError::TypeError(format!(
3203 "Collection '{}' is not an array",
3204 collection_name
3205 )))
3206 }
3207 None => Vec::new(),
3208 };
3209
3210 if let Some(p) = &mut context.plugin {
3211 p.on_for_start(&for_loop.fragment_id, context.writer)?;
3212 }
3213
3214 let item_name = for_loop.item.as_str();
3222 let saved_value = context.local_vars.remove(item_name);
3223 let saved_borrowed_value = context.local_borrowed_vars.remove(item_name);
3224 if !items.is_empty() {
3228 context
3229 .local_vars
3230 .insert(item_name.to_string(), Value::Null);
3231 }
3232 for (i, item) in items.into_iter().enumerate() {
3233 if let Some(p) = &mut context.plugin {
3234 p.on_repeat_item_start(i, context.writer)?;
3235 p.push_scope();
3236 }
3237
3238 if let Some(slot) = context.local_vars.get_mut(item_name) {
3240 *slot = item;
3241 }
3242 self.process_fragment_target(target, &for_loop.fragment_id, context)?;
3243
3244 if let Some(p) = &mut context.plugin {
3245 p.pop_scope();
3246 p.on_repeat_item_end(i, context.writer)?;
3247 }
3248 }
3249 match saved_value {
3251 Some(value) => {
3252 context.local_vars.insert(item_name.to_string(), value);
3253 }
3254 None => {
3255 context.local_vars.remove(item_name);
3256 }
3257 }
3258 if let Some(value) = saved_borrowed_value {
3259 context.local_borrowed_vars.insert(item_name, value);
3260 }
3261
3262 if let Some(p) = &mut context.plugin {
3263 p.on_for_end(&for_loop.fragment_id, context.writer)?;
3264 }
3265
3266 Ok(())
3267 }
3268
3269 #[inline]
3275 fn process_signal<'data>(
3276 &self,
3277 signal: &'data webui_protocol::WebUIFragmentSignal,
3278 context: &mut WebUIProcessContext<'data, '_, '_>,
3279 ) -> Result<()> {
3280 if signal.raw {
3281 self.process_raw_signal(signal, context)
3282 } else {
3283 self.process_state_signal(signal, false, context)
3284 }
3285 }
3286
3287 #[inline(never)]
3288 fn process_raw_signal<'data>(
3289 &self,
3290 signal: &'data webui_protocol::WebUIFragmentSignal,
3291 context: &mut WebUIProcessContext<'data, '_, '_>,
3292 ) -> Result<()> {
3293 let Some(structural_value) = structural_signal_value(signal) else {
3294 return self.process_state_signal(signal, !signal.raw_text_context, context);
3295 };
3296
3297 if let Some(root) = structural_value.strip_prefix("shadow_styles:") {
3298 return self.process_shadow_style_signal(root, context);
3299 }
3300
3301 if context.streaming.is_some()
3302 && self.process_streaming_signal(structural_value, context)?
3303 {
3304 return Ok(());
3305 }
3306
3307 if structural_value == "head_end" && !context.head_end_emitted {
3311 context.head_end_emitted = true;
3312 if let Some(nonce) = context.nonce {
3313 context
3314 .writer
3315 .write("<meta name=\"webui-nonce\" content=\"")?;
3316 context
3317 .writer
3318 .write(&crate::html_encode::encode_safe(nonce))?;
3319 context.writer.write("\">")?;
3320 }
3321
3322 if !context.protocol.component_render_css.is_empty() {
3325 context.writer.write("<style data-webui-render-policy")?;
3326 if let Some(nonce) = context.nonce {
3327 context.writer.write(" nonce=\"")?;
3328 context
3329 .writer
3330 .write(&crate::html_encode::encode_safe(nonce))?;
3331 context.writer.write("\"")?;
3332 }
3333 context.writer.write(">")?;
3334 context
3335 .writer
3336 .write(&context.protocol.component_render_css)?;
3337 context.writer.write("</style>")?;
3338 }
3339
3340 if !context.protocol.style_closures.is_empty() {
3341 let entry_id = context.entry_id;
3342 self.emit_component_style_closure(entry_id, StyleClosureInstall::Static, context)?;
3343 let mut preloaded = Vec::new();
3344 self.emit_active_route_styles(&mut preloaded, context)?;
3345 self.emit_reachable_shadow_preloads(&mut preloaded, context)?;
3346 }
3347
3348 if !context.protocol.module_preloads.is_empty() {
3360 for href in &context.protocol.module_preloads {
3361 context
3362 .writer
3363 .write("<link rel=\"modulepreload\" href=\"")?;
3364 context.writer.write(href)?;
3365 context.writer.write("\">")?;
3366 }
3367 }
3368
3369 write_component_asset_styles(context)?;
3370
3371 if let Some(html) = context.head_inject {
3377 context.writer.write(html)?;
3378 }
3379
3380 if let Some(html) = context.state_inject.head_end {
3384 context.writer.write(html)?;
3385 }
3386 }
3387
3388 if structural_value == "body_start" && !context.body_start_emitted {
3391 context.body_start_emitted = true;
3392 write_component_asset_styles(context)?;
3393 if let Some(html) = context.state_inject.body_start {
3394 context.writer.write(html)?;
3395 }
3396 }
3397
3398 if structural_value == "body_end" && !context.body_end_emitted {
3403 context.body_end_emitted = true;
3404 if context.plugin.is_some() {
3405 let reachable = context.reachable_components.take().unwrap_or_else(|| {
3418 crate::route_handler::collect_reachable_component_order_for_request(
3419 context.protocol,
3420 context.entry_id,
3421 context.request_path,
3422 context.route_index,
3423 )
3424 });
3425 let state_selection =
3426 collect_hydration_state(context.protocol, reachable.iter().map(String::as_str));
3427
3428 if context.css_strategy == webui_protocol::CssStrategy::Module {
3432 for name in &reachable {
3433 if !context.rendered_components.contains(name) {
3434 if let Some(css) = context
3435 .protocol
3436 .components
3437 .get(name)
3438 .map(|c| c.css.as_str())
3439 .filter(|s| !s.is_empty())
3440 {
3441 self.emit_css_module_importmap(name, css, context)?;
3442 }
3443 }
3444 }
3445 }
3446
3447 let template_payloads = context
3451 .plugin
3452 .as_ref()
3453 .and_then(|p| p.collect_template_payloads(context.protocol, &reachable));
3454
3455 if template_payloads.is_none() {
3456 if let Some(ref p) = context.plugin {
3458 p.emit_templates(
3459 context.protocol,
3460 &reachable,
3461 context.nonce,
3462 context.writer,
3463 )?;
3464 }
3465 }
3466
3467 let inventory_hex = crate::route_handler::encode_component_inventory(
3469 &context.rendered_components,
3470 context.component_index,
3471 );
3472
3473 Self::ensure_request_route_chain(context);
3475 let chain = context.route_chain.as_deref().ok_or_else(|| {
3476 HandlerError::Invariant(
3477 "request route chain disappeared after collection".to_string(),
3478 )
3479 })?;
3480 let chain_json: Vec<Value> = chain
3481 .iter()
3482 .map(crate::route_handler::RouteChainEntry::to_json)
3483 .collect();
3484
3485 let is_link = context.css_strategy == webui_protocol::CssStrategy::Link;
3487 let mut css_hrefs: Vec<&str> = Vec::new();
3488 if is_link {
3489 for name in &reachable {
3490 if let Some(href) = context
3491 .protocol
3492 .components
3493 .get(name)
3494 .map(|c| c.css_href.as_str())
3495 .filter(|h| !h.is_empty())
3496 {
3497 css_hrefs.push(href);
3498 }
3499 }
3500 }
3501
3502 let mut style_specs: Vec<&str> = Vec::new();
3504 if context.css_strategy == webui_protocol::CssStrategy::Module {
3505 for name in &reachable {
3506 if context
3507 .protocol
3508 .components
3509 .get(name)
3510 .map(|c| !c.css.is_empty())
3511 .unwrap_or(false)
3512 {
3513 style_specs.push(name);
3514 }
3515 }
3516 }
3517
3518 let empty_payloads: [WebUiTemplatePayload<'_>; 0] = [];
3519 let payloads = template_payloads.as_deref().unwrap_or(&empty_payloads);
3520 let mut style_roots = Vec::with_capacity(reachable.len() + chain.len() * 3 + 1);
3521 style_roots.push(context.entry_id);
3522 for entry in chain {
3523 style_roots.push(entry.component.as_str());
3524 if !entry.pending_component.is_empty() {
3525 style_roots.push(entry.pending_component.as_str());
3526 }
3527 if !entry.error_component.is_empty() {
3528 style_roots.push(entry.error_component.as_str());
3529 }
3530 }
3531 style_roots.extend(reachable.iter().map(String::as_str));
3532 let component_styles =
3533 crate::route_handler::collect_component_styles(context.protocol, style_roots)?;
3534 write_webui_data_block(
3535 context.writer,
3536 &mut context.json_scratch,
3537 WebUiBootstrap {
3538 declaration_id: None,
3539 enclosing_span_instance_id: None,
3540 state: WebUiBootstrapState::Complete {
3541 value: context.state,
3542 selection: state_selection,
3543 },
3544 chain: &chain_json,
3545 inventory: &inventory_hex,
3546 nonce: context.nonce,
3547 css_hrefs: &css_hrefs,
3548 style_specs: &style_specs,
3549 component_styles: &component_styles,
3550 templates: payloads,
3551 },
3552 )?;
3553
3554 if let Some(ref plugin) = context.plugin {
3558 plugin.emit_bootstrap_extension(
3559 BootstrapExtensionContext {
3560 protocol: context.protocol,
3561 components: &reachable,
3562 payloads,
3563 nonce: context.nonce,
3564 },
3565 context.writer,
3566 )?;
3567 }
3568 }
3569
3570 if let Some(html) = context.body_inject {
3577 context.writer.write(html)?;
3578 }
3579
3580 if let Some(html) = context.state_inject.body_end {
3583 context.writer.write(html)?;
3584 }
3585 }
3586
3587 Ok(())
3591 }
3592
3593 #[inline]
3605 fn process_state_signal(
3606 &self,
3607 signal: &webui_protocol::WebUIFragmentSignal,
3608 owns_html_range: bool,
3609 context: &mut WebUIProcessContext,
3610 ) -> Result<()> {
3611 if let Some(p) = &mut context.plugin {
3612 p.on_binding_start(&signal.value, owns_html_range, context.writer)?;
3613 }
3614
3615 if let Some(value) = resolve_value_from_sources(
3616 &signal.value,
3617 &context.loop_vars,
3618 context.visible_loop_scope,
3619 LocalValueSources {
3620 owned: &context.local_vars,
3621 borrowed: &context.local_borrowed_vars,
3622 },
3623 context.state,
3624 ) {
3625 self.write_signal_value(value.as_ref(), signal.raw, context.writer)?;
3626 }
3627
3628 if let Some(p) = &mut context.plugin {
3629 p.on_binding_end(&signal.value, owns_html_range, context.writer)?;
3630 }
3631 Ok(())
3632 }
3633
3634 fn write_signal_value(
3643 &self,
3644 value: &Value,
3645 raw: bool,
3646 writer: &mut dyn ResponseWriter,
3647 ) -> Result<()> {
3648 if write_plain_json_scalar(writer, value)? {
3651 return Ok(());
3652 }
3653 if raw {
3654 match value {
3655 Value::String(s) => writer.write(s),
3656 _ => writer.write(&value.to_string()),
3657 }
3658 } else {
3659 match value {
3660 Value::String(s) => writer.write(&crate::html_encode::encode_safe(s)),
3661 _ => {
3662 let s = value.to_string();
3663 writer.write(&crate::html_encode::encode_safe(&s))
3664 }
3665 }
3666 }
3667 }
3668
3669 fn process_if(
3671 &self,
3672 if_cond: &webui_protocol::WebUIFragmentIf,
3673 target: Option<usize>,
3674 context: &mut WebUIProcessContext,
3675 ) -> Result<()> {
3676 let condition = if_cond
3677 .condition
3678 .as_ref()
3679 .ok_or_else(|| HandlerError::Rendering("If fragment missing condition".to_string()))?;
3680 let condition_met = self.evaluate_condition(condition, context)?;
3681
3682 if let Some(p) = &mut context.plugin {
3683 p.on_if_start(&if_cond.fragment_id, context.writer)?;
3684 }
3685
3686 if condition_met {
3687 if let Some(p) = &mut context.plugin {
3688 p.push_scope();
3689 }
3690
3691 self.process_fragment_target(target, &if_cond.fragment_id, context)?;
3692
3693 if let Some(p) = &mut context.plugin {
3694 p.pop_scope();
3695 }
3696 }
3697
3698 if let Some(p) = &mut context.plugin {
3699 p.on_if_end(&if_cond.fragment_id, context.writer)?;
3700 }
3701
3702 Ok(())
3703 }
3704
3705 fn process_attribute<'protocol, 'state>(
3711 &self,
3712 attr: &'protocol webui_protocol::WebUIFragmentAttribute,
3713 template_target: Option<usize>,
3714 component_name: Option<&'protocol str>,
3715 context: &mut WebUIProcessContext<'protocol, 'state, '_>,
3716 ) -> Result<()> {
3717 if attr.attr_start {
3720 context.component_attrs.clear();
3721 context.component_borrowed_attrs.clear();
3722 context.collecting_component_attrs = true;
3723 }
3724
3725 if let Some(condition) = &attr.condition_tree {
3727 let condition_met = self.evaluate_condition(condition, context)?;
3728
3729 if context.collecting_component_attrs && !attr.attr_skip {
3730 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3731 context.component_borrowed_attrs.remove(name);
3732 context
3733 .component_attrs
3734 .insert(name.to_owned(), Value::Bool(condition_met));
3735 }
3736
3737 if condition_met {
3738 context.writer.write(" ")?;
3739 context.writer.write(&attr.name)?;
3740 }
3741 return Ok(());
3742 }
3743
3744 if !attr.template.is_empty() {
3746 let raw_value =
3747 self.render_template_attr_value(&attr.template, template_target, context)?;
3748 let escaped = crate::html_encode::encode_safe(&raw_value);
3749 write_attr(context.writer, &attr.name, &escaped)?;
3750
3751 if context.collecting_component_attrs && !attr.attr_skip {
3752 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3753 context.component_borrowed_attrs.remove(name);
3754 context
3755 .component_attrs
3756 .insert(name.to_owned(), Value::String(raw_value));
3757 }
3758 return Ok(());
3759 }
3760
3761 if attr.raw_value || !attr.value.is_empty() {
3763 if attr.raw_value {
3764 write_attr(
3766 context.writer,
3767 &attr.name,
3768 &crate::html_encode::encode_safe(&attr.value),
3769 )?;
3770 if context.collecting_component_attrs && !attr.attr_skip {
3771 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3772 context.component_borrowed_attrs.remove(name);
3773 context
3774 .component_attrs
3775 .insert(name.to_owned(), Value::String(attr.value.clone()));
3776 }
3777 } else if attr.complex {
3778 if context.collecting_component_attrs && !attr.attr_skip {
3780 let state_backed_value = resolve_state_backed_value(
3785 &attr.value,
3786 &context.loop_vars,
3787 context.visible_loop_scope,
3788 LocalValueSources {
3789 owned: &context.local_vars,
3790 borrowed: &context.local_borrowed_vars,
3791 },
3792 context.state,
3793 );
3794 if let Some(value) = state_backed_value {
3795 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3796 context.component_attrs.remove(name);
3797 context.component_borrowed_attrs.insert(name, value);
3798 } else if let Some(value) = self.resolve_value_owned(&attr.value, context) {
3799 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3800 context.component_borrowed_attrs.remove(name);
3801 context.component_attrs.insert(name.to_owned(), value);
3802 }
3803 }
3804 } else {
3805 let state_backed_value = if context.collecting_component_attrs && !attr.attr_skip {
3809 resolve_state_backed_value(
3810 &attr.value,
3811 &context.loop_vars,
3812 context.visible_loop_scope,
3813 LocalValueSources {
3814 owned: &context.local_vars,
3815 borrowed: &context.local_borrowed_vars,
3816 },
3817 context.state,
3818 )
3819 } else {
3820 None
3821 };
3822 let value = match state_backed_value {
3825 Some(value) => Some(Cow::Borrowed(value)),
3826 None => resolve_value_from_sources(
3827 &attr.value,
3828 &context.loop_vars,
3829 context.visible_loop_scope,
3830 LocalValueSources {
3831 owned: &context.local_vars,
3832 borrowed: &context.local_borrowed_vars,
3833 },
3834 context.state,
3835 ),
3836 };
3837 match value.as_deref() {
3840 Some(Value::String(s)) => {
3841 write_attr(
3842 context.writer,
3843 &attr.name,
3844 &crate::html_encode::encode_safe(s),
3845 )?;
3846 }
3847 Some(Value::Null) | None => {
3848 write_attr(context.writer, &attr.name, "")?;
3849 }
3850 Some(other) => {
3851 let mut scalar = ScalarBuffer::new();
3852 match format_plain_json_scalar(&mut scalar, other) {
3853 Some(rendered) => {
3854 write_attr(context.writer, &attr.name, rendered)?;
3855 }
3856 None => {
3857 let s = other.to_string();
3858 write_attr(
3859 context.writer,
3860 &attr.name,
3861 &crate::html_encode::encode_safe(&s),
3862 )?;
3863 }
3864 }
3865 }
3866 }
3867
3868 if context.collecting_component_attrs && !attr.attr_skip {
3869 if let Some(borrowed) = state_backed_value {
3870 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3871 context.component_attrs.remove(name);
3872 context.component_borrowed_attrs.insert(name, borrowed);
3873 } else {
3874 let name = component_name.ok_or_else(missing_component_attr_name_error)?;
3875 context.component_borrowed_attrs.remove(name);
3876 context.component_attrs.insert(
3877 name.to_owned(),
3878 value
3879 .map(Cow::into_owned)
3880 .unwrap_or(Value::String(String::new())),
3881 );
3882 }
3883 }
3884 }
3885 }
3886
3887 Ok(())
3888 }
3889
3890 fn render_template_attr_value(
3892 &self,
3893 template_id: &str,
3894 target: Option<usize>,
3895 context: &WebUIProcessContext,
3896 ) -> Result<String> {
3897 let Some(index) = target.or_else(|| context.render_fragments.index(template_id)) else {
3898 return Err(HandlerError::MissingFragment(template_id.to_string()));
3899 };
3900 let fragments = context
3901 .render_fragments
3902 .list(index)
3903 .ok_or_else(|| HandlerError::MissingFragment(template_id.to_string()))?;
3904 let mut raw_value = String::new();
3905 for frag in fragments.fragments {
3906 match frag.fragment.as_ref() {
3907 Some(Fragment::Raw(raw)) => raw_value.push_str(&raw.value),
3908 Some(Fragment::Signal(signal)) => {
3909 if let Some(value) = resolve_value_from_sources(
3910 &signal.value,
3911 &context.loop_vars,
3912 context.visible_loop_scope,
3913 LocalValueSources {
3914 owned: &context.local_vars,
3915 borrowed: &context.local_borrowed_vars,
3916 },
3917 context.state,
3918 ) {
3919 match value.as_ref() {
3920 Value::String(s) => raw_value.push_str(s),
3921 other => {
3922 let mut scalar = ScalarBuffer::new();
3923 match format_plain_json_scalar(&mut scalar, other) {
3924 Some(rendered) => raw_value.push_str(rendered),
3925 None => raw_value.push_str(&other.to_string()),
3926 }
3927 }
3928 }
3929 }
3930 }
3931 _ => {}
3932 }
3933 }
3934 Ok(raw_value)
3935 }
3936
3937 pub fn render<'a>(
3939 &self,
3940 protocol: &'a Protocol,
3941 state: &'a Value,
3942 options: &RenderOptions<'a>,
3943 writer: &'a mut dyn ResponseWriter,
3944 ) -> Result<()> {
3945 protocol.ensure_style_metadata()?;
3946 let document = protocol.protocol();
3947 let render_fragments = protocol.render_fragments().resolve(document);
3948 let Some(entry_index) = render_fragments.index(options.entry_id) else {
3949 return Err(HandlerError::MissingFragment(options.entry_id.to_string()));
3950 };
3951 let entry = render_fragments
3952 .list(entry_index)
3953 .ok_or_else(|| HandlerError::MissingFragment(options.entry_id.to_string()))?;
3954 let entry_owns_css_tree = Self::component_owns_css_tree(options.entry_id, document);
3955 let has_document_head_boundary = !entry_owns_css_tree
3956 && !document.style_closures.is_empty()
3957 && entry.fragments.iter().any(|fragment| {
3958 matches!(
3959 fragment.fragment.as_ref(),
3960 Some(Fragment::Signal(signal))
3961 if structural_signal_value(signal) == Some("head_end")
3962 )
3963 });
3964 let doctype_split = (!entry_owns_css_tree
3965 && !has_document_head_boundary
3966 && !document.style_closures.is_empty())
3967 .then(|| {
3968 let Some(Fragment::Raw(raw)) = entry
3969 .fragments
3970 .first()
3971 .and_then(|fragment| fragment.fragment.as_ref())
3972 else {
3973 return None;
3974 };
3975 doctype_prefix_end(&raw.value).map(|end| (raw.value.as_str(), end))
3976 })
3977 .flatten();
3978 let component_asset_style_manifest = protocol.component_asset_style_manifest()?;
3979 let mut context = WebUIProcessContext {
3980 protocol: document,
3981 render_fragments,
3982 component_asset_style_manifest,
3983 component_asset_style_links: protocol.component_asset_style_links(),
3984 state,
3985 writer,
3986 local_vars: HashMap::new(),
3987 local_borrowed_vars: BorrowedScope::default(),
3988 loop_vars: Vec::new(),
3989 visible_loop_scope: VisibleLoopScope::EMPTY,
3990 component_attrs: HashMap::new(),
3991 component_borrowed_attrs: BorrowedScope::default(),
3992 collecting_component_attrs: false,
3993 request_path: options.request_path,
3994 route_base: Cow::Borrowed("/"),
3995 rendered_components: HashSet::new(),
3996 plugin: self.plugin_factory.map(|f| f()),
3997 route_children: Cow::Borrowed(&[]),
3998 entry_id: options.entry_id,
3999 nonce: options.nonce.filter(|s| !s.is_empty()),
4002 head_inject: options.head_inject.filter(|s| !s.is_empty()),
4003 body_inject: options.body_inject.filter(|s| !s.is_empty()),
4004 state_inject: StateInject::resolve(state),
4005 head_end_emitted: false,
4006 body_start_emitted: false,
4007 component_asset_styles_emitted: false,
4008 component_index: protocol.component_index(),
4009 style_resource_index: protocol.style_resource_index(),
4010 style_resources_requiring_escape: protocol.style_resources_requiring_escape(),
4011 style_chunk_index: protocol.protocol().style_chunk_index(),
4012 css_strategy: protocol.css_strategy(),
4013 body_end_emitted: false,
4014 route_index: protocol.route_index(),
4015 route_chain_index: 0,
4016 route_chain: None,
4017 route_document_style_targets: Vec::new(),
4018 reachable_components: None,
4019 streaming: None,
4020 json_scratch: Vec::new(),
4021 scope_pool: Vec::new(),
4022 document_style_resources: HashSet::new(),
4023 shadow_style_roots: Vec::new(),
4024 borrowed_scope_pool: Vec::new(),
4025 };
4026
4027 if entry_owns_css_tree {
4028 Self::push_shadow_style_root(options.entry_id, &mut context)?;
4029 }
4030
4031 let render_result = if let Some((first_raw, split)) = doctype_split {
4032 context.writer.write(&first_raw[..split])?;
4033 self.emit_component_style_closure(
4034 options.entry_id,
4035 StyleClosureInstall::Static,
4036 &mut context,
4037 )?;
4038 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4039 context.writer.write(&first_raw[split..])?;
4040 self.process_fragment_from(entry, 1, &mut context)
4041 } else {
4042 if !entry_owns_css_tree
4043 && !has_document_head_boundary
4044 && !document.style_closures.is_empty()
4045 {
4046 self.emit_component_style_closure(
4047 options.entry_id,
4048 StyleClosureInstall::Static,
4049 &mut context,
4050 )?;
4051 self.emit_active_route_styles(&mut Vec::new(), &mut context)?;
4052 }
4053 self.process_fragment(entry, &mut context)
4054 };
4055
4056 if entry_owns_css_tree {
4057 Self::pop_shadow_style_root(options.entry_id, &mut context)?;
4058 }
4059 render_result?;
4060 writer.end()?;
4061
4062 Ok(())
4063 }
4064}
4065
4066impl Default for WebUIHandler {
4067 fn default() -> Self {
4068 Self::new()
4069 }
4070}
4071
4072fn write_attr(writer: &mut dyn ResponseWriter, name: &str, value: &str) -> Result<()> {
4074 writer.write_attribute(name, value)
4075}
4076
4077#[cfg(test)]
4078fn handle(
4079 protocol: &WebUIProtocol,
4080 state: &Value,
4081 options: &RenderOptions<'_>,
4082 writer: &mut dyn ResponseWriter,
4083) -> Result<()> {
4084 let handler = WebUIHandler::new();
4085 handler.handle(protocol, state, options, writer)
4086}
4087
4088#[cfg(test)]
4089mod scalar_buffer_tests {
4090 use super::*;
4091
4092 #[test]
4095 fn plain_scalars_match_value_to_string() {
4096 let cases = [
4097 Value::Null,
4098 Value::Bool(true),
4099 Value::Bool(false),
4100 serde_json::json!(0),
4101 serde_json::json!(-1),
4102 serde_json::json!(42),
4103 serde_json::json!(i64::MIN),
4104 serde_json::json!(i64::MAX),
4105 serde_json::json!(u64::MAX),
4106 serde_json::json!(0.5),
4107 serde_json::json!(-0.0_f64),
4108 serde_json::json!(1.7976931348623157e308_f64),
4109 serde_json::json!(-1.7976931348623157e308_f64),
4110 serde_json::json!(f64::MIN_POSITIVE),
4111 ];
4112 for value in cases {
4113 let mut buffer = ScalarBuffer::new();
4114 let rendered = format_plain_json_scalar(&mut buffer, &value);
4115 assert_eq!(
4116 rendered,
4117 Some(value.to_string().as_str()),
4118 "scalar rendering diverged for {value:?}"
4119 );
4120 }
4121 }
4122
4123 #[test]
4126 fn non_plain_values_are_declined() {
4127 let cases = [
4128 serde_json::json!("<script>"),
4129 serde_json::json!([1, 2]),
4130 serde_json::json!({ "a": 1 }),
4131 ];
4132 for value in cases {
4133 let mut buffer = ScalarBuffer::new();
4134 assert_eq!(
4135 format_plain_json_scalar(&mut buffer, &value),
4136 None,
4137 "expected fallback for {value:?}"
4138 );
4139 }
4140 }
4141
4142 #[test]
4144 fn overflow_is_reported_instead_of_truncating() {
4145 let mut buffer = ScalarBuffer::new();
4146 assert!(write!(&mut buffer, "{}", "x".repeat(49)).is_err());
4147 }
4148}
4149
4150#[cfg(test)]
4151mod tests {
4152 use super::*;
4153 use crate::streaming::STREAMING_MARKER;
4154 use std::cell::RefCell;
4155 use webui_parser::{ComponentRegistration, DomStrategy, HtmlParser};
4156 use webui_protocol::{
4157 web_ui_fragment, ComparisonOperator, ConditionExpr, FragmentList, LogicalOperator,
4158 WebUIFragmentAttribute, WebUiFragmentRoute,
4159 };
4160 use webui_test_utils::test_json;
4161
4162 fn structural_fragment(value: impl AsRef<str>) -> WebUIFragment {
4163 WebUIFragment::signal(
4164 format!("{STRUCTURAL_SIGNAL_PREFIX}{}", value.as_ref()),
4165 true,
4166 )
4167 }
4168
4169 #[test]
4170 fn route_descent_borrows_protocol_children() {
4171 let routes = vec![WebUiFragmentRoute {
4172 children: vec![WebUiFragmentRoute {
4173 fragment_id: "child".to_string(),
4174 ..Default::default()
4175 }],
4176 ..Default::default()
4177 }];
4178 let mut level = RouteChildren::Borrowed(&routes);
4179
4180 let descended = descend_into(&mut level, 0);
4181
4182 let Cow::Borrowed(children) = descended else {
4183 panic!("protocol-owned route children should remain borrowed");
4184 };
4185 assert!(std::ptr::eq(children, routes[0].children.as_slice()));
4186 }
4187
4188 #[test]
4189 fn route_descent_moves_parked_children() {
4190 let mut level = RouteChildren::Owned(vec![WebUiFragmentRoute {
4191 children: vec![WebUiFragmentRoute {
4192 fragment_id: "child".to_string(),
4193 ..Default::default()
4194 }],
4195 ..Default::default()
4196 }]);
4197
4198 let descended = descend_into(&mut level, 0);
4199
4200 let Cow::Owned(children) = descended else {
4201 panic!("parked route children must not borrow session-owned storage");
4202 };
4203 assert_eq!(children[0].fragment_id, "child");
4204 let Cow::Owned(source) = level else {
4205 panic!("the parked source level must stay owned");
4206 };
4207 assert!(
4208 source[0].children.is_empty(),
4209 "owned route children must move out instead of being cloned"
4210 );
4211 }
4212
4213 #[test]
4214 fn borrowed_collection_reuses_state_array() {
4215 let state = test_json!({
4216 "items": [
4217 {"name": "first"},
4218 {"name": "second"}
4219 ]
4220 });
4221 let local_vars = HashMap::new();
4222 let local_borrowed_vars = BorrowedScope::default();
4223 let loop_vars = Vec::new();
4224 let items = resolve_borrowed_collection(
4225 "items",
4226 &loop_vars,
4227 VisibleLoopScope::EMPTY,
4228 LocalValueSources {
4229 owned: &local_vars,
4230 borrowed: &local_borrowed_vars,
4231 },
4232 &state,
4233 )
4234 .unwrap_or_else(|| panic!("state array should be borrowed"));
4235 let state_items = state["items"]
4236 .as_array()
4237 .unwrap_or_else(|| panic!("fixture items should be an array"));
4238 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4239 }
4240
4241 #[test]
4242 fn owned_local_collection_keeps_precedence() {
4243 let state = test_json!({"items": [{"name": "global"}]});
4244 let local_vars = HashMap::from([("items".to_string(), test_json!([{"name": "local"}]))]);
4245 let local_borrowed_vars = BorrowedScope::default();
4246 assert!(resolve_borrowed_collection(
4247 "items",
4248 &[],
4249 VisibleLoopScope::EMPTY,
4250 LocalValueSources {
4251 owned: &local_vars,
4252 borrowed: &local_borrowed_vars,
4253 },
4254 &state,
4255 )
4256 .is_none());
4257 }
4258
4259 #[test]
4260 fn borrowed_component_collection_reuses_state_array() {
4261 let state = test_json!({"teams": [{"contacts": [{"name": "Avery"}]}]});
4262 let contacts = &state["teams"][0]["contacts"];
4263 let mut local_borrowed_vars = BorrowedScope::default();
4264 local_borrowed_vars.insert("contacts", contacts);
4265 let local_vars = HashMap::new();
4266 let items = resolve_borrowed_collection(
4267 "contacts",
4268 &[],
4269 VisibleLoopScope::EMPTY,
4270 LocalValueSources {
4271 owned: &local_vars,
4272 borrowed: &local_borrowed_vars,
4273 },
4274 &state,
4275 )
4276 .unwrap_or_else(|| panic!("component collection should stay borrowed"));
4277 let state_items = contacts
4278 .as_array()
4279 .unwrap_or_else(|| panic!("fixture contacts should be an array"));
4280 assert!(std::ptr::eq(items.as_ptr(), state_items.as_ptr()));
4281 }
4282
4283 #[test]
4284 fn nested_borrowed_collection_reuses_child_array() {
4285 let state = test_json!({
4286 "items": [{
4287 "children": [
4288 {"name": "first"},
4289 {"name": "second"}
4290 ]
4291 }]
4292 });
4293 let item = &state["items"][0];
4294 let loop_vars = vec![LoopBinding {
4295 name: "item",
4296 value: item,
4297 }];
4298 let local_vars = HashMap::new();
4299 let local_borrowed_vars = BorrowedScope::default();
4300 let children = resolve_borrowed_collection(
4301 "item.children",
4302 &loop_vars,
4303 VisibleLoopScope { start: 0, end: 1 },
4304 LocalValueSources {
4305 owned: &local_vars,
4306 borrowed: &local_borrowed_vars,
4307 },
4308 &state,
4309 )
4310 .unwrap_or_else(|| panic!("nested state array should be borrowed"));
4311 let state_children = item["children"]
4312 .as_array()
4313 .unwrap_or_else(|| panic!("fixture children should be an array"));
4314 assert!(std::ptr::eq(children.as_ptr(), state_children.as_ptr()));
4315 }
4316
4317 #[test]
4318 fn borrowed_resolver_preserves_loop_precedence_and_component_isolation() {
4319 let state = test_json!({
4320 "item": {"name": "global", "fallback": "global fallback"},
4321 "outer": {"name": "outer"},
4322 "inner": {"name": "inner"}
4323 });
4324 let loop_vars = vec![
4325 LoopBinding {
4326 name: "item",
4327 value: &state["outer"],
4328 },
4329 LoopBinding {
4330 name: "item",
4331 value: &state["inner"],
4332 },
4333 ];
4334 let local_vars = HashMap::new();
4335 let local_borrowed_vars = BorrowedScope::default();
4336 let sources = LocalValueSources {
4337 owned: &local_vars,
4338 borrowed: &local_borrowed_vars,
4339 };
4340
4341 assert_eq!(
4342 resolve_value_from_sources(
4343 "item.name",
4344 &loop_vars,
4345 VisibleLoopScope { start: 0, end: 2 },
4346 sources,
4347 &state,
4348 )
4349 .as_deref()
4350 .and_then(Value::as_str),
4351 Some("inner")
4352 );
4353 assert_eq!(
4354 resolve_value_from_sources(
4355 "item.fallback",
4356 &loop_vars,
4357 VisibleLoopScope { start: 0, end: 2 },
4358 sources,
4359 &state,
4360 )
4361 .as_deref()
4362 .and_then(Value::as_str),
4363 Some("global fallback")
4364 );
4365 assert_eq!(
4366 resolve_value_from_sources(
4367 "item.name",
4368 &loop_vars,
4369 VisibleLoopScope { start: 2, end: 2 },
4370 sources,
4371 &state,
4372 )
4373 .as_deref()
4374 .and_then(Value::as_str),
4375 Some("global")
4376 );
4377 }
4378
4379 #[test]
4380 fn state_backed_resolver_matches_general_borrowed_resolution() {
4381 let state = test_json!({
4382 "global": {"name": "global"},
4383 "inner": {"fallback": "global fallback"},
4384 "borrowed": {"name": "borrowed"},
4385 "items": [1, 2, 3]
4386 });
4387 let loop_values = test_json!({
4388 "outer": {"name": "outer"},
4389 "inner": {"name": "inner"}
4390 });
4391 let loop_vars = vec![
4392 LoopBinding {
4393 name: "outer",
4394 value: &loop_values["outer"],
4395 },
4396 LoopBinding {
4397 name: "inner",
4398 value: &loop_values["inner"],
4399 },
4400 ];
4401 let mut local_borrowed_vars = BorrowedScope::default();
4402 local_borrowed_vars.insert("borrowed", &state["borrowed"]);
4403 let local_vars = HashMap::from([("owned".to_string(), test_json!({"name": "owned"}))]);
4404 let sources = LocalValueSources {
4405 owned: &local_vars,
4406 borrowed: &local_borrowed_vars,
4407 };
4408 let scope = VisibleLoopScope { start: 0, end: 2 };
4409
4410 for path in [
4411 "global.name",
4412 "inner.name",
4413 "inner.fallback",
4414 "outer.name",
4415 "borrowed.name",
4416 ] {
4417 let state_backed = resolve_state_backed_value(path, &loop_vars, scope, sources, &state)
4418 .unwrap_or_else(|| panic!("{path} should resolve from immutable state"));
4419 let resolved = resolve_value_from_sources(path, &loop_vars, scope, sources, &state)
4420 .unwrap_or_else(|| panic!("{path} should resolve generally"));
4421 let Cow::Borrowed(resolved) = resolved else {
4422 panic!("{path} should remain borrowed");
4423 };
4424 assert!(
4425 std::ptr::eq(state_backed, resolved),
4426 "{path} resolved from different sources"
4427 );
4428 }
4429
4430 assert!(
4431 resolve_state_backed_value("owned.name", &loop_vars, scope, sources, &state).is_none(),
4432 "owned local values must use the general resolver"
4433 );
4434 assert!(
4435 resolve_state_backed_value("items.length", &loop_vars, scope, sources, &state)
4436 .is_none(),
4437 "synthetic values must use the general resolver"
4438 );
4439 }
4440
4441 #[test]
4442 fn borrowed_scope_preserves_inline_overflow_and_replacement_semantics() {
4443 let values = [
4444 Value::String("a".to_string()),
4445 Value::String("b".to_string()),
4446 Value::String("c".to_string()),
4447 Value::String("d".to_string()),
4448 Value::String("e".to_string()),
4449 Value::String("f".to_string()),
4450 Value::String("replacement".to_string()),
4451 ];
4452 let mut scope = BorrowedScope::default();
4453 for (index, name) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
4454 assert!(scope.insert(name, &values[index]).is_none());
4455 }
4456 assert_eq!(scope.inline_len, INLINE_SCOPE_SLOTS);
4457 assert_eq!(scope.overflow.len(), 2);
4458 assert_eq!(scope.get("a"), Some(&values[0]));
4459 assert_eq!(scope.get("f"), Some(&values[5]));
4460 assert_eq!(scope.insert("a", &values[6]), Some(&values[0]));
4461 assert_eq!(scope.get("a"), Some(&values[6]));
4462 assert_eq!(scope.remove("b"), Some(&values[1]));
4463 assert!(scope.get("b").is_none());
4464 assert_eq!(scope.get("f"), Some(&values[5]));
4465 scope.clear();
4466 assert!(scope.get("a").is_none());
4467 assert!(scope.overflow.is_empty());
4468 }
4469
4470 struct TestWriter {
4472 content: RefCell<String>,
4473 ended: RefCell<bool>,
4474 }
4475
4476 impl TestWriter {
4477 fn new() -> Self {
4478 Self {
4479 content: RefCell::new(String::new()),
4480 ended: RefCell::new(false),
4481 }
4482 }
4483
4484 fn get_content(&self) -> String {
4485 self.content.borrow().clone()
4486 }
4487
4488 fn is_ended(&self) -> bool {
4489 *self.ended.borrow()
4490 }
4491 }
4492
4493 impl ResponseWriter for TestWriter {
4494 fn write(&mut self, content: &str) -> Result<()> {
4495 self.content.borrow_mut().push_str(content);
4496 Ok(())
4497 }
4498
4499 fn end(&mut self) -> Result<()> {
4500 *self.ended.borrow_mut() = true;
4501 Ok(())
4502 }
4503 }
4504
4505 #[test]
4506 fn generated_string_writer_methods_preserve_exact_attribute_output() {
4507 struct AttributeWriter {
4508 output: String,
4509 }
4510
4511 impl ResponseWriter for AttributeWriter {
4512 fn write(&mut self, content: &str) -> Result<()> {
4513 self.output.push_str(content);
4514 Ok(())
4515 }
4516
4517 crate::string_response_writer_methods!(output);
4518
4519 fn end(&mut self) -> Result<()> {
4520 Ok(())
4521 }
4522 }
4523
4524 let mut writer = AttributeWriter {
4525 output: String::new(),
4526 };
4527 write_attr(&mut writer, "data-id", "42")
4528 .unwrap_or_else(|error| panic!("attribute write failed: {error}"));
4529 writer
4530 .write_boolean_attribute("disabled")
4531 .unwrap_or_else(|error| panic!("boolean attribute write failed: {error}"));
4532 assert_eq!(writer.output, " data-id=\"42\" disabled");
4533 }
4534
4535 #[test]
4536 fn prepared_graph_resolves_targets_and_canonical_component_prop_names() {
4537 let mut fragments = HashMap::new();
4538 fragments.insert(
4539 "index.html".to_string(),
4540 FragmentList {
4541 fragments: vec![
4542 WebUIFragment {
4543 fragment: Some(Fragment::Attribute(WebUIFragmentAttribute {
4544 name: ":data-title".to_string(),
4545 value: "source".to_string(),
4546 complex: true,
4547 attr_start: true,
4548 ..Default::default()
4549 })),
4550 },
4551 WebUIFragment::component("prepared-child"),
4552 ],
4553 contains_boundary: false,
4554 },
4555 );
4556 fragments.insert(
4557 "prepared-child".to_string(),
4558 FragmentList {
4559 fragments: vec![WebUIFragment::signal("dataTitle", false)],
4560 contains_boundary: false,
4561 },
4562 );
4563 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4564 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4565 let entry = resolved
4566 .list_by_id("index.html")
4567 .unwrap_or_else(|| panic!("entry render plan should exist"));
4568 let child_index = protocol
4569 .render_fragments()
4570 .index("prepared-child")
4571 .unwrap_or_else(|| panic!("component target should be indexed"));
4572 assert_eq!(entry.component_attr_name(0), Some("dataTitle"));
4573 assert_eq!(entry.target(1), Some(child_index));
4574 assert!(!entry.has_routes);
4575 let source_entry = protocol
4576 .protocol()
4577 .fragments
4578 .get("index.html")
4579 .unwrap_or_else(|| panic!("source entry should exist"));
4580 assert!(std::ptr::eq(
4581 entry.fragments.as_ptr(),
4582 source_entry.fragments.as_ptr()
4583 ));
4584
4585 let state = test_json!({"source": "Prepared"});
4586 let options = RenderOptions::new("index.html", "/");
4587 for handler in [
4588 WebUIHandler::new(),
4589 WebUIHandler::with_plugin(|| {
4590 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
4591 }),
4592 ] {
4593 let mut writer = TestWriter::new();
4594 assert!(handler
4595 .render(&protocol, &state, &options, &mut writer)
4596 .is_ok());
4597 assert!(writer.get_content().contains("Prepared"));
4598 }
4599 }
4600
4601 #[test]
4602 fn prepared_graph_records_route_presence_per_fragment_list() {
4603 let mut fragments = HashMap::new();
4604 fragments.insert(
4605 "index.html".to_string(),
4606 FragmentList {
4607 fragments: vec![WebUIFragment::route("/", "dash-page")],
4608 contains_boundary: false,
4609 },
4610 );
4611 fragments.insert(
4612 "dash-page".to_string(),
4613 FragmentList {
4614 fragments: vec![WebUIFragment::raw("dashboard")],
4615 contains_boundary: false,
4616 },
4617 );
4618 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4619 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4620 let entry = resolved
4621 .list_by_id("index.html")
4622 .unwrap_or_else(|| panic!("entry render plan should exist"));
4623 let page = resolved
4624 .list_by_id("dash-page")
4625 .unwrap_or_else(|| panic!("page render plan should exist"));
4626 assert!(entry.has_routes);
4628 assert!(!page.has_routes);
4629 }
4630
4631 #[test]
4632 fn prepared_prop_names_match_attribute_to_camel_exactly() {
4633 let names = [
4637 "data-title",
4638 ":data-title",
4639 "aria-labelledby",
4640 "aria-valuemax",
4641 "readonly",
4642 "tabindex",
4643 "class",
4644 "for",
4645 "maxlength",
4646 "novalidate",
4647 "already-camel",
4648 "trailing-",
4649 "-leading",
4650 "multi-part-attribute-name",
4651 "single",
4652 "",
4653 ];
4654 for name in names {
4655 let stripped = name.strip_prefix(':').unwrap_or(name);
4656 assert_eq!(
4657 component_attr_name(name),
4658 webui_protocol::attrs::attribute_to_camel(stripped),
4659 "prepared name diverged for `{name}`"
4660 );
4661 }
4662 }
4663
4664 #[test]
4665 fn prepared_component_prop_names_match_per_branch_stripping() {
4666 let complex = WebUIFragmentAttribute {
4670 name: "::data-title".to_string(),
4671 value: "source".to_string(),
4672 complex: true,
4673 ..Default::default()
4674 };
4675 assert_eq!(component_attr_source(&complex), ":data-title");
4676 assert_eq!(
4677 component_attr_name(component_attr_source(&complex)),
4678 "dataTitle"
4679 );
4680
4681 let raw = WebUIFragmentAttribute {
4683 raw_value: true,
4684 ..complex.clone()
4685 };
4686 assert_eq!(component_attr_source(&raw), "::data-title");
4687
4688 let templated = WebUIFragmentAttribute {
4690 template: "tpl".to_string(),
4691 ..complex.clone()
4692 };
4693 assert_eq!(component_attr_source(&templated), "::data-title");
4694
4695 let empty_value = WebUIFragmentAttribute {
4697 value: String::new(),
4698 ..complex.clone()
4699 };
4700 assert_eq!(component_attr_source(&empty_value), "::data-title");
4701
4702 let ordinary = WebUIFragmentAttribute {
4704 name: ":data-title".to_string(),
4705 ..complex
4706 };
4707 assert_eq!(component_attr_source(&ordinary), "data-title");
4708 assert_eq!(
4709 component_attr_name(component_attr_source(&ordinary)),
4710 "dataTitle"
4711 );
4712 }
4713
4714 #[test]
4715 fn render_slots_match_continuation_slots() {
4716 let mut fragments = HashMap::new();
4721 for id in [
4722 "index.html",
4723 "zeta-last",
4724 "alpha-first",
4725 "middle-one",
4726 "middle-two",
4727 ] {
4728 fragments.insert(
4729 id.to_string(),
4730 FragmentList {
4731 fragments: vec![WebUIFragment::raw(id)],
4732 contains_boundary: false,
4733 },
4734 );
4735 }
4736 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4737 let index = protocol.render_fragments();
4738 for slot in 0..u32::try_from(protocol.protocol().fragments.len())
4739 .unwrap_or_else(|_| panic!("fragment count fits in u32"))
4740 {
4741 let id = protocol
4742 .fragment_id(slot)
4743 .unwrap_or_else(|| panic!("slot {slot} should resolve to an ID"));
4744 assert_eq!(
4745 index.index(id),
4746 Some(slot as usize),
4747 "render slot must equal continuation slot for `{id}`"
4748 );
4749 assert_eq!(protocol.fragment_slot(id), Some(slot));
4750 }
4751 }
4752
4753 #[test]
4754 fn prepared_graph_reports_missing_fragments_without_panicking() {
4755 let mut fragments = HashMap::new();
4756 fragments.insert(
4757 "index.html".to_string(),
4758 FragmentList {
4759 fragments: vec![WebUIFragment::component("absent-child")],
4760 contains_boundary: false,
4761 },
4762 );
4763 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4764 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4765 let entry = resolved
4766 .list_by_id("index.html")
4767 .unwrap_or_else(|| panic!("entry render plan should exist"));
4768 assert_eq!(entry.target(0), None);
4771 assert!(resolved.list_by_id("absent-child").is_none());
4772 assert!(resolved.list(usize::MAX).is_none());
4773
4774 let state = test_json!({});
4775 let options = RenderOptions::new("index.html", "/");
4776 let mut writer = TestWriter::new();
4777 match WebUIHandler::new().render(&protocol, &state, &options, &mut writer) {
4778 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-child"),
4779 other => panic!("expected a missing fragment error, got {other:?}"),
4780 }
4781
4782 let mut writer = TestWriter::new();
4783 match WebUIHandler::new().render(
4784 &protocol,
4785 &state,
4786 &RenderOptions::new("absent-entry", "/"),
4787 &mut writer,
4788 ) {
4789 Err(HandlerError::MissingFragment(id)) => assert_eq!(id, "absent-entry"),
4790 other => panic!("expected a missing entry error, got {other:?}"),
4791 }
4792 }
4793
4794 #[test]
4795 fn prepared_graph_lazily_borrows_large_protocol_fragment_lists() {
4796 let list_count = INLINE_RENDER_FRAGMENT_LISTS * 2 + 1;
4797 let mut fragments = HashMap::with_capacity(list_count);
4798 for index in 0..list_count {
4799 fragments.insert(
4800 format!("fragment-{index}"),
4801 FragmentList {
4802 fragments: vec![WebUIFragment::raw("content")],
4803 contains_boundary: false,
4804 },
4805 );
4806 }
4807 let protocol = Protocol::new(WebUIProtocol::new(fragments));
4808 let resolved = protocol.render_fragments().resolve(protocol.protocol());
4809 assert!(resolved.cache[0].get().is_none());
4811 assert!(resolved.spilled.get().is_none());
4812 assert!(resolved.list(0).is_some());
4813 assert!(resolved.cache[0].get().is_some());
4814 assert!(resolved.spilled.get().is_none());
4816 assert!(resolved.list(INLINE_RENDER_FRAGMENT_LISTS).is_some());
4819 let spilled = resolved
4820 .spilled
4821 .get()
4822 .expect("a slot past the inline cache should allocate the spill region");
4823 assert_eq!(spilled.len(), list_count - INLINE_RENDER_FRAGMENT_LISTS);
4824 assert!(spilled[0].get().is_some());
4825 assert!(resolved.list(list_count - 1).is_some());
4826
4827 for (id, source) in &protocol.protocol().fragments {
4828 let prepared = resolved
4829 .list_by_id(id)
4830 .unwrap_or_else(|| panic!("render plan should contain {id}"));
4831 assert!(std::ptr::eq(
4833 prepared.fragments.as_ptr(),
4834 source.fragments.as_ptr()
4835 ));
4836 }
4837 }
4838
4839 #[test]
4840 fn test_handle_raw() {
4841 let mut fragments = HashMap::new();
4843 fragments.insert(
4844 "index.html".to_string(),
4845 FragmentList {
4846 fragments: vec![WebUIFragment::raw("Hello, WebUI!")],
4847 contains_boundary: false,
4848 },
4849 );
4850
4851 let protocol = WebUIProtocol::new(fragments);
4852 let state = test_json!({});
4853
4854 let mut writer = TestWriter::new();
4856
4857 assert!(
4859 handle(
4860 &protocol,
4861 &state,
4862 &RenderOptions::new("index.html", "/"),
4863 &mut writer
4864 )
4865 .is_ok(),
4866 "Failed to handle raw protocol"
4867 );
4868
4869 assert_eq!(writer.get_content(), "Hello, WebUI!");
4871 assert!(writer.is_ended());
4872 }
4873
4874 #[test]
4875 fn test_handle_signal() {
4876 let mut fragments = HashMap::new();
4878 fragments.insert(
4879 "index.html".to_string(),
4880 FragmentList {
4881 fragments: vec![
4882 WebUIFragment::raw("Hello, "),
4883 WebUIFragment::signal("name", false),
4884 WebUIFragment::raw("!"),
4885 ],
4886 contains_boundary: false,
4887 },
4888 );
4889
4890 let protocol = WebUIProtocol::new(fragments);
4891 let state = test_json!({"name": "WebUI"});
4892
4893 let mut writer = TestWriter::new();
4895
4896 assert!(
4898 handle(
4899 &protocol,
4900 &state,
4901 &RenderOptions::new("index.html", "/"),
4902 &mut writer
4903 )
4904 .is_ok(),
4905 "Failed to handle signal protocol"
4906 );
4907
4908 assert_eq!(writer.get_content(), "Hello, WebUI!");
4910 assert!(writer.is_ended());
4911 }
4912
4913 #[test]
4914 fn test_handle_for_loop() {
4915 let mut fragments = HashMap::new();
4917 fragments.insert(
4918 "index.html".to_string(),
4919 FragmentList {
4920 fragments: vec![
4921 WebUIFragment::raw("People: "),
4922 WebUIFragment::for_loop("person", "people", "person-item"),
4923 ],
4924 contains_boundary: false,
4925 },
4926 );
4927
4928 fragments.insert(
4929 "person-item".to_string(),
4930 FragmentList {
4931 fragments: vec![
4932 WebUIFragment::signal("person.name", false),
4933 WebUIFragment::raw(", "),
4934 ],
4935 contains_boundary: false,
4936 },
4937 );
4938
4939 let protocol = WebUIProtocol::new(fragments);
4940 let state = test_json!({
4941 "people": [
4942 {"name": "Alice"},
4943 {"name": "Bob"},
4944 {"name": "Charlie"}
4945 ]
4946 });
4947
4948 let mut writer = TestWriter::new();
4950
4951 assert!(
4953 handle(
4954 &protocol,
4955 &state,
4956 &RenderOptions::new("index.html", "/"),
4957 &mut writer
4958 )
4959 .is_ok(),
4960 "Failed to handle for loop protocol"
4961 );
4962
4963 assert_eq!(writer.get_content(), "People: Alice, Bob, Charlie, ");
4965 assert!(writer.is_ended());
4966 }
4967
4968 #[test]
4969 fn test_handle_if_condition() {
4970 let mut fragments = HashMap::new();
4972 fragments.insert(
4973 "index.html".to_string(),
4974 FragmentList {
4975 fragments: vec![
4976 WebUIFragment::raw("Status: "),
4977 WebUIFragment::if_cond(
4978 webui_protocol::ConditionExpr::identifier("isActive"),
4979 "active-content",
4980 ),
4981 WebUIFragment::raw("End"),
4982 ],
4983 contains_boundary: false,
4984 },
4985 );
4986
4987 fragments.insert(
4988 "active-content".to_string(),
4989 FragmentList {
4990 fragments: vec![WebUIFragment::raw("Active")],
4991 contains_boundary: false,
4992 },
4993 );
4994
4995 let protocol = WebUIProtocol::new(fragments);
4996
4997 let state_true = test_json!({"isActive": true});
4999 let mut writer_true = TestWriter::new();
5000 assert!(
5001 handle(
5002 &protocol,
5003 &state_true,
5004 &RenderOptions::new("index.html", "/"),
5005 &mut writer_true
5006 )
5007 .is_ok(),
5008 "Failed to handle if condition (true case)"
5009 );
5010 assert_eq!(writer_true.get_content(), "Status: ActiveEnd");
5011 assert!(writer_true.is_ended());
5012
5013 let state_false = test_json!({"isActive": false});
5015 let mut writer_false = TestWriter::new();
5016 assert!(
5017 handle(
5018 &protocol,
5019 &state_false,
5020 &RenderOptions::new("index.html", "/"),
5021 &mut writer_false
5022 )
5023 .is_ok(),
5024 "Failed to handle if condition (false case)"
5025 );
5026 assert_eq!(writer_false.get_content(), "Status: End");
5027 assert!(writer_false.is_ended());
5028 }
5029
5030 #[test]
5031 fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
5032 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>"#;
5033 let mut parser = HtmlParser::new();
5034 parser
5035 .parse("index.html", source)
5036 .expect("parse missing-path condition fixture");
5037 let protocol = WebUIProtocol::new(parser.into_fragment_records());
5038 let state = test_json!({
5039 "items": [
5040 {"label": "Normal"},
5041 {"label": "Search", "searchPresentation": true}
5042 ]
5043 });
5044 let mut writer = TestWriter::new();
5045
5046 handle(
5047 &protocol,
5048 &state,
5049 &RenderOptions::new("index.html", "/"),
5050 &mut writer,
5051 )
5052 .expect("render missing-path condition fixture");
5053
5054 assert_eq!(
5055 writer.get_content(),
5056 "top-negated<span>Normal</span><mark>Search</mark>"
5057 );
5058 }
5059
5060 #[test]
5061 fn native_dynamic_attribute_does_not_leak_into_next_component() {
5062 let mut fragments = HashMap::new();
5063 fragments.insert(
5064 "index.html".to_string(),
5065 FragmentList {
5066 fragments: vec![
5067 WebUIFragment::raw("<div"),
5068 WebUIFragment {
5069 fragment: Some(web_ui_fragment::Fragment::Attribute(
5070 WebUIFragmentAttribute {
5071 name: "title".into(),
5072 value: "nativeTitle".into(),
5073 ..Default::default()
5074 },
5075 )),
5076 },
5077 WebUIFragment::raw("></div><my-comp>"),
5078 WebUIFragment::component("my-comp"),
5079 WebUIFragment::raw("</my-comp>"),
5080 ],
5081 contains_boundary: false,
5082 },
5083 );
5084 fragments.insert(
5085 "my-comp".to_string(),
5086 FragmentList {
5087 fragments: vec![WebUIFragment::signal("title", false)],
5088 contains_boundary: false,
5089 },
5090 );
5091 let protocol = WebUIProtocol::new(fragments);
5092 let state = test_json!({
5093 "nativeTitle": "native",
5094 "title": "global"
5095 });
5096 let mut writer = TestWriter::new();
5097 handle(
5098 &protocol,
5099 &state,
5100 &RenderOptions::new("index.html", "/"),
5101 &mut writer,
5102 )
5103 .unwrap_or_else(|error| panic!("render failed: {error}"));
5104 assert_eq!(
5105 writer.get_content(),
5106 "<div title=\"native\"></div><my-comp>global</my-comp>"
5107 );
5108 }
5109
5110 #[test]
5111 fn test_handle_component() {
5112 let mut fragments = HashMap::new();
5114 fragments.insert(
5115 "index.html".to_string(),
5116 FragmentList {
5117 fragments: vec![
5118 WebUIFragment::raw("Component: "),
5119 WebUIFragment::component("my-component"),
5120 ],
5121 contains_boundary: false,
5122 },
5123 );
5124
5125 fragments.insert(
5126 "my-component".to_string(),
5127 FragmentList {
5128 fragments: vec![WebUIFragment::raw("<div>Component Content</div>")],
5129 contains_boundary: false,
5130 },
5131 );
5132
5133 let protocol = WebUIProtocol::new(fragments);
5134 let state = test_json!({});
5135
5136 let mut writer = TestWriter::new();
5138
5139 assert!(
5141 handle(
5142 &protocol,
5143 &state,
5144 &RenderOptions::new("index.html", "/"),
5145 &mut writer
5146 )
5147 .is_ok(),
5148 "Failed to handle component protocol"
5149 );
5150
5151 assert_eq!(
5153 writer.get_content(),
5154 "Component: <div>Component Content</div>"
5155 );
5156 assert!(writer.is_ended());
5157 }
5158
5159 #[test]
5160 fn test_missing_fragment() {
5161 let mut fragments = HashMap::new();
5163 fragments.insert(
5164 "index.html".to_string(),
5165 FragmentList {
5166 fragments: vec![WebUIFragment::component("missing-component")],
5167 contains_boundary: false,
5168 },
5169 );
5170
5171 let protocol = WebUIProtocol::new(fragments);
5172 let state = test_json!({});
5173
5174 let mut writer = TestWriter::new();
5176
5177 let result = handle(
5179 &protocol,
5180 &state,
5181 &RenderOptions::new("index.html", "/"),
5182 &mut writer,
5183 );
5184
5185 assert!(result.is_err());
5187 if let Err(HandlerError::MissingFragment(fragment_id)) = result {
5188 assert_eq!(fragment_id, "missing-component");
5189 } else {
5190 panic!("Expected MissingFragment error");
5191 }
5192 }
5193
5194 #[test]
5195 fn test_missing_signal_renders_empty() {
5196 let mut fragments = HashMap::new();
5198 fragments.insert(
5199 "index.html".to_string(),
5200 FragmentList {
5201 fragments: vec![
5202 WebUIFragment::raw("Hello, "),
5203 WebUIFragment::signal("missing_field", false),
5204 WebUIFragment::raw("!"),
5205 ],
5206 contains_boundary: false,
5207 },
5208 );
5209
5210 let protocol = WebUIProtocol::new(fragments);
5211 let state = test_json!({});
5212
5213 let mut writer = TestWriter::new();
5214
5215 assert!(
5216 handle(
5217 &protocol,
5218 &state,
5219 &RenderOptions::new("index.html", "/"),
5220 &mut writer
5221 )
5222 .is_ok(),
5223 "Missing signal should not produce an error"
5224 );
5225
5226 assert_eq!(writer.get_content(), "Hello, !");
5227 assert!(writer.is_ended());
5228 }
5229
5230 #[test]
5233 fn test_boolean_attr_true() {
5234 let mut fragments = HashMap::new();
5235 fragments.insert(
5236 "index.html".to_string(),
5237 FragmentList {
5238 fragments: vec![
5239 WebUIFragment::raw("<button"),
5240 WebUIFragment::attribute_boolean(
5241 "disabled",
5242 ConditionExpr::identifier("isDisabled"),
5243 ),
5244 WebUIFragment::raw(">Click</button>"),
5245 ],
5246 contains_boundary: false,
5247 },
5248 );
5249 let protocol = WebUIProtocol::new(fragments);
5250 let state = test_json!({"isDisabled": true});
5251 let mut writer = TestWriter::new();
5252 handle(
5253 &protocol,
5254 &state,
5255 &RenderOptions::new("index.html", "/"),
5256 &mut writer,
5257 )
5258 .unwrap();
5259 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
5260 }
5261
5262 #[test]
5263 fn test_boolean_attr_false() {
5264 let mut fragments = HashMap::new();
5265 fragments.insert(
5266 "index.html".to_string(),
5267 FragmentList {
5268 fragments: vec![
5269 WebUIFragment::raw("<button"),
5270 WebUIFragment::attribute_boolean(
5271 "disabled",
5272 ConditionExpr::identifier("isDisabled"),
5273 ),
5274 WebUIFragment::raw(">Click</button>"),
5275 ],
5276 contains_boundary: false,
5277 },
5278 );
5279 let protocol = WebUIProtocol::new(fragments);
5280 let state = test_json!({"isDisabled": false});
5281 let mut writer = TestWriter::new();
5282 handle(
5283 &protocol,
5284 &state,
5285 &RenderOptions::new("index.html", "/"),
5286 &mut writer,
5287 )
5288 .unwrap();
5289 assert_eq!(writer.get_content(), "<button>Click</button>");
5290 }
5291
5292 #[test]
5293 fn test_boolean_attr_missing() {
5294 let mut fragments = HashMap::new();
5295 fragments.insert(
5296 "index.html".to_string(),
5297 FragmentList {
5298 fragments: vec![
5299 WebUIFragment::raw("<input type=\"checkbox\""),
5300 WebUIFragment::attribute_boolean(
5301 "checked",
5302 ConditionExpr::identifier("checked"),
5303 ),
5304 WebUIFragment::raw(">"),
5305 ],
5306 contains_boundary: false,
5307 },
5308 );
5309 let protocol = WebUIProtocol::new(fragments);
5310 let state = test_json!({});
5311 let mut writer = TestWriter::new();
5312 handle(
5313 &protocol,
5314 &state,
5315 &RenderOptions::new("index.html", "/"),
5316 &mut writer,
5317 )
5318 .unwrap();
5319 assert_eq!(writer.get_content(), "<input type=\"checkbox\">");
5320 }
5321
5322 #[test]
5323 fn test_boolean_attr_multiple() {
5324 let mut fragments = HashMap::new();
5325 fragments.insert(
5326 "index.html".to_string(),
5327 FragmentList {
5328 fragments: vec![
5329 WebUIFragment::raw("<input type=\"checkbox\""),
5330 WebUIFragment::attribute_boolean(
5331 "checked",
5332 ConditionExpr::identifier("checked"),
5333 ),
5334 WebUIFragment::attribute_boolean(
5335 "disabled",
5336 ConditionExpr::identifier("disabled"),
5337 ),
5338 WebUIFragment::raw(">"),
5339 ],
5340 contains_boundary: false,
5341 },
5342 );
5343 let protocol = WebUIProtocol::new(fragments);
5344 let state = test_json!({"checked": true, "disabled": false});
5345 let mut writer = TestWriter::new();
5346 handle(
5347 &protocol,
5348 &state,
5349 &RenderOptions::new("index.html", "/"),
5350 &mut writer,
5351 )
5352 .unwrap();
5353 assert_eq!(writer.get_content(), "<input type=\"checkbox\" checked>");
5354 }
5355
5356 #[test]
5359 fn test_attribute_with_value() {
5360 let mut fragments = HashMap::new();
5361 fragments.insert(
5362 "index.html".to_string(),
5363 FragmentList {
5364 fragments: vec![
5365 WebUIFragment::raw("<input"),
5366 WebUIFragment::attribute("value", "inputValue"),
5367 WebUIFragment::raw(">"),
5368 ],
5369 contains_boundary: false,
5370 },
5371 );
5372 let protocol = WebUIProtocol::new(fragments);
5373 let state = test_json!({"inputValue": "Hello"});
5374 let mut writer = TestWriter::new();
5375 handle(
5376 &protocol,
5377 &state,
5378 &RenderOptions::new("index.html", "/"),
5379 &mut writer,
5380 )
5381 .unwrap();
5382 assert_eq!(writer.get_content(), "<input value=\"Hello\">");
5383 }
5384
5385 #[test]
5386 fn test_attribute_with_falsy_numeric() {
5387 let mut fragments = HashMap::new();
5388 fragments.insert(
5389 "index.html".to_string(),
5390 FragmentList {
5391 fragments: vec![
5392 WebUIFragment::raw("<div name=\"test\""),
5393 WebUIFragment::attribute("handle", "number"),
5394 WebUIFragment::raw("></div>"),
5395 ],
5396 contains_boundary: false,
5397 },
5398 );
5399 let protocol = WebUIProtocol::new(fragments);
5400 let state = test_json!({"number": 0});
5401 let mut writer = TestWriter::new();
5402 handle(
5403 &protocol,
5404 &state,
5405 &RenderOptions::new("index.html", "/"),
5406 &mut writer,
5407 )
5408 .unwrap();
5409 assert_eq!(
5410 writer.get_content(),
5411 "<div name=\"test\" handle=\"0\"></div>"
5412 );
5413 }
5414
5415 #[test]
5418 fn test_attribute_array_value_is_escaped() {
5419 let mut fragments = HashMap::new();
5420 fragments.insert(
5421 "index.html".to_string(),
5422 FragmentList {
5423 fragments: vec![
5424 WebUIFragment::raw("<a"),
5425 WebUIFragment::attribute("href", "value"),
5426 WebUIFragment::raw(">demo</a>"),
5427 ],
5428 contains_boundary: false,
5429 },
5430 );
5431 let protocol = WebUIProtocol::new(fragments);
5432 let state = test_json!({"value": ["\" autofocus onfocus=alert(1) x=\""]});
5433 let mut writer = TestWriter::new();
5434 handle(
5435 &protocol,
5436 &state,
5437 &RenderOptions::new("index.html", "/"),
5438 &mut writer,
5439 )
5440 .unwrap();
5441 let content = writer.get_content();
5442 assert!(
5445 content.contains("""),
5446 "Double quotes inside attribute value must be escaped: {content}"
5447 );
5448 assert_eq!(
5451 content.matches("=\"").count(),
5452 1,
5453 "Only one attribute assignment expected: {content}"
5454 );
5455 }
5456
5457 #[test]
5458 fn test_attribute_object_value_is_escaped() {
5459 let mut fragments = HashMap::new();
5460 fragments.insert(
5461 "index.html".to_string(),
5462 FragmentList {
5463 fragments: vec![
5464 WebUIFragment::raw("<div"),
5465 WebUIFragment::attribute("data-cfg", "cfg"),
5466 WebUIFragment::raw("></div>"),
5467 ],
5468 contains_boundary: false,
5469 },
5470 );
5471 let protocol = WebUIProtocol::new(fragments);
5472 let state = test_json!({"cfg": {"key": "\" onfocus=alert(1) x=\""}});
5473 let mut writer = TestWriter::new();
5474 handle(
5475 &protocol,
5476 &state,
5477 &RenderOptions::new("index.html", "/"),
5478 &mut writer,
5479 )
5480 .unwrap();
5481 let content = writer.get_content();
5482 assert!(
5483 content.contains("""),
5484 "Double quotes inside attribute value must be escaped: {content}"
5485 );
5486 assert_eq!(
5487 content.matches("=\"").count(),
5488 1,
5489 "Only one attribute assignment expected: {content}"
5490 );
5491 }
5492
5493 #[test]
5496 fn test_mixed_attribute_template() {
5497 let mut fragments = HashMap::new();
5498 fragments.insert(
5499 "index.html".to_string(),
5500 FragmentList {
5501 fragments: vec![
5502 WebUIFragment::raw("<input"),
5503 WebUIFragment::attribute_template("value", "attr-1"),
5504 WebUIFragment::raw(">"),
5505 ],
5506 contains_boundary: false,
5507 },
5508 );
5509 fragments.insert(
5510 "attr-1".to_string(),
5511 FragmentList {
5512 fragments: vec![
5513 WebUIFragment::raw("hello "),
5514 WebUIFragment::signal("item", false),
5515 ],
5516 contains_boundary: false,
5517 },
5518 );
5519 let protocol = WebUIProtocol::new(fragments);
5520 let state = test_json!({"item": "world"});
5521 let mut writer = TestWriter::new();
5522 handle(
5523 &protocol,
5524 &state,
5525 &RenderOptions::new("index.html", "/"),
5526 &mut writer,
5527 )
5528 .unwrap();
5529 assert_eq!(writer.get_content(), "<input value=\"hello world\">");
5530 }
5531
5532 #[test]
5535 fn test_raw_signal_not_escaped() {
5536 let mut fragments = HashMap::new();
5537 fragments.insert(
5538 "index.html".to_string(),
5539 FragmentList {
5540 fragments: vec![
5541 WebUIFragment::signal("html", false),
5542 WebUIFragment::signal("html", true),
5543 ],
5544 contains_boundary: false,
5545 },
5546 );
5547 let protocol = WebUIProtocol::new(fragments);
5548 let state = test_json!({"html": "<strong>hi</strong>"});
5549 let mut writer = TestWriter::new();
5550 handle(
5551 &protocol,
5552 &state,
5553 &RenderOptions::new("index.html", "/"),
5554 &mut writer,
5555 )
5556 .unwrap();
5557 assert_eq!(
5558 writer.get_content(),
5559 "<strong>hi</strong><strong>hi</strong>"
5560 );
5561 }
5562
5563 fn render_named_for(template: &str, state: &Value) -> String {
5566 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5567 parser.parse("index.html", template).unwrap();
5568 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5569 let mut writer = TestWriter::new();
5570 WebUIHandler::new()
5571 .render(
5572 &protocol,
5573 state,
5574 &RenderOptions::new("index.html", "/"),
5575 &mut writer,
5576 )
5577 .unwrap();
5578 writer.get_content()
5579 }
5580
5581 #[test]
5582 fn named_for_renders_the_issue_tree_and_restores_shadowed_items() {
5583 let source = r#"<ul><for each="child in items" id="tree-item"><li>{{child.name}}</li><if condition="{{child.children}}"><ul><for id="tree-item" each="child in child.children" /></ul></if><i>{{child.name}}</i></for></ul><b>{{child.name}}</b>"#;
5584 let state = test_json!({
5585 "child": { "name": "global" },
5586 "items": [
5587 { "name": "Colors", "children": [{ "name": "Ali" }, { "name": "Alice" }, { "name": "Bob" }] },
5588 { "name": "Name", "children": [] },
5589 { "name": "Hobbies", "children": [{ "name": "Sports", "children": [{ "name": "Futbol" }, { "name": "Cricket" }] }] }
5590 ]
5591 });
5592 assert_eq!(render_named_for(source, &state), "<ul><li>Colors</li><ul><li>Ali</li><i>Ali</i><li>Alice</li><i>Alice</i><li>Bob</li><i>Bob</i></ul><i>Colors</i><li>Name</li><i>Name</i><li>Hobbies</li><ul><li>Sports</li><ul><li>Futbol</li><i>Futbol</i><li>Cricket</li><i>Cricket</i></ul><i>Sports</i></ul><i>Hobbies</i></ul><b>global</b>");
5593 }
5594
5595 #[test]
5596 fn named_for_missing_and_empty_children_stop_recursion_without_an_if() {
5597 let source = r#"<for id="tree" each="item in items">{{item.name}}<for id="tree" each="item in item.children" />{{item.name}}</for>"#;
5598 let state = test_json!({
5599 "items": [{ "name": "a", "children": [{ "name": "b" }, { "name": "c", "children": [] }] }]
5600 });
5601 assert_eq!(render_named_for(source, &state), "abbcca");
5602 assert_eq!(render_named_for(source, &test_json!({"items": []})), "");
5603 }
5604
5605 #[test]
5606 fn named_for_forward_and_mutual_references_follow_finite_data() {
5607 let source = r#"<for id="a" each="item in items" /><for id="a" each="item in empty"><b>{{item.name}}</b><for id="b" each="item in item.children" /></for><for id="b" each="item in empty"><i>{{item.name}}</i><for id="a" each="item in item.children" /></for>"#;
5608 let state = test_json!({
5609 "items": [{ "name": "a", "children": [{ "name": "b", "children": [{ "name": "c" }] }] }],
5610 "empty": []
5611 });
5612 assert_eq!(render_named_for(source, &state), "<b>a</b><i>b</i><b>c</b>");
5613 }
5614
5615 #[test]
5616 fn named_for_preserves_enclosing_scope_and_escapes_nested_values() {
5617 let source = r#"<for each="group in groups"><for id="tree" each="item in group.items">{{group.name}}:{{item.name}};<for id="tree" each="item in item.children" /></for></for>"#;
5618 let state = test_json!({
5619 "groups": [
5620 { "name": "one", "items": [{ "name": "<root>", "children": [{ "name": "&leaf" }] }] },
5621 { "name": "two", "items": [{ "name": "last" }] }
5622 ]
5623 });
5624 assert_eq!(
5625 render_named_for(source, &state),
5626 "one:<root>;one:&leaf;two:last;"
5627 );
5628 }
5629
5630 #[test]
5631 fn named_for_non_array_collection_returns_the_existing_type_error() {
5632 let mut parser = HtmlParser::new();
5633 parser.parse("index.html", r#"<for id="tree" each="item in items"><for id="tree" each="item in item.children" /></for>"#).unwrap();
5634 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
5635 let mut writer = TestWriter::new();
5636 let error = WebUIHandler::new()
5637 .render(
5638 &protocol,
5639 &test_json!({"items": [{"children": 1}]}),
5640 &RenderOptions::new("index.html", "/"),
5641 &mut writer,
5642 )
5643 .unwrap_err();
5644 assert!(matches!(error, HandlerError::TypeError(_)));
5645 }
5646
5647 #[test]
5648 fn test_nested_for_loop() {
5649 let mut fragments = HashMap::new();
5650 fragments.insert(
5651 "index.html".to_string(),
5652 FragmentList {
5653 fragments: vec![
5654 WebUIFragment::raw("<div>"),
5655 WebUIFragment::for_loop("outerItem", "outerItems", "outer"),
5656 WebUIFragment::raw("</div>"),
5657 ],
5658 contains_boundary: false,
5659 },
5660 );
5661 fragments.insert(
5662 "outer".to_string(),
5663 FragmentList {
5664 fragments: vec![
5665 WebUIFragment::raw("<div>"),
5666 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "inner"),
5667 WebUIFragment::raw("</div>"),
5668 ],
5669 contains_boundary: false,
5670 },
5671 );
5672 fragments.insert(
5673 "inner".to_string(),
5674 FragmentList {
5675 fragments: vec![WebUIFragment::raw("<span>Inner</span>")],
5676 contains_boundary: false,
5677 },
5678 );
5679 let protocol = WebUIProtocol::new(fragments);
5680 let state = test_json!({
5681 "outerItems": [
5682 {"innerItems": [{"name": "A"}, {"name": "B"}]},
5683 {"innerItems": [{"name": "C"}]}
5684 ]
5685 });
5686 let mut writer = TestWriter::new();
5687 handle(
5688 &protocol,
5689 &state,
5690 &RenderOptions::new("index.html", "/"),
5691 &mut writer,
5692 )
5693 .unwrap();
5694 assert_eq!(
5695 writer.get_content(),
5696 "<div><div><span>Inner</span><span>Inner</span></div><div><span>Inner</span></div></div>"
5697 );
5698 }
5699
5700 #[test]
5701 fn test_nested_for_with_signals() {
5702 let mut fragments = HashMap::new();
5703 fragments.insert(
5704 "index.html".to_string(),
5705 FragmentList {
5706 fragments: vec![
5707 WebUIFragment::raw("<div>"),
5708 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5709 WebUIFragment::raw("</div>"),
5710 ],
5711 contains_boundary: false,
5712 },
5713 );
5714 fragments.insert(
5715 "outerTemplate".to_string(),
5716 FragmentList {
5717 fragments: vec![
5718 WebUIFragment::raw("<div>"),
5719 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5720 WebUIFragment::raw("</div>"),
5721 ],
5722 contains_boundary: false,
5723 },
5724 );
5725 fragments.insert(
5726 "innerTemplate".to_string(),
5727 FragmentList {
5728 fragments: vec![
5729 WebUIFragment::raw("<span>"),
5730 WebUIFragment::signal("innerItem.name", false),
5731 WebUIFragment::raw("</span>"),
5732 ],
5733 contains_boundary: false,
5734 },
5735 );
5736 let protocol = WebUIProtocol::new(fragments);
5737 let state = test_json!({
5738 "outerItems": [
5739 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5740 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5741 ]
5742 });
5743 let mut writer = TestWriter::new();
5744 handle(
5745 &protocol,
5746 &state,
5747 &RenderOptions::new("index.html", "/"),
5748 &mut writer,
5749 )
5750 .unwrap();
5751 assert_eq!(
5752 writer.get_content(),
5753 "<div><div><span>Item1</span><span>Item2</span></div><div><span>Item3</span><span>Item4</span></div></div>"
5754 );
5755 }
5756
5757 #[test]
5758 fn test_nested_for_with_global_state() {
5759 let mut fragments = HashMap::new();
5760 fragments.insert(
5761 "index.html".to_string(),
5762 FragmentList {
5763 fragments: vec![
5764 WebUIFragment::raw("<div>"),
5765 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
5766 WebUIFragment::raw("</div>"),
5767 ],
5768 contains_boundary: false,
5769 },
5770 );
5771 fragments.insert(
5772 "outerTemplate".to_string(),
5773 FragmentList {
5774 fragments: vec![
5775 WebUIFragment::raw("<div>"),
5776 WebUIFragment::signal("globalOuter", false),
5777 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
5778 WebUIFragment::raw("</div>"),
5779 ],
5780 contains_boundary: false,
5781 },
5782 );
5783 fragments.insert(
5784 "innerTemplate".to_string(),
5785 FragmentList {
5786 fragments: vec![
5787 WebUIFragment::raw("<span>"),
5788 WebUIFragment::signal("innerItem.name", false),
5789 WebUIFragment::signal("globalInner", false),
5790 WebUIFragment::raw("</span>"),
5791 ],
5792 contains_boundary: false,
5793 },
5794 );
5795 let protocol = WebUIProtocol::new(fragments);
5796 let state = test_json!({
5797 "globalOuter": "GO",
5798 "globalInner": "GI",
5799 "outerItems": [
5800 {"innerItems": [{"name": "Item1"}, {"name": "Item2"}]},
5801 {"innerItems": [{"name": "Item3"}, {"name": "Item4"}]}
5802 ]
5803 });
5804 let mut writer = TestWriter::new();
5805 handle(
5806 &protocol,
5807 &state,
5808 &RenderOptions::new("index.html", "/"),
5809 &mut writer,
5810 )
5811 .unwrap();
5812 assert_eq!(
5813 writer.get_content(),
5814 "<div><div>GO<span>Item1GI</span><span>Item2GI</span></div><div>GO<span>Item3GI</span><span>Item4GI</span></div></div>"
5815 );
5816 }
5817
5818 #[test]
5821 fn test_if_in_for_uses_local_state() {
5822 let mut fragments = HashMap::new();
5823 fragments.insert(
5824 "index.html".to_string(),
5825 FragmentList {
5826 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5827 contains_boundary: false,
5828 },
5829 );
5830 fragments.insert(
5831 "item-tpl".to_string(),
5832 FragmentList {
5833 fragments: vec![WebUIFragment::if_cond(
5834 ConditionExpr::identifier("item.visible"),
5835 "visible-tpl",
5836 )],
5837 contains_boundary: false,
5838 },
5839 );
5840 fragments.insert(
5841 "visible-tpl".to_string(),
5842 FragmentList {
5843 fragments: vec![WebUIFragment::signal("item.name", false)],
5844 contains_boundary: false,
5845 },
5846 );
5847 let protocol = WebUIProtocol::new(fragments);
5848 let state = test_json!({"items": [{"name": "Show", "visible": true}, {"name": "Hide", "visible": false}]});
5849 let mut writer = TestWriter::new();
5850 handle(
5851 &protocol,
5852 &state,
5853 &RenderOptions::new("index.html", "/"),
5854 &mut writer,
5855 )
5856 .unwrap();
5857 assert_eq!(writer.get_content(), "Show");
5858 }
5859
5860 #[test]
5861 fn test_for_if_local_overrides_global() {
5862 let mut fragments = HashMap::new();
5863 fragments.insert(
5864 "index.html".to_string(),
5865 FragmentList {
5866 fragments: vec![WebUIFragment::for_loop("item", "items", "item-tpl")],
5867 contains_boundary: false,
5868 },
5869 );
5870 fragments.insert(
5871 "item-tpl".to_string(),
5872 FragmentList {
5873 fragments: vec![WebUIFragment::if_cond(
5874 ConditionExpr::identifier("item.flag"),
5875 "show-tpl",
5876 )],
5877 contains_boundary: false,
5878 },
5879 );
5880 fragments.insert(
5881 "show-tpl".to_string(),
5882 FragmentList {
5883 fragments: vec![WebUIFragment::raw("yes")],
5884 contains_boundary: false,
5885 },
5886 );
5887 let protocol = WebUIProtocol::new(fragments);
5888 let state = test_json!({"flag": true, "items": [{"flag": true}, {"flag": false}]});
5890 let mut writer = TestWriter::new();
5891 handle(
5892 &protocol,
5893 &state,
5894 &RenderOptions::new("index.html", "/"),
5895 &mut writer,
5896 )
5897 .unwrap();
5898 assert_eq!(writer.get_content(), "yes");
5899 }
5900
5901 #[test]
5904 fn html_component_inputs_forward_flags_and_aria_without_javascript_metadata() {
5905 let cases = [
5906 (
5907 r#"<my-drawer open aria-label="Canvas information"></my-drawer>"#,
5908 test_json!({}),
5909 r#"<dialog open aria-label="Canvas information"></dialog>"#,
5910 ),
5911 (
5912 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
5913 test_json!({"open": true}),
5914 r#"<dialog open aria-label="Canvas information"></dialog>"#,
5915 ),
5916 (
5917 r#"<my-drawer ?open="{{open}}" aria-label="Canvas information"></my-drawer>"#,
5918 test_json!({"open": false}),
5919 r#"<dialog aria-label="Canvas information"></dialog>"#,
5920 ),
5921 (
5922 r#"<my-drawer aria-label=""></my-drawer>"#,
5923 test_json!({"ariaLabel": "Fallback"}),
5924 r#"<dialog aria-label=""></dialog>"#,
5925 ),
5926 (
5927 r#"<my-drawer open aria-label="Canvas & information"></my-drawer>"#,
5928 test_json!({}),
5929 r#"<dialog open aria-label="Canvas & information"></dialog>"#,
5930 ),
5931 ];
5932 for (input, state, expected) in cases {
5933 let mut parser = HtmlParser::with_options(DomStrategy::Light);
5934 parser
5935 .component_registry_mut()
5936 .register_component(ComponentRegistration::new(
5937 "my-drawer",
5938 r#"<dialog ?open="{{open}}" aria-label="{{ariaLabel}}"></dialog>"#,
5939 None,
5940 true,
5941 ))
5942 .unwrap();
5943 parser.parse("index.html", input).unwrap();
5944 let protocol = WebUIProtocol::new(parser.into_fragment_records());
5945 let mut writer = TestWriter::new();
5946 handle(
5947 &protocol,
5948 &state,
5949 &RenderOptions::new("index.html", "/"),
5950 &mut writer,
5951 )
5952 .unwrap();
5953 assert!(
5954 writer.get_content().contains(expected),
5955 "{input}: {}",
5956 writer.get_content()
5957 );
5958 }
5959 }
5960
5961 #[test]
5962 fn test_component_attr_state_simple() {
5963 let mut fragments = HashMap::new();
5964 fragments.insert(
5965 "index.html".to_string(),
5966 FragmentList {
5967 fragments: vec![
5968 WebUIFragment::raw("<my-comp"),
5969 WebUIFragment {
5970 fragment: Some(web_ui_fragment::Fragment::Attribute(
5971 WebUIFragmentAttribute {
5972 name: "title".into(),
5973 value: "Attribute Title".into(),
5974 attr_start: true,
5975 raw_value: true,
5976 ..Default::default()
5977 },
5978 )),
5979 },
5980 WebUIFragment::raw(">"),
5981 WebUIFragment::component("my-comp"),
5982 WebUIFragment::raw("</my-comp>"),
5983 ],
5984
5985 contains_boundary: false,
5986 },
5987 );
5988 fragments.insert(
5989 "my-comp".to_string(),
5990 FragmentList {
5991 fragments: vec![
5992 WebUIFragment::raw("<span>"),
5993 WebUIFragment::signal("title", false),
5994 WebUIFragment::raw("</span>"),
5995 ],
5996 contains_boundary: false,
5997 },
5998 );
5999 let protocol = WebUIProtocol::new(fragments);
6000 let state = test_json!({"title": "Global Title"});
6001 let mut writer = TestWriter::new();
6002 handle(
6003 &protocol,
6004 &state,
6005 &RenderOptions::new("index.html", "/"),
6006 &mut writer,
6007 )
6008 .unwrap();
6009 assert_eq!(
6010 writer.get_content(),
6011 "<my-comp title=\"Attribute Title\"><span>Attribute Title</span></my-comp>"
6012 );
6013 }
6014
6015 #[test]
6016 fn test_component_attr_state_template() {
6017 let mut fragments = HashMap::new();
6018 fragments.insert(
6019 "index.html".to_string(),
6020 FragmentList {
6021 fragments: vec![
6022 WebUIFragment::raw("<my-comp"),
6023 WebUIFragment {
6024 fragment: Some(web_ui_fragment::Fragment::Attribute(
6025 WebUIFragmentAttribute {
6026 name: "title".into(),
6027 template: "title-attr".into(),
6028 attr_start: true,
6029 ..Default::default()
6030 },
6031 )),
6032 },
6033 WebUIFragment::raw(">"),
6034 WebUIFragment::component("my-comp"),
6035 WebUIFragment::raw("</my-comp>"),
6036 ],
6037
6038 contains_boundary: false,
6039 },
6040 );
6041 fragments.insert(
6042 "title-attr".to_string(),
6043 FragmentList {
6044 fragments: vec![
6045 WebUIFragment::raw("hello "),
6046 WebUIFragment::signal("item", false),
6047 ],
6048 contains_boundary: false,
6049 },
6050 );
6051 fragments.insert(
6052 "my-comp".to_string(),
6053 FragmentList {
6054 fragments: vec![
6055 WebUIFragment::raw("<span>"),
6056 WebUIFragment::signal("title", false),
6057 WebUIFragment::raw("</span>"),
6058 ],
6059 contains_boundary: false,
6060 },
6061 );
6062 let protocol = WebUIProtocol::new(fragments);
6063 let state = test_json!({"item": "<world>"});
6064 let mut writer = TestWriter::new();
6065 handle(
6066 &protocol,
6067 &state,
6068 &RenderOptions::new("index.html", "/"),
6069 &mut writer,
6070 )
6071 .unwrap();
6072 assert_eq!(
6073 writer.get_content(),
6074 "<my-comp title=\"hello <world>\"><span>hello <world></span></my-comp>"
6075 );
6076 }
6077
6078 #[test]
6079 fn test_component_attr_camel_case() {
6080 let mut fragments = HashMap::new();
6081 fragments.insert(
6082 "index.html".to_string(),
6083 FragmentList {
6084 fragments: vec![
6085 WebUIFragment::raw("<my-comp"),
6086 WebUIFragment {
6087 fragment: Some(web_ui_fragment::Fragment::Attribute(
6088 WebUIFragmentAttribute {
6089 name: "data-title".into(),
6090 template: "dt-attr".into(),
6091 attr_start: true,
6092 ..Default::default()
6093 },
6094 )),
6095 },
6096 WebUIFragment::raw(">"),
6097 WebUIFragment::component("my-comp"),
6098 WebUIFragment::raw("</my-comp>"),
6099 ],
6100
6101 contains_boundary: false,
6102 },
6103 );
6104 fragments.insert(
6105 "dt-attr".to_string(),
6106 FragmentList {
6107 fragments: vec![
6108 WebUIFragment::raw("prefix "),
6109 WebUIFragment::signal("item", false),
6110 ],
6111 contains_boundary: false,
6112 },
6113 );
6114 fragments.insert(
6115 "my-comp".to_string(),
6116 FragmentList {
6117 fragments: vec![
6118 WebUIFragment::raw("<span>"),
6119 WebUIFragment::signal("dataTitle", false),
6120 WebUIFragment::raw("</span>"),
6121 ],
6122 contains_boundary: false,
6123 },
6124 );
6125 let protocol = WebUIProtocol::new(fragments);
6126 let state = test_json!({"item": "a&b"});
6127 let mut writer = TestWriter::new();
6128 handle(
6129 &protocol,
6130 &state,
6131 &RenderOptions::new("index.html", "/"),
6132 &mut writer,
6133 )
6134 .unwrap();
6135 assert_eq!(
6136 writer.get_content(),
6137 "<my-comp data-title=\"prefix a&b\"><span>prefix a&b</span></my-comp>"
6138 );
6139 }
6140
6141 #[test]
6142 fn test_component_complex_attr() {
6143 let mut fragments = HashMap::new();
6144 fragments.insert(
6145 "index.html".to_string(),
6146 FragmentList {
6147 fragments: vec![
6148 WebUIFragment::raw("<my-comp"),
6149 WebUIFragment {
6150 fragment: Some(web_ui_fragment::Fragment::Attribute(
6151 WebUIFragmentAttribute {
6152 name: ":item".into(),
6153 value: "complexItem".into(),
6154 attr_start: true,
6155 complex: true,
6156 ..Default::default()
6157 },
6158 )),
6159 },
6160 WebUIFragment::raw(">"),
6161 WebUIFragment::component("my-comp"),
6162 WebUIFragment::raw("</my-comp>"),
6163 ],
6164
6165 contains_boundary: false,
6166 },
6167 );
6168 fragments.insert(
6169 "my-comp".to_string(),
6170 FragmentList {
6171 fragments: vec![
6172 WebUIFragment::raw("<span>"),
6173 WebUIFragment::signal("item.foo", false),
6174 WebUIFragment::raw("</span><p>"),
6175 WebUIFragment::signal("item.bar", false),
6176 WebUIFragment::raw("</p>"),
6177 ],
6178 contains_boundary: false,
6179 },
6180 );
6181 let protocol = WebUIProtocol::new(fragments);
6182 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
6183 let mut writer = TestWriter::new();
6184 handle(
6185 &protocol,
6186 &state,
6187 &RenderOptions::new("index.html", "/"),
6188 &mut writer,
6189 )
6190 .unwrap();
6191 assert_eq!(
6192 writer.get_content(),
6193 "<my-comp><span>1</span><p>true</p></my-comp>"
6194 );
6195 }
6196
6197 #[test]
6198 fn test_component_no_parent_pollution() {
6199 let mut fragments = HashMap::new();
6200 fragments.insert(
6201 "index.html".to_string(),
6202 FragmentList {
6203 fragments: vec![
6204 WebUIFragment::raw("<parent"),
6205 WebUIFragment {
6206 fragment: Some(web_ui_fragment::Fragment::Attribute(
6207 WebUIFragmentAttribute {
6208 name: "var".into(),
6209 value: "var".into(),
6210 attr_start: true,
6211 ..Default::default()
6212 },
6213 )),
6214 },
6215 WebUIFragment::raw(">"),
6216 WebUIFragment::component("parent"),
6217 WebUIFragment::raw("</parent>"),
6218 ],
6219
6220 contains_boundary: false,
6221 },
6222 );
6223 fragments.insert(
6224 "parent".to_string(),
6225 FragmentList {
6226 fragments: vec![
6227 WebUIFragment::raw("Before: "),
6228 WebUIFragment::signal("var", false),
6229 WebUIFragment::raw("<child foo"),
6230 WebUIFragment {
6231 fragment: Some(web_ui_fragment::Fragment::Attribute(
6232 WebUIFragmentAttribute {
6233 name: "var".into(),
6234 value: "replaced".into(),
6235 raw_value: true,
6236 ..Default::default()
6237 },
6238 )),
6239 },
6240 WebUIFragment::raw(">"),
6241 WebUIFragment::component("child"),
6242 WebUIFragment::raw("Label</child>After: "),
6243 WebUIFragment::signal("var", false),
6244 ],
6245
6246 contains_boundary: false,
6247 },
6248 );
6249 fragments.insert(
6250 "child".to_string(),
6251 FragmentList {
6252 fragments: vec![],
6253 contains_boundary: false,
6254 },
6255 );
6256 let protocol = WebUIProtocol::new(fragments);
6257 let state = test_json!({"var": "original<&"});
6258 let mut writer = TestWriter::new();
6259 handle(
6260 &protocol,
6261 &state,
6262 &RenderOptions::new("index.html", "/"),
6263 &mut writer,
6264 )
6265 .unwrap();
6266 assert_eq!(
6267 writer.get_content(),
6268 "<parent var=\"original<&\">Before: original<&<child foo var=\"replaced\">Label</child>After: original<&</parent>"
6269 );
6270 }
6271
6272 #[test]
6273 fn test_component_boolean_attr_state() {
6274 let mut fragments = HashMap::new();
6275 fragments.insert(
6276 "index.html".to_string(),
6277 FragmentList {
6278 fragments: vec![
6279 WebUIFragment::raw("<my-comp"),
6280 WebUIFragment {
6281 fragment: Some(web_ui_fragment::Fragment::Attribute(
6282 WebUIFragmentAttribute {
6283 name: "disabled".into(),
6284 attr_start: true,
6285 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
6286 ..Default::default()
6287 },
6288 )),
6289 },
6290 WebUIFragment::raw(">"),
6291 WebUIFragment::component("my-comp"),
6292 WebUIFragment::raw("</my-comp>"),
6293 ],
6294
6295 contains_boundary: false,
6296 },
6297 );
6298 fragments.insert(
6299 "my-comp".to_string(),
6300 FragmentList {
6301 fragments: vec![WebUIFragment::if_cond(
6302 ConditionExpr::identifier("disabled"),
6303 "show",
6304 )],
6305 contains_boundary: false,
6306 },
6307 );
6308 fragments.insert(
6309 "show".to_string(),
6310 FragmentList {
6311 fragments: vec![WebUIFragment::raw("disabled!")],
6312 contains_boundary: false,
6313 },
6314 );
6315 let protocol = WebUIProtocol::new(fragments);
6316 let state = test_json!({"isDisabled": true});
6317 let mut writer = TestWriter::new();
6318 handle(
6319 &protocol,
6320 &state,
6321 &RenderOptions::new("index.html", "/"),
6322 &mut writer,
6323 )
6324 .unwrap();
6325 assert_eq!(
6326 writer.get_content(),
6327 "<my-comp disabled>disabled!</my-comp>"
6328 );
6329 }
6330
6331 fn render_signal(value: &str) -> String {
6335 let mut fragments = HashMap::new();
6336 fragments.insert(
6337 "index.html".to_string(),
6338 FragmentList {
6339 fragments: vec![WebUIFragment::signal("v", false)],
6340 contains_boundary: false,
6341 },
6342 );
6343 let protocol = WebUIProtocol::new(fragments);
6344 let state = test_json!({"v": value});
6345 let mut writer = TestWriter::new();
6346 handle(
6347 &protocol,
6348 &state,
6349 &RenderOptions::new("index.html", "/"),
6350 &mut writer,
6351 )
6352 .unwrap();
6353 writer.get_content()
6354 }
6355
6356 #[test]
6357 fn test_escape_ampersand() {
6358 assert_eq!(render_signal("&"), "&");
6359 }
6360
6361 #[test]
6362 fn test_escape_less_than() {
6363 assert_eq!(render_signal("<"), "<");
6364 }
6365
6366 #[test]
6367 fn test_escape_greater_than() {
6368 assert_eq!(render_signal(">"), ">");
6369 }
6370
6371 #[test]
6372 fn test_escape_double_quote() {
6373 assert_eq!(render_signal("\""), """);
6374 }
6375
6376 #[test]
6377 fn test_escape_single_quote() {
6378 let result = render_signal("'");
6380 assert_eq!(
6381 result, "'",
6382 "Expected ' for single quote, got: {result}"
6383 );
6384 }
6385
6386 #[test]
6387 fn test_escape_multiple_special_chars() {
6388 let result = render_signal("<script>alert('xss');</script>");
6389 assert!(
6390 result.contains("<") && result.contains(">"),
6391 "Expected escaped HTML, got: {}",
6392 result
6393 );
6394 assert!(
6395 !result.contains("<script>"),
6396 "Should not contain raw <script> tag"
6397 );
6398 }
6399
6400 #[test]
6401 fn test_escape_no_special_chars() {
6402 assert_eq!(render_signal("Hello World"), "Hello World");
6403 }
6404
6405 #[test]
6406 fn test_escape_empty_string() {
6407 assert_eq!(render_signal(""), "");
6408 }
6409
6410 #[test]
6411 fn test_escape_special_at_beginning() {
6412 let result = render_signal("<Hello");
6413 assert!(
6414 result.starts_with("<"),
6415 "Expected < at start, got: {}",
6416 result
6417 );
6418 }
6419
6420 #[test]
6421 fn test_escape_special_at_end() {
6422 let result = render_signal("Hello>");
6423 assert!(
6424 result.ends_with(">"),
6425 "Expected > at end, got: {}",
6426 result
6427 );
6428 }
6429
6430 #[test]
6431 fn test_escape_special_in_middle() {
6432 let result = render_signal("Hel&lo");
6433 assert!(
6434 result.contains("&"),
6435 "Expected & in middle, got: {}",
6436 result
6437 );
6438 }
6439
6440 #[test]
6443 fn test_boolean_attr_truthy_values() {
6444 {
6446 let mut fragments = HashMap::new();
6447 fragments.insert(
6448 "index.html".to_string(),
6449 FragmentList {
6450 fragments: vec![
6451 WebUIFragment::raw("<input"),
6452 WebUIFragment::attribute_boolean(
6453 "checked",
6454 ConditionExpr::identifier("checked"),
6455 ),
6456 WebUIFragment::raw(">"),
6457 ],
6458 contains_boundary: false,
6459 },
6460 );
6461 let protocol = WebUIProtocol::new(fragments);
6462 let state = test_json!({"checked": 1});
6463 let mut writer = TestWriter::new();
6464 handle(
6465 &protocol,
6466 &state,
6467 &RenderOptions::new("index.html", "/"),
6468 &mut writer,
6469 )
6470 .unwrap();
6471 assert_eq!(writer.get_content(), "<input checked>");
6472 }
6473 {
6475 let mut fragments = HashMap::new();
6476 fragments.insert(
6477 "index.html".to_string(),
6478 FragmentList {
6479 fragments: vec![
6480 WebUIFragment::raw("<input"),
6481 WebUIFragment::attribute_boolean(
6482 "checked",
6483 ConditionExpr::identifier("checked"),
6484 ),
6485 WebUIFragment::raw(">"),
6486 ],
6487 contains_boundary: false,
6488 },
6489 );
6490 let protocol = WebUIProtocol::new(fragments);
6491 let state = test_json!({"checked": "yes"});
6492 let mut writer = TestWriter::new();
6493 handle(
6494 &protocol,
6495 &state,
6496 &RenderOptions::new("index.html", "/"),
6497 &mut writer,
6498 )
6499 .unwrap();
6500 assert_eq!(writer.get_content(), "<input checked>");
6501 }
6502 {
6504 let mut fragments = HashMap::new();
6505 fragments.insert(
6506 "index.html".to_string(),
6507 FragmentList {
6508 fragments: vec![
6509 WebUIFragment::raw("<input"),
6510 WebUIFragment::attribute_boolean(
6511 "checked",
6512 ConditionExpr::identifier("checked"),
6513 ),
6514 WebUIFragment::raw(">"),
6515 ],
6516 contains_boundary: false,
6517 },
6518 );
6519 let protocol = WebUIProtocol::new(fragments);
6520 let state = test_json!({"checked": {}});
6521 let mut writer = TestWriter::new();
6522 handle(
6523 &protocol,
6524 &state,
6525 &RenderOptions::new("index.html", "/"),
6526 &mut writer,
6527 )
6528 .unwrap();
6529 assert_eq!(writer.get_content(), "<input>");
6531 }
6532 {
6534 let mut fragments = HashMap::new();
6535 fragments.insert(
6536 "index.html".to_string(),
6537 FragmentList {
6538 fragments: vec![
6539 WebUIFragment::raw("<input"),
6540 WebUIFragment::attribute_boolean(
6541 "checked",
6542 ConditionExpr::identifier("checked"),
6543 ),
6544 WebUIFragment::raw(">"),
6545 ],
6546 contains_boundary: false,
6547 },
6548 );
6549 let protocol = WebUIProtocol::new(fragments);
6550 let state = test_json!({"checked": "false"});
6551 let mut writer = TestWriter::new();
6552 handle(
6553 &protocol,
6554 &state,
6555 &RenderOptions::new("index.html", "/"),
6556 &mut writer,
6557 )
6558 .unwrap();
6559 assert_eq!(writer.get_content(), "<input checked>");
6560 }
6561 }
6562
6563 #[test]
6564 fn test_boolean_attr_falsy_values() {
6565 {
6567 let mut fragments = HashMap::new();
6568 fragments.insert(
6569 "index.html".to_string(),
6570 FragmentList {
6571 fragments: vec![
6572 WebUIFragment::raw("<input"),
6573 WebUIFragment::attribute_boolean(
6574 "checked",
6575 ConditionExpr::identifier("checked"),
6576 ),
6577 WebUIFragment::raw(">"),
6578 ],
6579 contains_boundary: false,
6580 },
6581 );
6582 let protocol = WebUIProtocol::new(fragments);
6583 let state = test_json!({"checked": 0});
6584 let mut writer = TestWriter::new();
6585 handle(
6586 &protocol,
6587 &state,
6588 &RenderOptions::new("index.html", "/"),
6589 &mut writer,
6590 )
6591 .unwrap();
6592 assert_eq!(writer.get_content(), "<input>");
6593 }
6594 {
6596 let mut fragments = HashMap::new();
6597 fragments.insert(
6598 "index.html".to_string(),
6599 FragmentList {
6600 fragments: vec![
6601 WebUIFragment::raw("<input"),
6602 WebUIFragment::attribute_boolean(
6603 "checked",
6604 ConditionExpr::identifier("checked"),
6605 ),
6606 WebUIFragment::raw(">"),
6607 ],
6608 contains_boundary: false,
6609 },
6610 );
6611 let protocol = WebUIProtocol::new(fragments);
6612 let state = test_json!({"checked": ""});
6613 let mut writer = TestWriter::new();
6614 handle(
6615 &protocol,
6616 &state,
6617 &RenderOptions::new("index.html", "/"),
6618 &mut writer,
6619 )
6620 .unwrap();
6621 assert_eq!(writer.get_content(), "<input>");
6622 }
6623 {
6625 let mut fragments = HashMap::new();
6626 fragments.insert(
6627 "index.html".to_string(),
6628 FragmentList {
6629 fragments: vec![
6630 WebUIFragment::raw("<input"),
6631 WebUIFragment::attribute_boolean(
6632 "checked",
6633 ConditionExpr::identifier("checked"),
6634 ),
6635 WebUIFragment::raw(">"),
6636 ],
6637 contains_boundary: false,
6638 },
6639 );
6640 let protocol = WebUIProtocol::new(fragments);
6641 let state = test_json!({"checked": false});
6642 let mut writer = TestWriter::new();
6643 handle(
6644 &protocol,
6645 &state,
6646 &RenderOptions::new("index.html", "/"),
6647 &mut writer,
6648 )
6649 .unwrap();
6650 assert_eq!(writer.get_content(), "<input>");
6651 }
6652 {
6654 let mut fragments = HashMap::new();
6655 fragments.insert(
6656 "index.html".to_string(),
6657 FragmentList {
6658 fragments: vec![
6659 WebUIFragment::raw("<input"),
6660 WebUIFragment::attribute_boolean(
6661 "checked",
6662 ConditionExpr::identifier("checked"),
6663 ),
6664 WebUIFragment::raw(">"),
6665 ],
6666 contains_boundary: false,
6667 },
6668 );
6669 let protocol = WebUIProtocol::new(fragments);
6670 let state = test_json!({});
6671 let mut writer = TestWriter::new();
6672 handle(
6673 &protocol,
6674 &state,
6675 &RenderOptions::new("index.html", "/"),
6676 &mut writer,
6677 )
6678 .unwrap();
6679 assert_eq!(writer.get_content(), "<input>");
6680 }
6681 }
6682
6683 #[test]
6684 fn test_boolean_attr_expression_true() {
6685 let mut fragments = HashMap::new();
6686 fragments.insert(
6687 "index.html".to_string(),
6688 FragmentList {
6689 fragments: vec![
6690 WebUIFragment::raw("<button"),
6691 WebUIFragment::attribute_boolean(
6692 "disabled",
6693 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6694 ),
6695 WebUIFragment::raw(">Click</button>"),
6696 ],
6697 contains_boundary: false,
6698 },
6699 );
6700 let protocol = WebUIProtocol::new(fragments);
6701 let state = test_json!({"itemCount": 5});
6702 let mut writer = TestWriter::new();
6703 handle(
6704 &protocol,
6705 &state,
6706 &RenderOptions::new("index.html", "/"),
6707 &mut writer,
6708 )
6709 .unwrap();
6710 assert_eq!(writer.get_content(), "<button disabled>Click</button>");
6711 }
6712
6713 #[test]
6714 fn test_boolean_attr_expression_false() {
6715 let mut fragments = HashMap::new();
6716 fragments.insert(
6717 "index.html".to_string(),
6718 FragmentList {
6719 fragments: vec![
6720 WebUIFragment::raw("<button"),
6721 WebUIFragment::attribute_boolean(
6722 "disabled",
6723 ConditionExpr::predicate("itemCount", ComparisonOperator::Equal, "5"),
6724 ),
6725 WebUIFragment::raw(">Click</button>"),
6726 ],
6727 contains_boundary: false,
6728 },
6729 );
6730 let protocol = WebUIProtocol::new(fragments);
6731 let state = test_json!({"itemCount": 3});
6732 let mut writer = TestWriter::new();
6733 handle(
6734 &protocol,
6735 &state,
6736 &RenderOptions::new("index.html", "/"),
6737 &mut writer,
6738 )
6739 .unwrap();
6740 assert_eq!(writer.get_content(), "<button>Click</button>");
6741 }
6742
6743 #[test]
6746 fn test_nested_component_attr_capture() {
6747 let mut fragments = HashMap::new();
6748 fragments.insert(
6749 "index.html".to_string(),
6750 FragmentList {
6751 fragments: vec![
6752 WebUIFragment::raw("<parent-component"),
6753 WebUIFragment {
6754 fragment: Some(web_ui_fragment::Fragment::Attribute(
6755 WebUIFragmentAttribute {
6756 name: "title".into(),
6757 template: "parent-title".into(),
6758 attr_start: true,
6759 ..Default::default()
6760 },
6761 )),
6762 },
6763 WebUIFragment::raw(">"),
6764 WebUIFragment::component("parent-component"),
6765 WebUIFragment::raw("</parent-component>"),
6766 ],
6767
6768 contains_boundary: false,
6769 },
6770 );
6771 fragments.insert(
6772 "parent-title".to_string(),
6773 FragmentList {
6774 fragments: vec![
6775 WebUIFragment::raw("Hello "),
6776 WebUIFragment::signal("who", false),
6777 ],
6778 contains_boundary: false,
6779 },
6780 );
6781 fragments.insert(
6782 "parent-component".to_string(),
6783 FragmentList {
6784 fragments: vec![
6785 WebUIFragment::raw("<child-component"),
6786 WebUIFragment {
6787 fragment: Some(web_ui_fragment::Fragment::Attribute(
6788 WebUIFragmentAttribute {
6789 name: "title".into(),
6790 template: "child-title".into(),
6791 attr_start: true,
6792 ..Default::default()
6793 },
6794 )),
6795 },
6796 WebUIFragment::raw(">"),
6797 WebUIFragment::component("child-component"),
6798 WebUIFragment::raw("</child-component>"),
6799 ],
6800
6801 contains_boundary: false,
6802 },
6803 );
6804 fragments.insert(
6805 "child-title".to_string(),
6806 FragmentList {
6807 fragments: vec![
6808 WebUIFragment::raw("Child of "),
6809 WebUIFragment::signal("title", false),
6810 ],
6811 contains_boundary: false,
6812 },
6813 );
6814 fragments.insert(
6815 "child-component".to_string(),
6816 FragmentList {
6817 fragments: vec![
6818 WebUIFragment::raw("<span>"),
6819 WebUIFragment::signal("title", false),
6820 WebUIFragment::raw("</span>"),
6821 ],
6822 contains_boundary: false,
6823 },
6824 );
6825 let protocol = WebUIProtocol::new(fragments);
6826 let state = test_json!({"who": "<world>"});
6827 let mut writer = TestWriter::new();
6828 handle(
6829 &protocol,
6830 &state,
6831 &RenderOptions::new("index.html", "/"),
6832 &mut writer,
6833 )
6834 .unwrap();
6835 assert_eq!(
6836 writer.get_content(),
6837 "<parent-component title=\"Hello <world>\"><child-component title=\"Child of Hello <world>\"><span>Child of Hello <world></span></child-component></parent-component>"
6838 );
6839 }
6840
6841 #[test]
6842 fn test_grandchild_attr_propagation() {
6843 let mut fragments = HashMap::new();
6844 fragments.insert(
6845 "index.html".to_string(),
6846 FragmentList {
6847 fragments: vec![
6848 WebUIFragment::raw("<parent-component"),
6849 WebUIFragment {
6850 fragment: Some(web_ui_fragment::Fragment::Attribute(
6851 WebUIFragmentAttribute {
6852 name: "title".into(),
6853 template: "p-title".into(),
6854 attr_start: true,
6855 ..Default::default()
6856 },
6857 )),
6858 },
6859 WebUIFragment::raw(">"),
6860 WebUIFragment::component("parent-component"),
6861 WebUIFragment::raw("</parent-component>"),
6862 ],
6863
6864 contains_boundary: false,
6865 },
6866 );
6867 fragments.insert(
6868 "p-title".to_string(),
6869 FragmentList {
6870 fragments: vec![WebUIFragment::raw("P:"), WebUIFragment::signal("p", false)],
6871 contains_boundary: false,
6872 },
6873 );
6874 fragments.insert(
6875 "parent-component".to_string(),
6876 FragmentList {
6877 fragments: vec![
6878 WebUIFragment::raw("<child-component"),
6879 WebUIFragment {
6880 fragment: Some(web_ui_fragment::Fragment::Attribute(
6881 WebUIFragmentAttribute {
6882 name: "title".into(),
6883 template: "c-title".into(),
6884 attr_start: true,
6885 ..Default::default()
6886 },
6887 )),
6888 },
6889 WebUIFragment::raw(">"),
6890 WebUIFragment::component("child-component"),
6891 WebUIFragment::raw("</child-component>"),
6892 ],
6893
6894 contains_boundary: false,
6895 },
6896 );
6897 fragments.insert(
6898 "c-title".to_string(),
6899 FragmentList {
6900 fragments: vec![
6901 WebUIFragment::raw("C("),
6902 WebUIFragment::signal("title", false),
6903 WebUIFragment::raw(")-"),
6904 WebUIFragment::signal("cExtra", false),
6905 ],
6906 contains_boundary: false,
6907 },
6908 );
6909 fragments.insert(
6910 "child-component".to_string(),
6911 FragmentList {
6912 fragments: vec![
6913 WebUIFragment::raw("<grandchild-component"),
6914 WebUIFragment {
6915 fragment: Some(web_ui_fragment::Fragment::Attribute(
6916 WebUIFragmentAttribute {
6917 name: "title".into(),
6918 value: "title".into(),
6919 attr_start: true,
6920 ..Default::default()
6921 },
6922 )),
6923 },
6924 WebUIFragment::raw(">"),
6925 WebUIFragment::component("grandchild-component"),
6926 WebUIFragment::raw("</grandchild-component>"),
6927 ],
6928
6929 contains_boundary: false,
6930 },
6931 );
6932 fragments.insert(
6933 "grandchild-component".to_string(),
6934 FragmentList {
6935 fragments: vec![
6936 WebUIFragment::raw("<span>"),
6937 WebUIFragment::signal("title", false),
6938 WebUIFragment::raw("</span>"),
6939 ],
6940 contains_boundary: false,
6941 },
6942 );
6943 let protocol = WebUIProtocol::new(fragments);
6944 let state = test_json!({"p": "<p>", "cExtra": "x&y"});
6945 let mut writer = TestWriter::new();
6946 handle(
6947 &protocol,
6948 &state,
6949 &RenderOptions::new("index.html", "/"),
6950 &mut writer,
6951 )
6952 .unwrap();
6953 assert_eq!(
6954 writer.get_content(),
6955 "<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>"
6956 );
6957 }
6958
6959 #[test]
6960 fn test_for_loop_component_attr() {
6961 let mut fragments = HashMap::new();
6962 fragments.insert(
6963 "index.html".to_string(),
6964 FragmentList {
6965 fragments: vec![
6966 WebUIFragment::raw("<parent-component"),
6967 WebUIFragment {
6968 fragment: Some(web_ui_fragment::Fragment::Attribute(
6969 WebUIFragmentAttribute {
6970 name: "title".into(),
6971 template: "parent-title-loop".into(),
6972 attr_start: true,
6973 ..Default::default()
6974 },
6975 )),
6976 },
6977 WebUIFragment::raw(">"),
6978 WebUIFragment::component("parent-component"),
6979 WebUIFragment::raw("</parent-component>"),
6980 ],
6981
6982 contains_boundary: false,
6983 },
6984 );
6985 fragments.insert(
6986 "parent-title-loop".to_string(),
6987 FragmentList {
6988 fragments: vec![
6989 WebUIFragment::raw("Parent:"),
6990 WebUIFragment::signal("who", false),
6991 ],
6992 contains_boundary: false,
6993 },
6994 );
6995 fragments.insert(
6996 "parent-component".to_string(),
6997 FragmentList {
6998 fragments: vec![WebUIFragment::for_loop("item", "items", "child-loop")],
6999 contains_boundary: false,
7000 },
7001 );
7002 fragments.insert(
7003 "child-loop".to_string(),
7004 FragmentList {
7005 fragments: vec![
7006 WebUIFragment::raw("<child-component"),
7007 WebUIFragment {
7008 fragment: Some(web_ui_fragment::Fragment::Attribute(
7009 WebUIFragmentAttribute {
7010 name: "title".into(),
7011 template: "child-title-loop".into(),
7012 attr_start: true,
7013 ..Default::default()
7014 },
7015 )),
7016 },
7017 WebUIFragment::raw(">"),
7018 WebUIFragment::component("child-component"),
7019 WebUIFragment::raw("</child-component>"),
7020 ],
7021
7022 contains_boundary: false,
7023 },
7024 );
7025 fragments.insert(
7026 "child-title-loop".to_string(),
7027 FragmentList {
7028 fragments: vec![
7029 WebUIFragment::raw("Hi "),
7030 WebUIFragment::signal("item.name", false),
7031 WebUIFragment::raw(" / "),
7032 WebUIFragment::signal("title", false),
7033 ],
7034 contains_boundary: false,
7035 },
7036 );
7037 fragments.insert(
7038 "child-component".to_string(),
7039 FragmentList {
7040 fragments: vec![
7041 WebUIFragment::raw("<span>"),
7042 WebUIFragment::signal("title", false),
7043 WebUIFragment::raw("</span>"),
7044 ],
7045 contains_boundary: false,
7046 },
7047 );
7048 let protocol = WebUIProtocol::new(fragments);
7049 let state = test_json!({"who": "Bob", "items": [{"name": "A<1>"}, {"name": "B&2"}]});
7050 let mut writer = TestWriter::new();
7051 handle(
7052 &protocol,
7053 &state,
7054 &RenderOptions::new("index.html", "/"),
7055 &mut writer,
7056 )
7057 .unwrap();
7058 assert_eq!(
7059 writer.get_content(),
7060 "<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>"
7061 );
7062 }
7063
7064 #[test]
7065 fn test_multiple_template_attrs() {
7066 let mut fragments = HashMap::new();
7067 fragments.insert(
7068 "index.html".to_string(),
7069 FragmentList {
7070 fragments: vec![
7071 WebUIFragment::raw("<my-component"),
7072 WebUIFragment {
7073 fragment: Some(web_ui_fragment::Fragment::Attribute(
7074 WebUIFragmentAttribute {
7075 name: "title".into(),
7076 template: "attr-title".into(),
7077 attr_start: true,
7078 ..Default::default()
7079 },
7080 )),
7081 },
7082 WebUIFragment {
7083 fragment: Some(web_ui_fragment::Fragment::Attribute(
7084 WebUIFragmentAttribute {
7085 name: "data-title".into(),
7086 template: "attr-data-title".into(),
7087 attr_start: false,
7088 ..Default::default()
7089 },
7090 )),
7091 },
7092 WebUIFragment {
7093 fragment: Some(web_ui_fragment::Fragment::Attribute(
7094 WebUIFragmentAttribute {
7095 name: "aria-label".into(),
7096 template: "attr-aria-label".into(),
7097 attr_start: false,
7098 ..Default::default()
7099 },
7100 )),
7101 },
7102 WebUIFragment::raw(">"),
7103 WebUIFragment::component("my-component"),
7104 WebUIFragment::raw("</my-component>"),
7105 ],
7106
7107 contains_boundary: false,
7108 },
7109 );
7110 fragments.insert(
7111 "attr-title".to_string(),
7112 FragmentList {
7113 fragments: vec![WebUIFragment::raw("T:"), WebUIFragment::signal("t", false)],
7114 contains_boundary: false,
7115 },
7116 );
7117 fragments.insert(
7118 "attr-data-title".to_string(),
7119 FragmentList {
7120 fragments: vec![WebUIFragment::raw("D:"), WebUIFragment::signal("d", false)],
7121 contains_boundary: false,
7122 },
7123 );
7124 fragments.insert(
7125 "attr-aria-label".to_string(),
7126 FragmentList {
7127 fragments: vec![WebUIFragment::raw("A:"), WebUIFragment::signal("a", false)],
7128 contains_boundary: false,
7129 },
7130 );
7131 fragments.insert(
7132 "my-component".to_string(),
7133 FragmentList {
7134 fragments: vec![
7135 WebUIFragment::raw("<span>"),
7136 WebUIFragment::signal("title", false),
7137 WebUIFragment::raw("|"),
7138 WebUIFragment::signal("dataTitle", false),
7139 WebUIFragment::raw("|"),
7140 WebUIFragment::signal("ariaLabel", false),
7141 WebUIFragment::raw("</span>"),
7142 ],
7143 contains_boundary: false,
7144 },
7145 );
7146 let protocol = WebUIProtocol::new(fragments);
7147 let state = test_json!({"t": "<t&1>", "d": "d<2>", "a": "a&3"});
7148 let mut writer = TestWriter::new();
7149 handle(
7150 &protocol,
7151 &state,
7152 &RenderOptions::new("index.html", "/"),
7153 &mut writer,
7154 )
7155 .unwrap();
7156 assert_eq!(
7157 writer.get_content(),
7158 "<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>"
7159 );
7160 }
7161
7162 #[test]
7163 fn test_attr_priority_over_global() {
7164 let mut fragments = HashMap::new();
7165 fragments.insert(
7166 "index.html".to_string(),
7167 FragmentList {
7168 fragments: vec![
7169 WebUIFragment::raw("<my-component"),
7170 WebUIFragment {
7171 fragment: Some(web_ui_fragment::Fragment::Attribute(
7172 WebUIFragmentAttribute {
7173 name: "title".into(),
7174 value: "Attribute Title".into(),
7175 raw_value: true,
7176 attr_start: true,
7177 ..Default::default()
7178 },
7179 )),
7180 },
7181 WebUIFragment::raw(">"),
7182 WebUIFragment::component("my-component"),
7183 WebUIFragment::raw("</my-component>"),
7184 ],
7185
7186 contains_boundary: false,
7187 },
7188 );
7189 fragments.insert(
7190 "my-component".to_string(),
7191 FragmentList {
7192 fragments: vec![
7193 WebUIFragment::raw("<span>"),
7194 WebUIFragment::signal("title", false),
7195 WebUIFragment::raw("</span>"),
7196 ],
7197 contains_boundary: false,
7198 },
7199 );
7200 let protocol = WebUIProtocol::new(fragments);
7201 let state = test_json!({"title": "Global Title"});
7202 let mut writer = TestWriter::new();
7203 handle(
7204 &protocol,
7205 &state,
7206 &RenderOptions::new("index.html", "/"),
7207 &mut writer,
7208 )
7209 .unwrap();
7210 assert_eq!(
7211 writer.get_content(),
7212 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7213 );
7214 }
7215
7216 #[test]
7217 fn test_attr_priority_over_local_and_global() {
7218 let mut fragments = HashMap::new();
7219 fragments.insert(
7220 "index.html".to_string(),
7221 FragmentList {
7222 fragments: vec![WebUIFragment::for_loop("item", "items", "loop")],
7223 contains_boundary: false,
7224 },
7225 );
7226 fragments.insert(
7227 "loop".to_string(),
7228 FragmentList {
7229 fragments: vec![
7230 WebUIFragment::raw("<my-component"),
7231 WebUIFragment {
7232 fragment: Some(web_ui_fragment::Fragment::Attribute(
7233 WebUIFragmentAttribute {
7234 name: "title".into(),
7235 value: "Attribute Title".into(),
7236 raw_value: true,
7237 attr_start: true,
7238 ..Default::default()
7239 },
7240 )),
7241 },
7242 WebUIFragment::raw(">"),
7243 WebUIFragment::component("my-component"),
7244 WebUIFragment::raw("</my-component>"),
7245 ],
7246
7247 contains_boundary: false,
7248 },
7249 );
7250 fragments.insert(
7251 "my-component".to_string(),
7252 FragmentList {
7253 fragments: vec![
7254 WebUIFragment::raw("<span>"),
7255 WebUIFragment::signal("title", false),
7256 WebUIFragment::raw("</span>"),
7257 ],
7258 contains_boundary: false,
7259 },
7260 );
7261 let protocol = WebUIProtocol::new(fragments);
7262 let state = test_json!({"title": "Global Title", "items": [{"title": "Local Title"}]});
7263 let mut writer = TestWriter::new();
7264 handle(
7265 &protocol,
7266 &state,
7267 &RenderOptions::new("index.html", "/"),
7268 &mut writer,
7269 )
7270 .unwrap();
7271 assert_eq!(
7272 writer.get_content(),
7273 "<my-component title=\"Attribute Title\"><span>Attribute Title</span></my-component>"
7274 );
7275 }
7276
7277 #[test]
7278 fn test_boolean_attr_first_component_attr() {
7279 let mut fragments = HashMap::new();
7280 fragments.insert(
7281 "index.html".to_string(),
7282 FragmentList {
7283 fragments: vec![
7284 WebUIFragment::raw("<my-component"),
7285 WebUIFragment {
7286 fragment: Some(web_ui_fragment::Fragment::Attribute(
7287 WebUIFragmentAttribute {
7288 name: "disabled".into(),
7289 attr_start: true,
7290 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7291 ..Default::default()
7292 },
7293 )),
7294 },
7295 WebUIFragment {
7296 fragment: Some(web_ui_fragment::Fragment::Attribute(
7297 WebUIFragmentAttribute {
7298 name: "label".into(),
7299 value: "Component Label".into(),
7300 raw_value: true,
7301 attr_start: false,
7302 ..Default::default()
7303 },
7304 )),
7305 },
7306 WebUIFragment::raw(">"),
7307 WebUIFragment::component("my-component"),
7308 WebUIFragment::raw("</my-component>"),
7309 ],
7310
7311 contains_boundary: false,
7312 },
7313 );
7314 fragments.insert(
7315 "my-component".to_string(),
7316 FragmentList {
7317 fragments: vec![
7318 WebUIFragment::if_cond(
7319 ConditionExpr::identifier("disabled"),
7320 "disabledTemplate",
7321 ),
7322 WebUIFragment::raw("<span>"),
7323 WebUIFragment::signal("label", false),
7324 WebUIFragment::raw("</span>"),
7325 ],
7326 contains_boundary: false,
7327 },
7328 );
7329 fragments.insert(
7330 "disabledTemplate".to_string(),
7331 FragmentList {
7332 fragments: vec![WebUIFragment::raw("<div>Disabled</div>")],
7333 contains_boundary: false,
7334 },
7335 );
7336 let protocol = WebUIProtocol::new(fragments);
7337 let state = test_json!({"isDisabled": true});
7338 let mut writer = TestWriter::new();
7339 handle(
7340 &protocol,
7341 &state,
7342 &RenderOptions::new("index.html", "/"),
7343 &mut writer,
7344 )
7345 .unwrap();
7346 assert_eq!(
7347 writer.get_content(),
7348 "<my-component disabled label=\"Component Label\"><div>Disabled</div><span>Component Label</span></my-component>"
7349 );
7350 }
7351
7352 #[test]
7353 fn test_hyphenated_attr_camelcase() {
7354 let mut fragments = HashMap::new();
7355 fragments.insert(
7356 "index.html".to_string(),
7357 FragmentList {
7358 fragments: vec![
7359 WebUIFragment::raw("<my-component"),
7360 WebUIFragment {
7361 fragment: Some(web_ui_fragment::Fragment::Attribute(
7362 WebUIFragmentAttribute {
7363 name: "key-hyphen".into(),
7364 value: "Local Value".into(),
7365 raw_value: true,
7366 attr_start: true,
7367 ..Default::default()
7368 },
7369 )),
7370 },
7371 WebUIFragment::raw(">"),
7372 WebUIFragment::component("my-component"),
7373 WebUIFragment::raw("</my-component>"),
7374 ],
7375
7376 contains_boundary: false,
7377 },
7378 );
7379 fragments.insert(
7380 "my-component".to_string(),
7381 FragmentList {
7382 fragments: vec![
7383 WebUIFragment::raw("<span>"),
7384 WebUIFragment::signal("keyHyphen", false),
7385 WebUIFragment::raw("</span>"),
7386 ],
7387 contains_boundary: false,
7388 },
7389 );
7390 let protocol = WebUIProtocol::new(fragments);
7391 let state = test_json!({"keyHyphen": "Global Value"});
7392 let mut writer = TestWriter::new();
7393 handle(
7394 &protocol,
7395 &state,
7396 &RenderOptions::new("index.html", "/"),
7397 &mut writer,
7398 )
7399 .unwrap();
7400 assert_eq!(
7401 writer.get_content(),
7402 "<my-component key-hyphen=\"Local Value\"><span>Local Value</span></my-component>"
7403 );
7404 }
7405
7406 #[test]
7407 fn test_skipped_component_attrs() {
7408 let mut fragments = HashMap::new();
7412 fragments.insert(
7413 "index.html".to_string(),
7414 FragmentList {
7415 fragments: vec![
7416 WebUIFragment::raw("<test-component"),
7417 WebUIFragment {
7419 fragment: Some(web_ui_fragment::Fragment::Attribute(
7420 WebUIFragmentAttribute {
7421 name: "class".into(),
7422 value: "skippedClass".into(),
7423 attr_start: true,
7424 attr_skip: true,
7425 ..Default::default()
7426 },
7427 )),
7428 },
7429 WebUIFragment {
7431 fragment: Some(web_ui_fragment::Fragment::Attribute(
7432 WebUIFragmentAttribute {
7433 name: "style".into(),
7434 value: "skippedStyle".into(),
7435 attr_skip: true,
7436 ..Default::default()
7437 },
7438 )),
7439 },
7440 WebUIFragment {
7442 fragment: Some(web_ui_fragment::Fragment::Attribute(
7443 WebUIFragmentAttribute {
7444 name: "role".into(),
7445 value: "skippedRole".into(),
7446 attr_skip: true,
7447 ..Default::default()
7448 },
7449 )),
7450 },
7451 WebUIFragment {
7453 fragment: Some(web_ui_fragment::Fragment::Attribute(
7454 WebUIFragmentAttribute {
7455 name: "data-testid".into(),
7456 value: "skippedDataTestid".into(),
7457 attr_skip: true,
7458 ..Default::default()
7459 },
7460 )),
7461 },
7462 WebUIFragment {
7464 fragment: Some(web_ui_fragment::Fragment::Attribute(
7465 WebUIFragmentAttribute {
7466 name: "aria-label".into(),
7467 value: "skippedAriaLabel".into(),
7468 attr_skip: true,
7469 ..Default::default()
7470 },
7471 )),
7472 },
7473 WebUIFragment {
7475 fragment: Some(web_ui_fragment::Fragment::Attribute(
7476 WebUIFragmentAttribute {
7477 name: "title".into(),
7478 value: "title".into(),
7479 ..Default::default()
7480 },
7481 )),
7482 },
7483 WebUIFragment::raw(">"),
7484 WebUIFragment::component("test-component"),
7485 WebUIFragment::raw("</test-component>"),
7486 ],
7487
7488 contains_boundary: false,
7489 },
7490 );
7491 fragments.insert(
7492 "test-component".to_string(),
7493 FragmentList {
7494 fragments: vec![
7495 WebUIFragment::raw("<span>"),
7496 WebUIFragment::signal("title", false),
7497 WebUIFragment::raw("-"),
7498 WebUIFragment::signal("class", false),
7499 WebUIFragment::raw("-"),
7500 WebUIFragment::signal("style", false),
7501 WebUIFragment::raw("-"),
7502 WebUIFragment::signal("role", false),
7503 WebUIFragment::raw("-"),
7504 WebUIFragment::signal("dataTestid", false),
7505 WebUIFragment::raw("-"),
7506 WebUIFragment::signal("ariaLabel", false),
7507 WebUIFragment::raw("</span>"),
7508 ],
7509 contains_boundary: false,
7510 },
7511 );
7512 let protocol = WebUIProtocol::new(fragments);
7513 let state = test_json!({
7514 "title": "Hello",
7515 "skippedClass": "my-class",
7516 "skippedStyle": "color:red",
7517 "skippedRole": "button",
7518 "skippedDataTestid": "test-id",
7519 "skippedAriaLabel": "label-text"
7520 });
7521 let mut writer = TestWriter::new();
7522 handle(
7523 &protocol,
7524 &state,
7525 &RenderOptions::new("index.html", "/"),
7526 &mut writer,
7527 )
7528 .unwrap();
7529 assert_eq!(
7533 writer.get_content(),
7534 "<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>"
7535 );
7536 }
7537
7538 #[test]
7541 fn test_attr_inherit_parent_to_child() {
7542 let mut fragments = HashMap::new();
7543 fragments.insert(
7544 "index.html".to_string(),
7545 FragmentList {
7546 fragments: vec![
7547 WebUIFragment::raw("<parent-component"),
7548 WebUIFragment {
7549 fragment: Some(web_ui_fragment::Fragment::Attribute(
7550 WebUIFragmentAttribute {
7551 name: "title".into(),
7552 value: "Parent Title".into(),
7553 raw_value: true,
7554 attr_start: true,
7555 ..Default::default()
7556 },
7557 )),
7558 },
7559 WebUIFragment::raw(">"),
7560 WebUIFragment::component("parent-component"),
7561 WebUIFragment::raw("</parent-component>"),
7562 ],
7563
7564 contains_boundary: false,
7565 },
7566 );
7567 fragments.insert(
7568 "parent-component".to_string(),
7569 FragmentList {
7570 fragments: vec![
7571 WebUIFragment::raw("<h1>"),
7572 WebUIFragment::signal("title", false),
7573 WebUIFragment::raw("</h1><child-component"),
7574 WebUIFragment {
7575 fragment: Some(web_ui_fragment::Fragment::Attribute(
7576 WebUIFragmentAttribute {
7577 name: "title".into(),
7578 value: "title".into(),
7579 attr_start: true,
7580 ..Default::default()
7581 },
7582 )),
7583 },
7584 WebUIFragment::raw(">"),
7585 WebUIFragment::component("child-component"),
7586 WebUIFragment::raw("</child-component>"),
7587 ],
7588
7589 contains_boundary: false,
7590 },
7591 );
7592 fragments.insert(
7593 "child-component".to_string(),
7594 FragmentList {
7595 fragments: vec![
7596 WebUIFragment::raw("<h2>"),
7597 WebUIFragment::signal("title", false),
7598 WebUIFragment::raw("</h2>"),
7599 ],
7600 contains_boundary: false,
7601 },
7602 );
7603 let protocol = WebUIProtocol::new(fragments);
7604 let state = test_json!({});
7605 let mut writer = TestWriter::new();
7606 handle(
7607 &protocol,
7608 &state,
7609 &RenderOptions::new("index.html", "/"),
7610 &mut writer,
7611 )
7612 .unwrap();
7613 assert_eq!(
7614 writer.get_content(),
7615 "<parent-component title=\"Parent Title\"><h1>Parent Title</h1><child-component title=\"Parent Title\"><h2>Parent Title</h2></child-component></parent-component>"
7616 );
7617 }
7618
7619 #[test]
7620 fn test_attr_inherit_deep() {
7621 let mut fragments = HashMap::new();
7622 fragments.insert(
7623 "index.html".to_string(),
7624 FragmentList {
7625 fragments: vec![
7626 WebUIFragment::raw("<parent-component"),
7627 WebUIFragment {
7628 fragment: Some(web_ui_fragment::Fragment::Attribute(
7629 WebUIFragmentAttribute {
7630 name: "title".into(),
7631 value: "Parent Title".into(),
7632 raw_value: true,
7633 attr_start: true,
7634 ..Default::default()
7635 },
7636 )),
7637 },
7638 WebUIFragment::raw(">"),
7639 WebUIFragment::component("parent-component"),
7640 WebUIFragment::raw("</parent-component>"),
7641 ],
7642
7643 contains_boundary: false,
7644 },
7645 );
7646 fragments.insert(
7647 "parent-component".to_string(),
7648 FragmentList {
7649 fragments: vec![
7650 WebUIFragment::raw("<child-component"),
7651 WebUIFragment {
7652 fragment: Some(web_ui_fragment::Fragment::Attribute(
7653 WebUIFragmentAttribute {
7654 name: "title".into(),
7655 value: "Child Title".into(),
7656 raw_value: true,
7657 attr_start: true,
7658 ..Default::default()
7659 },
7660 )),
7661 },
7662 WebUIFragment::raw(">"),
7663 WebUIFragment::component("child-component"),
7664 WebUIFragment::raw("</child-component>"),
7665 ],
7666
7667 contains_boundary: false,
7668 },
7669 );
7670 fragments.insert(
7671 "child-component".to_string(),
7672 FragmentList {
7673 fragments: vec![
7674 WebUIFragment::raw("<grandchild-component"),
7675 WebUIFragment {
7676 fragment: Some(web_ui_fragment::Fragment::Attribute(
7677 WebUIFragmentAttribute {
7678 name: "title".into(),
7679 value: "title".into(),
7680 attr_start: true,
7681 ..Default::default()
7682 },
7683 )),
7684 },
7685 WebUIFragment::raw(">"),
7686 WebUIFragment::component("grandchild-component"),
7687 WebUIFragment::raw("</grandchild-component>"),
7688 ],
7689
7690 contains_boundary: false,
7691 },
7692 );
7693 fragments.insert(
7694 "grandchild-component".to_string(),
7695 FragmentList {
7696 fragments: vec![
7697 WebUIFragment::raw("<h3>"),
7698 WebUIFragment::signal("title", false),
7699 WebUIFragment::raw("</h3>"),
7700 ],
7701 contains_boundary: false,
7702 },
7703 );
7704 let protocol = WebUIProtocol::new(fragments);
7705 let state = test_json!({});
7706 let mut writer = TestWriter::new();
7707 handle(
7708 &protocol,
7709 &state,
7710 &RenderOptions::new("index.html", "/"),
7711 &mut writer,
7712 )
7713 .unwrap();
7714 assert_eq!(
7715 writer.get_content(),
7716 "<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>"
7717 );
7718 }
7719
7720 #[test]
7721 fn test_complex_attr_access() {
7722 let mut fragments = HashMap::new();
7723 fragments.insert(
7724 "index.html".to_string(),
7725 FragmentList {
7726 fragments: vec![
7727 WebUIFragment::raw("<my-component"),
7728 WebUIFragment {
7729 fragment: Some(web_ui_fragment::Fragment::Attribute(
7730 WebUIFragmentAttribute {
7731 name: ":item".into(),
7732 value: "complexItem".into(),
7733 attr_start: true,
7734 complex: true,
7735 ..Default::default()
7736 },
7737 )),
7738 },
7739 WebUIFragment::raw(">"),
7740 WebUIFragment::component("my-component"),
7741 WebUIFragment::raw("</my-component>"),
7742 ],
7743
7744 contains_boundary: false,
7745 },
7746 );
7747 fragments.insert(
7748 "my-component".to_string(),
7749 FragmentList {
7750 fragments: vec![
7751 WebUIFragment::raw("<span>"),
7752 WebUIFragment::signal("item.foo", false),
7753 WebUIFragment::raw("</span><p>"),
7754 WebUIFragment::signal("item.bar", false),
7755 WebUIFragment::raw("</p>"),
7756 ],
7757 contains_boundary: false,
7758 },
7759 );
7760 let protocol = WebUIProtocol::new(fragments);
7761 let state = test_json!({"complexItem": {"foo": 1, "bar": "true"}});
7762 let mut writer = TestWriter::new();
7763 handle(
7764 &protocol,
7765 &state,
7766 &RenderOptions::new("index.html", "/"),
7767 &mut writer,
7768 )
7769 .unwrap();
7770 assert_eq!(
7771 writer.get_content(),
7772 "<my-component><span>1</span><p>true</p></my-component>"
7773 );
7774 }
7775
7776 #[test]
7777 fn test_complex_attr_for_loop() {
7778 let mut fragments = HashMap::new();
7779 fragments.insert(
7780 "index.html".to_string(),
7781 FragmentList {
7782 fragments: vec![WebUIFragment::for_loop(
7783 "item",
7784 "list.items",
7785 "listTemplate",
7786 )],
7787 contains_boundary: false,
7788 },
7789 );
7790 fragments.insert(
7791 "listTemplate".to_string(),
7792 FragmentList {
7793 fragments: vec![
7794 WebUIFragment {
7795 fragment: Some(web_ui_fragment::Fragment::Attribute(
7796 WebUIFragmentAttribute {
7797 name: ":item".into(),
7798 value: "item".into(),
7799 attr_start: true,
7800 complex: true,
7801 ..Default::default()
7802 },
7803 )),
7804 },
7805 WebUIFragment::component("item_component"),
7806 ],
7807
7808 contains_boundary: false,
7809 },
7810 );
7811 fragments.insert(
7812 "item_component".to_string(),
7813 FragmentList {
7814 fragments: vec![
7815 WebUIFragment::raw("<span>"),
7816 WebUIFragment::signal("item.name", false),
7817 WebUIFragment::raw("</span>"),
7818 ],
7819 contains_boundary: false,
7820 },
7821 );
7822 let protocol = WebUIProtocol::new(fragments);
7823 let state = test_json!({"list": {"items": [{"name": "Alice"}, {"name": "Bob"}]}});
7824 let mut writer = TestWriter::new();
7825 handle(
7826 &protocol,
7827 &state,
7828 &RenderOptions::new("index.html", "/"),
7829 &mut writer,
7830 )
7831 .unwrap();
7832 assert_eq!(writer.get_content(), "<span>Alice</span><span>Bob</span>");
7833 }
7834
7835 #[test]
7836 fn test_complex_attr_nested_for() {
7837 let mut fragments = HashMap::new();
7838 fragments.insert(
7839 "index.html".to_string(),
7840 FragmentList {
7841 fragments: vec![WebUIFragment::for_loop(
7842 "outer",
7843 "data.outer",
7844 "outerTemplate",
7845 )],
7846 contains_boundary: false,
7847 },
7848 );
7849 fragments.insert(
7850 "outerTemplate".to_string(),
7851 FragmentList {
7852 fragments: vec![WebUIFragment::for_loop(
7853 "middle",
7854 "outer.middle",
7855 "middleTemplate",
7856 )],
7857 contains_boundary: false,
7858 },
7859 );
7860 fragments.insert(
7861 "middleTemplate".to_string(),
7862 FragmentList {
7863 fragments: vec![WebUIFragment::for_loop(
7864 "inner",
7865 "middle.inner",
7866 "innerTemplate",
7867 )],
7868 contains_boundary: false,
7869 },
7870 );
7871 fragments.insert(
7872 "innerTemplate".to_string(),
7873 FragmentList {
7874 fragments: vec![
7875 WebUIFragment::raw("<card"),
7876 WebUIFragment {
7877 fragment: Some(web_ui_fragment::Fragment::Attribute(
7878 WebUIFragmentAttribute {
7879 name: ":outer".into(),
7880 value: "outer".into(),
7881 attr_start: true,
7882 complex: true,
7883 ..Default::default()
7884 },
7885 )),
7886 },
7887 WebUIFragment {
7888 fragment: Some(web_ui_fragment::Fragment::Attribute(
7889 WebUIFragmentAttribute {
7890 name: ":middle".into(),
7891 value: "middle".into(),
7892 attr_start: false,
7893 complex: true,
7894 ..Default::default()
7895 },
7896 )),
7897 },
7898 WebUIFragment {
7899 fragment: Some(web_ui_fragment::Fragment::Attribute(
7900 WebUIFragmentAttribute {
7901 name: ":inner".into(),
7902 value: "inner".into(),
7903 attr_start: false,
7904 complex: true,
7905 ..Default::default()
7906 },
7907 )),
7908 },
7909 WebUIFragment::raw(">"),
7910 WebUIFragment::component("card_component"),
7911 WebUIFragment::raw("</card>"),
7912 ],
7913
7914 contains_boundary: false,
7915 },
7916 );
7917 fragments.insert(
7918 "card_component".to_string(),
7919 FragmentList {
7920 fragments: vec![
7921 WebUIFragment::raw("<p>"),
7922 WebUIFragment::signal("outer.label", false),
7923 WebUIFragment::raw(" / "),
7924 WebUIFragment::signal("middle.label", false),
7925 WebUIFragment::raw(" / "),
7926 WebUIFragment::signal("inner.label", false),
7927 WebUIFragment::raw("</p>"),
7928 ],
7929 contains_boundary: false,
7930 },
7931 );
7932 let protocol = WebUIProtocol::new(fragments);
7933 let state = test_json!({"data": {"outer": [
7934 {"label": "Outer1", "middle": [{"label": "Middle1", "inner": [{"label": "Inner1A"}, {"label": "Inner1B"}]}]},
7935 {"label": "Outer2", "middle": [{"label": "Middle2", "inner": [{"label": "Inner2A"}]}]}
7936 ]}});
7937 let mut writer = TestWriter::new();
7938 handle(
7939 &protocol,
7940 &state,
7941 &RenderOptions::new("index.html", "/"),
7942 &mut writer,
7943 )
7944 .unwrap();
7945 assert_eq!(
7946 writer.get_content(),
7947 "<card><p>Outer1 / Middle1 / Inner1A</p></card><card><p>Outer1 / Middle1 / Inner1B</p></card><card><p>Outer2 / Middle2 / Inner2A</p></card>"
7948 );
7949 }
7950
7951 #[test]
7954 fn test_bool_component_state_true() {
7955 let mut fragments = HashMap::new();
7956 fragments.insert(
7957 "index.html".to_string(),
7958 FragmentList {
7959 fragments: vec![
7960 WebUIFragment::raw("<my-component"),
7961 WebUIFragment {
7962 fragment: Some(web_ui_fragment::Fragment::Attribute(
7963 WebUIFragmentAttribute {
7964 name: "disabled".into(),
7965 attr_start: true,
7966 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
7967 ..Default::default()
7968 },
7969 )),
7970 },
7971 WebUIFragment::raw(">"),
7972 WebUIFragment::component("my-component"),
7973 WebUIFragment::raw("</my-component>"),
7974 ],
7975
7976 contains_boundary: false,
7977 },
7978 );
7979 fragments.insert(
7980 "my-component".to_string(),
7981 FragmentList {
7982 fragments: vec![
7983 WebUIFragment::if_cond(
7984 ConditionExpr::identifier("disabled"),
7985 "disabledTemplate",
7986 ),
7987 WebUIFragment::if_cond(
7988 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
7989 "enabledTemplate",
7990 ),
7991 ],
7992 contains_boundary: false,
7993 },
7994 );
7995 fragments.insert(
7996 "disabledTemplate".to_string(),
7997 FragmentList {
7998 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
7999 contains_boundary: false,
8000 },
8001 );
8002 fragments.insert(
8003 "enabledTemplate".to_string(),
8004 FragmentList {
8005 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8006 contains_boundary: false,
8007 },
8008 );
8009 let protocol = WebUIProtocol::new(fragments);
8010 let state = test_json!({"isDisabled": true});
8011 let mut writer = TestWriter::new();
8012 handle(
8013 &protocol,
8014 &state,
8015 &RenderOptions::new("index.html", "/"),
8016 &mut writer,
8017 )
8018 .unwrap();
8019 assert_eq!(
8020 writer.get_content(),
8021 "<my-component disabled><span>Disabled</span></my-component>"
8022 );
8023 }
8024
8025 #[test]
8026 fn test_bool_component_state_false() {
8027 let mut fragments = HashMap::new();
8028 fragments.insert(
8029 "index.html".to_string(),
8030 FragmentList {
8031 fragments: vec![
8032 WebUIFragment::raw("<my-component"),
8033 WebUIFragment {
8034 fragment: Some(web_ui_fragment::Fragment::Attribute(
8035 WebUIFragmentAttribute {
8036 name: "disabled".into(),
8037 attr_start: true,
8038 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8039 ..Default::default()
8040 },
8041 )),
8042 },
8043 WebUIFragment::raw(">"),
8044 WebUIFragment::component("my-component"),
8045 WebUIFragment::raw("</my-component>"),
8046 ],
8047
8048 contains_boundary: false,
8049 },
8050 );
8051 fragments.insert(
8052 "my-component".to_string(),
8053 FragmentList {
8054 fragments: vec![
8055 WebUIFragment::if_cond(
8056 ConditionExpr::identifier("disabled"),
8057 "disabledTemplate",
8058 ),
8059 WebUIFragment::if_cond(
8060 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8061 "enabledTemplate",
8062 ),
8063 ],
8064 contains_boundary: false,
8065 },
8066 );
8067 fragments.insert(
8068 "disabledTemplate".to_string(),
8069 FragmentList {
8070 fragments: vec![WebUIFragment::raw("<span>Disabled</span>")],
8071 contains_boundary: false,
8072 },
8073 );
8074 fragments.insert(
8075 "enabledTemplate".to_string(),
8076 FragmentList {
8077 fragments: vec![WebUIFragment::raw("<span>Enabled</span>")],
8078 contains_boundary: false,
8079 },
8080 );
8081 let protocol = WebUIProtocol::new(fragments);
8082 let state = test_json!({"isDisabled": false});
8083 let mut writer = TestWriter::new();
8084 handle(
8085 &protocol,
8086 &state,
8087 &RenderOptions::new("index.html", "/"),
8088 &mut writer,
8089 )
8090 .unwrap();
8091 assert_eq!(
8092 writer.get_content(),
8093 "<my-component><span>Enabled</span></my-component>"
8094 );
8095 }
8096
8097 #[test]
8098 fn test_bool_component_state_forward() {
8099 let mut fragments = HashMap::new();
8100 fragments.insert(
8101 "index.html".to_string(),
8102 FragmentList {
8103 fragments: vec![
8104 WebUIFragment::raw("<parent-component"),
8105 WebUIFragment {
8106 fragment: Some(web_ui_fragment::Fragment::Attribute(
8107 WebUIFragmentAttribute {
8108 name: "disabled".into(),
8109 attr_start: true,
8110 condition_tree: Some(ConditionExpr::identifier("isDisabled")),
8111 ..Default::default()
8112 },
8113 )),
8114 },
8115 WebUIFragment::raw(">"),
8116 WebUIFragment::component("parent-component"),
8117 WebUIFragment::raw("</parent-component>"),
8118 ],
8119
8120 contains_boundary: false,
8121 },
8122 );
8123 fragments.insert(
8124 "parent-component".to_string(),
8125 FragmentList {
8126 fragments: vec![
8127 WebUIFragment::if_cond(
8128 ConditionExpr::identifier("disabled"),
8129 "parentDisabledTemplate",
8130 ),
8131 WebUIFragment::raw("<child-component"),
8132 WebUIFragment {
8133 fragment: Some(web_ui_fragment::Fragment::Attribute(
8134 WebUIFragmentAttribute {
8135 name: "disabled".into(),
8136 attr_start: true,
8137 condition_tree: Some(ConditionExpr::identifier("disabled")),
8138 ..Default::default()
8139 },
8140 )),
8141 },
8142 WebUIFragment::raw(">"),
8143 WebUIFragment::component("child-component"),
8144 WebUIFragment::raw("</child-component>"),
8145 ],
8146
8147 contains_boundary: false,
8148 },
8149 );
8150 fragments.insert(
8151 "parentDisabledTemplate".to_string(),
8152 FragmentList {
8153 fragments: vec![WebUIFragment::raw("<div>Parent Disabled</div>")],
8154 contains_boundary: false,
8155 },
8156 );
8157 fragments.insert(
8158 "child-component".to_string(),
8159 FragmentList {
8160 fragments: vec![
8161 WebUIFragment::if_cond(
8162 ConditionExpr::identifier("disabled"),
8163 "childDisabledTemplate",
8164 ),
8165 WebUIFragment::if_cond(
8166 ConditionExpr::negated(ConditionExpr::identifier("disabled")),
8167 "childEnabledTemplate",
8168 ),
8169 ],
8170 contains_boundary: false,
8171 },
8172 );
8173 fragments.insert(
8174 "childDisabledTemplate".to_string(),
8175 FragmentList {
8176 fragments: vec![WebUIFragment::raw("<div>Child Disabled</div>")],
8177 contains_boundary: false,
8178 },
8179 );
8180 fragments.insert(
8181 "childEnabledTemplate".to_string(),
8182 FragmentList {
8183 fragments: vec![WebUIFragment::raw("<div>Child Enabled</div>")],
8184 contains_boundary: false,
8185 },
8186 );
8187
8188 {
8190 let protocol = WebUIProtocol::new(fragments.clone());
8191 let state = test_json!({"isDisabled": true});
8192 let mut writer = TestWriter::new();
8193 handle(
8194 &protocol,
8195 &state,
8196 &RenderOptions::new("index.html", "/"),
8197 &mut writer,
8198 )
8199 .unwrap();
8200 assert_eq!(
8201 writer.get_content(),
8202 "<parent-component disabled><div>Parent Disabled</div><child-component disabled><div>Child Disabled</div></child-component></parent-component>"
8203 );
8204 }
8205
8206 {
8208 let protocol = WebUIProtocol::new(fragments.clone());
8209 let state = test_json!({"isDisabled": false});
8210 let mut writer = TestWriter::new();
8211 handle(
8212 &protocol,
8213 &state,
8214 &RenderOptions::new("index.html", "/"),
8215 &mut writer,
8216 )
8217 .unwrap();
8218 assert_eq!(
8219 writer.get_content(),
8220 "<parent-component><child-component><div>Child Enabled</div></child-component></parent-component>"
8221 );
8222 }
8223 }
8224
8225 #[test]
8232 fn test_component_with_template() {
8233 let mut fragments = HashMap::new();
8234 fragments.insert(
8235 "index.html".to_string(),
8236 FragmentList {
8237 fragments: vec![
8238 WebUIFragment::raw("<custom-element>"),
8239 WebUIFragment::component("custom-element"),
8240 WebUIFragment::raw("</custom-element>"),
8241 ],
8242 contains_boundary: false,
8243 },
8244 );
8245 fragments.insert(
8246 "custom-element".to_string(),
8247 FragmentList {
8248 fragments: vec![WebUIFragment::raw("<div>Custom Element</div>")],
8249 contains_boundary: false,
8250 },
8251 );
8252 let protocol = WebUIProtocol::new(fragments);
8253 let state = test_json!({});
8254 let mut writer = TestWriter::new();
8255 handle(
8256 &protocol,
8257 &state,
8258 &RenderOptions::new("index.html", "/"),
8259 &mut writer,
8260 )
8261 .unwrap();
8262 assert_eq!(
8263 writer.get_content(),
8264 "<custom-element><div>Custom Element</div></custom-element>"
8265 );
8266 assert!(writer.is_ended());
8267 }
8268
8269 #[test]
8270 fn test_component_with_slots() {
8271 let mut fragments = HashMap::new();
8272 fragments.insert(
8273 "index.html".to_string(),
8274 FragmentList {
8275 fragments: vec![
8276 WebUIFragment::raw("<custom-element appearance=\"subtle\">"),
8277 WebUIFragment::component("custom-element"),
8278 WebUIFragment::raw("Hello World</custom-element>"),
8279 ],
8280 contains_boundary: false,
8281 },
8282 );
8283 fragments.insert(
8284 "custom-element".to_string(),
8285 FragmentList {
8286 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8287 contains_boundary: false,
8288 },
8289 );
8290 let protocol = WebUIProtocol::new(fragments);
8291 let state = test_json!({});
8292 let mut writer = TestWriter::new();
8293 handle(
8294 &protocol,
8295 &state,
8296 &RenderOptions::new("index.html", "/"),
8297 &mut writer,
8298 )
8299 .unwrap();
8300 assert_eq!(
8301 writer.get_content(),
8302 "<custom-element appearance=\"subtle\"><slot></slot>Hello World</custom-element>"
8303 );
8304 assert!(writer.is_ended());
8305 }
8306
8307 #[test]
8308 fn test_multiple_nested_components() {
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", "templateRepeat"),
8316 WebUIFragment::raw("</div>"),
8317 ],
8318 contains_boundary: false,
8319 },
8320 );
8321 fragments.insert(
8322 "custom-button".to_string(),
8323 FragmentList {
8324 fragments: vec![WebUIFragment::raw("<slot></slot>")],
8325 contains_boundary: false,
8326 },
8327 );
8328 fragments.insert(
8329 "custom-element".to_string(),
8330 FragmentList {
8331 fragments: vec![
8332 WebUIFragment::raw("<custom-child>"),
8333 WebUIFragment::component("custom-child"),
8334 WebUIFragment::raw("</custom-child><slot></slot>"),
8335 ],
8336 contains_boundary: false,
8337 },
8338 );
8339 fragments.insert(
8340 "custom-child".to_string(),
8341 FragmentList {
8342 fragments: vec![WebUIFragment::raw("<h1>Hello World!</h1>")],
8343 contains_boundary: false,
8344 },
8345 );
8346 fragments.insert(
8347 "templateRepeat".to_string(),
8348 FragmentList {
8349 fragments: vec![
8350 WebUIFragment::raw("<custom-element>"),
8351 WebUIFragment::component("custom-element"),
8352 WebUIFragment::raw("<custom-button>"),
8353 WebUIFragment::component("custom-button"),
8354 WebUIFragment::raw("Ok</custom-button></custom-element>"),
8355 ],
8356 contains_boundary: false,
8357 },
8358 );
8359 let protocol = WebUIProtocol::new(fragments);
8360 let state = test_json!({"items": [{"name": "Item1"}]});
8361 let mut writer = TestWriter::new();
8362 handle(
8363 &protocol,
8364 &state,
8365 &RenderOptions::new("index.html", "/"),
8366 &mut writer,
8367 )
8368 .unwrap();
8369 assert_eq!(
8370 writer.get_content(),
8371 "<div><custom-element><custom-child><h1>Hello World!</h1></custom-child><slot></slot><custom-button><slot></slot>Ok</custom-button></custom-element></div>"
8372 );
8373 assert!(writer.is_ended());
8374 }
8375
8376 #[test]
8379 fn test_if_with_binary_expression() {
8380 let mut fragments = HashMap::new();
8381 fragments.insert(
8382 "index.html".to_string(),
8383 FragmentList {
8384 fragments: vec![
8385 WebUIFragment::raw("<div>"),
8386 WebUIFragment::if_cond(
8387 ConditionExpr::predicate("x", ComparisonOperator::GreaterThan, "5"),
8388 "if-1",
8389 ),
8390 WebUIFragment::raw("</div>"),
8391 ],
8392 contains_boundary: false,
8393 },
8394 );
8395 fragments.insert(
8396 "if-1".to_string(),
8397 FragmentList {
8398 fragments: vec![WebUIFragment::raw("<span>If 1</span>")],
8399 contains_boundary: false,
8400 },
8401 );
8402 let protocol = WebUIProtocol::new(fragments);
8403
8404 let state_true = test_json!({"x": 10});
8406 let mut writer_true = TestWriter::new();
8407 handle(
8408 &protocol,
8409 &state_true,
8410 &RenderOptions::new("index.html", "/"),
8411 &mut writer_true,
8412 )
8413 .unwrap();
8414 assert_eq!(writer_true.get_content(), "<div><span>If 1</span></div>");
8415
8416 let state_false = test_json!({"x": 1});
8418 let mut writer_false = TestWriter::new();
8419 handle(
8420 &protocol,
8421 &state_false,
8422 &RenderOptions::new("index.html", "/"),
8423 &mut writer_false,
8424 )
8425 .unwrap();
8426 assert_eq!(writer_false.get_content(), "<div></div>");
8427 }
8428
8429 #[test]
8430 fn test_for_if_overlapping_local_state() {
8431 let mut fragments = HashMap::new();
8432 fragments.insert(
8433 "index.html".to_string(),
8434 FragmentList {
8435 fragments: vec![
8436 WebUIFragment::raw("<div>"),
8437 WebUIFragment::for_loop("item", "items", "template1"),
8438 WebUIFragment::raw("</div>"),
8439 ],
8440 contains_boundary: false,
8441 },
8442 );
8443 fragments.insert(
8444 "template1".to_string(),
8445 FragmentList {
8446 fragments: vec![
8447 WebUIFragment::raw("<div>"),
8448 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8449 WebUIFragment::raw("</div>"),
8450 ],
8451 contains_boundary: false,
8452 },
8453 );
8454 fragments.insert(
8455 "ifBlock".to_string(),
8456 FragmentList {
8457 fragments: vec![
8458 WebUIFragment::raw("<span>"),
8459 WebUIFragment::signal("item.label", false),
8460 WebUIFragment::raw("</span>"),
8461 ],
8462 contains_boundary: false,
8463 },
8464 );
8465 let protocol = WebUIProtocol::new(fragments);
8466 let state = test_json!({
8467 "flag": false,
8468 "items": [
8469 {"label": "A", "flag": true},
8470 {"label": "B", "flag": false},
8471 {"label": "C", "flag": true}
8472 ]
8473 });
8474 let mut writer = TestWriter::new();
8475 handle(
8476 &protocol,
8477 &state,
8478 &RenderOptions::new("index.html", "/"),
8479 &mut writer,
8480 )
8481 .unwrap();
8482 assert_eq!(
8483 writer.get_content(),
8484 "<div><div><span>A</span></div><div></div><div><span>C</span></div></div>"
8485 );
8486 }
8487
8488 #[test]
8489 fn test_for_if_global_flag_no_effect() {
8490 let mut fragments = HashMap::new();
8491 fragments.insert(
8492 "index.html".to_string(),
8493 FragmentList {
8494 fragments: vec![
8495 WebUIFragment::raw("<div>"),
8496 WebUIFragment::for_loop("item", "items", "template1"),
8497 WebUIFragment::raw("</div>"),
8498 ],
8499 contains_boundary: false,
8500 },
8501 );
8502 fragments.insert(
8503 "template1".to_string(),
8504 FragmentList {
8505 fragments: vec![
8506 WebUIFragment::raw("<div>"),
8507 WebUIFragment::if_cond(ConditionExpr::identifier("item.flag"), "ifBlock"),
8508 WebUIFragment::raw("</div>"),
8509 ],
8510 contains_boundary: false,
8511 },
8512 );
8513 fragments.insert(
8514 "ifBlock".to_string(),
8515 FragmentList {
8516 fragments: vec![
8517 WebUIFragment::raw("<span>"),
8518 WebUIFragment::signal("item.label", false),
8519 WebUIFragment::raw("</span>"),
8520 ],
8521 contains_boundary: false,
8522 },
8523 );
8524 let protocol = WebUIProtocol::new(fragments);
8525 let state = test_json!({
8526 "item": {"flag": true},
8527 "items": [
8528 {"label": "A", "flag": false},
8529 {"label": "B", "flag": true},
8530 {"label": "C", "flag": false}
8531 ]
8532 });
8533 let mut writer = TestWriter::new();
8534 handle(
8535 &protocol,
8536 &state,
8537 &RenderOptions::new("index.html", "/"),
8538 &mut writer,
8539 )
8540 .unwrap();
8541 assert_eq!(
8542 writer.get_content(),
8543 "<div><div></div><div><span>B</span></div><div></div></div>"
8544 );
8545 }
8546
8547 #[test]
8550 fn test_recursive_template_refs() {
8551 let mut fragments = HashMap::new();
8552 fragments.insert(
8553 "index.html".to_string(),
8554 FragmentList {
8555 fragments: vec![WebUIFragment::for_loop("item", "items", "static")],
8556 contains_boundary: false,
8557 },
8558 );
8559 fragments.insert(
8560 "static".to_string(),
8561 FragmentList {
8562 fragments: vec![
8563 WebUIFragment::raw("<div expanded=\""),
8564 WebUIFragment::signal("item.expanded", false),
8565 WebUIFragment::raw("\" class=\""),
8566 WebUIFragment::signal("testScenario", false),
8567 WebUIFragment::raw("\"><span>"),
8568 WebUIFragment::signal("item.name", false),
8569 WebUIFragment::raw("</span>"),
8570 WebUIFragment::for_loop("item", "item.children", "static"),
8571 WebUIFragment::raw("</div>"),
8572 ],
8573 contains_boundary: false,
8574 },
8575 );
8576 let protocol = WebUIProtocol::new(fragments);
8577 let state = test_json!({
8578 "testScenario": "RecursiveTemplatesWithGlobalState",
8579 "items": [
8580 {"name": "A", "expanded": "false", "children": []},
8581 {"name": "B", "expanded": "true", "children": [
8582 {"name": "C", "expanded": "false"},
8583 {"name": "D", "expanded": "false"}
8584 ]},
8585 {"name": "E", "expanded": "false"}
8586 ]
8587 });
8588 let mut writer = TestWriter::new();
8589 handle(
8590 &protocol,
8591 &state,
8592 &RenderOptions::new("index.html", "/"),
8593 &mut writer,
8594 )
8595 .unwrap();
8596 assert_eq!(
8597 writer.get_content(),
8598 "<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>"
8599 );
8600 }
8601
8602 #[test]
8605 fn test_component_in_for_no_local_access() {
8606 let mut fragments = HashMap::new();
8607 fragments.insert(
8608 "index.html".to_string(),
8609 FragmentList {
8610 fragments: vec![
8611 WebUIFragment::raw("<div>"),
8612 WebUIFragment::for_loop("item", "items", "templateComponent"),
8613 WebUIFragment::raw("</div>"),
8614 ],
8615 contains_boundary: false,
8616 },
8617 );
8618 fragments.insert(
8619 "templateComponent".to_string(),
8620 FragmentList {
8621 fragments: vec![
8622 WebUIFragment::raw("<component-tag>"),
8623 WebUIFragment::component("my-component"),
8624 WebUIFragment::raw("</component-tag>"),
8625 ],
8626 contains_boundary: false,
8627 },
8628 );
8629 fragments.insert(
8630 "my-component".to_string(),
8631 FragmentList {
8632 fragments: vec![
8633 WebUIFragment::raw("<span>"),
8634 WebUIFragment::signal("name", false),
8635 WebUIFragment::raw("</span>"),
8636 ],
8637 contains_boundary: false,
8638 },
8639 );
8640 let protocol = WebUIProtocol::new(fragments);
8641 let state = test_json!({"items": [{"name": "Item1"}, {"name": "Item2"}]});
8642 let mut writer = TestWriter::new();
8643 handle(
8644 &protocol,
8645 &state,
8646 &RenderOptions::new("index.html", "/"),
8647 &mut writer,
8648 )
8649 .unwrap();
8650 assert_eq!(
8651 writer.get_content(),
8652 "<div><component-tag><span></span></component-tag><component-tag><span></span></component-tag></div>"
8653 );
8654 }
8655
8656 #[test]
8657 fn test_nested_for_hierarchical_state() {
8658 let mut fragments = HashMap::new();
8659 fragments.insert(
8660 "index.html".to_string(),
8661 FragmentList {
8662 fragments: vec![
8663 WebUIFragment::raw("<div>"),
8664 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8665 WebUIFragment::raw("</div>"),
8666 ],
8667 contains_boundary: false,
8668 },
8669 );
8670 fragments.insert(
8671 "outerTemplate".to_string(),
8672 FragmentList {
8673 fragments: vec![
8674 WebUIFragment::raw("<section>"),
8675 WebUIFragment::signal("globalPrefix", false),
8676 WebUIFragment::signal("outerItem.outerLabel", false),
8677 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8678 WebUIFragment::raw("</section>"),
8679 ],
8680 contains_boundary: false,
8681 },
8682 );
8683 fragments.insert(
8684 "innerTemplate".to_string(),
8685 FragmentList {
8686 fragments: vec![
8687 WebUIFragment::raw("<p>"),
8688 WebUIFragment::signal("globalPrefix", false),
8689 WebUIFragment::signal("outerItem.outerLabel", false),
8690 WebUIFragment::raw(": "),
8691 WebUIFragment::signal("innerItem.innerLabel", false),
8692 WebUIFragment::raw("</p>"),
8693 ],
8694 contains_boundary: false,
8695 },
8696 );
8697 let protocol = WebUIProtocol::new(fragments);
8698 let state = test_json!({
8699 "globalPrefix": "Prefix: ",
8700 "outerItems": [
8701 {"outerLabel": "O1", "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8702 {"outerLabel": "O2", "innerItems": [{"innerLabel": "I3"}]}
8703 ]
8704 });
8705 let mut writer = TestWriter::new();
8706 handle(
8707 &protocol,
8708 &state,
8709 &RenderOptions::new("index.html", "/"),
8710 &mut writer,
8711 )
8712 .unwrap();
8713 assert_eq!(
8714 writer.get_content(),
8715 "<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>"
8716 );
8717 }
8718
8719 #[test]
8720 fn test_component_in_for_global_only() {
8721 let mut fragments = HashMap::new();
8722 fragments.insert(
8723 "index.html".to_string(),
8724 FragmentList {
8725 fragments: vec![
8726 WebUIFragment::raw("<div>"),
8727 WebUIFragment::for_loop("item", "items", "templateComponent"),
8728 WebUIFragment::raw("</div>"),
8729 ],
8730 contains_boundary: false,
8731 },
8732 );
8733 fragments.insert(
8734 "templateComponent".to_string(),
8735 FragmentList {
8736 fragments: vec![
8737 WebUIFragment::raw("<component-tag>"),
8738 WebUIFragment::component("my-component"),
8739 WebUIFragment::raw("</component-tag>"),
8740 ],
8741 contains_boundary: false,
8742 },
8743 );
8744 fragments.insert(
8745 "my-component".to_string(),
8746 FragmentList {
8747 fragments: vec![
8748 WebUIFragment::raw("<span>"),
8749 WebUIFragment::signal("name", false),
8750 WebUIFragment::raw("-"),
8751 WebUIFragment::signal("globalSuffix", false),
8752 WebUIFragment::raw("</span>"),
8753 ],
8754 contains_boundary: false,
8755 },
8756 );
8757 let protocol = WebUIProtocol::new(fragments);
8758 let state =
8759 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8760 let mut writer = TestWriter::new();
8761 handle(
8762 &protocol,
8763 &state,
8764 &RenderOptions::new("index.html", "/"),
8765 &mut writer,
8766 )
8767 .unwrap();
8768 assert_eq!(
8769 writer.get_content(),
8770 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8771 );
8772 }
8773
8774 #[test]
8775 fn test_component_no_item_moniker() {
8776 let mut fragments = HashMap::new();
8777 fragments.insert(
8778 "index.html".to_string(),
8779 FragmentList {
8780 fragments: vec![
8781 WebUIFragment::raw("<div>"),
8782 WebUIFragment::for_loop("item", "items", "templateComponent"),
8783 WebUIFragment::raw("</div>"),
8784 ],
8785 contains_boundary: false,
8786 },
8787 );
8788 fragments.insert(
8789 "templateComponent".to_string(),
8790 FragmentList {
8791 fragments: vec![
8792 WebUIFragment::raw("<component-tag>"),
8793 WebUIFragment::component("my-component"),
8794 WebUIFragment::raw("</component-tag>"),
8795 ],
8796 contains_boundary: false,
8797 },
8798 );
8799 fragments.insert(
8800 "my-component".to_string(),
8801 FragmentList {
8802 fragments: vec![
8803 WebUIFragment::raw("<span>"),
8804 WebUIFragment::signal("item.name", false),
8805 WebUIFragment::raw("-"),
8806 WebUIFragment::signal("globalSuffix", false),
8807 WebUIFragment::raw("</span>"),
8808 ],
8809 contains_boundary: false,
8810 },
8811 );
8812 let protocol = WebUIProtocol::new(fragments);
8813 let state =
8814 test_json!({"globalSuffix": "Global", "items": [{"name": "Item1"}, {"name": "Item2"}]});
8815 let mut writer = TestWriter::new();
8816 handle(
8817 &protocol,
8818 &state,
8819 &RenderOptions::new("index.html", "/"),
8820 &mut writer,
8821 )
8822 .unwrap();
8823 assert_eq!(
8824 writer.get_content(),
8825 "<div><component-tag><span>-Global</span></component-tag><component-tag><span>-Global</span></component-tag></div>"
8826 );
8827 }
8828
8829 #[test]
8830 fn test_for_nonqualified_uses_global() {
8831 let mut fragments = HashMap::new();
8832 fragments.insert(
8833 "index.html".to_string(),
8834 FragmentList {
8835 fragments: vec![
8836 WebUIFragment::raw("<div>"),
8837 WebUIFragment::for_loop("item", "items", "template1"),
8838 WebUIFragment::raw("</div>"),
8839 ],
8840 contains_boundary: false,
8841 },
8842 );
8843 fragments.insert(
8844 "template1".to_string(),
8845 FragmentList {
8846 fragments: vec![
8847 WebUIFragment::raw("<span>"),
8848 WebUIFragment::signal("name", false),
8849 WebUIFragment::raw("</span>"),
8850 ],
8851 contains_boundary: false,
8852 },
8853 );
8854 let protocol = WebUIProtocol::new(fragments);
8855 let state = test_json!({"name": "GlobalName", "items": [{"name": "LocalName1"}, {"name": "LocalName2"}]});
8856 let mut writer = TestWriter::new();
8857 handle(
8858 &protocol,
8859 &state,
8860 &RenderOptions::new("index.html", "/"),
8861 &mut writer,
8862 )
8863 .unwrap();
8864 assert_eq!(
8865 writer.get_content(),
8866 "<div><span>GlobalName</span><span>GlobalName</span></div>"
8867 );
8868 }
8869
8870 #[test]
8871 fn test_nested_for_if_interleaved() {
8872 let mut fragments = HashMap::new();
8873 fragments.insert(
8874 "index.html".to_string(),
8875 FragmentList {
8876 fragments: vec![
8877 WebUIFragment::raw("<div>"),
8878 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8879 WebUIFragment::raw("</div>"),
8880 ],
8881 contains_boundary: false,
8882 },
8883 );
8884 fragments.insert(
8885 "outerTemplate".to_string(),
8886 FragmentList {
8887 fragments: vec![
8888 WebUIFragment::raw("<section>"),
8889 WebUIFragment::signal("globalPrefix", false),
8890 WebUIFragment::signal("outerItem.outerLabel", false),
8891 WebUIFragment::if_cond(
8892 ConditionExpr::identifier("outerItem.include"),
8893 "ifTemplate",
8894 ),
8895 WebUIFragment::raw("</section>"),
8896 ],
8897 contains_boundary: false,
8898 },
8899 );
8900 fragments.insert(
8901 "ifTemplate".to_string(),
8902 FragmentList {
8903 fragments: vec![
8904 WebUIFragment::raw("<div>"),
8905 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
8906 WebUIFragment::raw("</div>"),
8907 ],
8908 contains_boundary: false,
8909 },
8910 );
8911 fragments.insert(
8912 "innerTemplate".to_string(),
8913 FragmentList {
8914 fragments: vec![
8915 WebUIFragment::raw("<p>"),
8916 WebUIFragment::signal("globalSuffix", false),
8917 WebUIFragment::raw(": "),
8918 WebUIFragment::signal("innerItem.innerLabel", false),
8919 WebUIFragment::raw("</p>"),
8920 ],
8921 contains_boundary: false,
8922 },
8923 );
8924 let protocol = WebUIProtocol::new(fragments);
8925 let state = test_json!({
8926 "globalPrefix": "Prefix: ",
8927 "globalSuffix": "Suffix",
8928 "outerItems": [
8929 {"outerLabel": "O1", "include": true, "innerItems": [{"innerLabel": "I1"}, {"innerLabel": "I2"}]},
8930 {"outerLabel": "O2", "include": false, "innerItems": [{"innerLabel": "Iignored"}]},
8931 {"outerLabel": "O3", "include": true, "innerItems": [{"innerLabel": "I3"}]}
8932 ]
8933 });
8934 let mut writer = TestWriter::new();
8935 handle(
8936 &protocol,
8937 &state,
8938 &RenderOptions::new("index.html", "/"),
8939 &mut writer,
8940 )
8941 .unwrap();
8942 assert_eq!(
8943 writer.get_content(),
8944 "<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>"
8945 );
8946 }
8947
8948 #[test]
8949 fn test_nested_for_if_outer_state() {
8950 let mut fragments = HashMap::new();
8951 fragments.insert(
8952 "index.html".to_string(),
8953 FragmentList {
8954 fragments: vec![
8955 WebUIFragment::raw("<div>"),
8956 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
8957 WebUIFragment::raw("</div>"),
8958 ],
8959 contains_boundary: false,
8960 },
8961 );
8962 fragments.insert(
8963 "outerTemplate".to_string(),
8964 FragmentList {
8965 fragments: vec![
8966 WebUIFragment::raw("<section>"),
8967 WebUIFragment::signal("globalPrefix", false),
8968 WebUIFragment::signal("outerItem.label", false),
8969 WebUIFragment::for_loop(
8970 "middleItem",
8971 "outerItem.middleItems",
8972 "middleTemplate",
8973 ),
8974 WebUIFragment::raw("</section>"),
8975 ],
8976 contains_boundary: false,
8977 },
8978 );
8979 fragments.insert(
8980 "middleTemplate".to_string(),
8981 FragmentList {
8982 fragments: vec![
8983 WebUIFragment::raw("<div>"),
8984 WebUIFragment::if_cond(
8985 ConditionExpr::identifier("outerItem.active"),
8986 "ifTemplate",
8987 ),
8988 WebUIFragment::raw("</div>"),
8989 ],
8990 contains_boundary: false,
8991 },
8992 );
8993 fragments.insert(
8994 "ifTemplate".to_string(),
8995 FragmentList {
8996 fragments: vec![
8997 WebUIFragment::raw("<p>"),
8998 WebUIFragment::signal("middleItem.value", false),
8999 WebUIFragment::raw("</p>"),
9000 ],
9001 contains_boundary: false,
9002 },
9003 );
9004 let protocol = WebUIProtocol::new(fragments);
9005 let state = test_json!({
9006 "globalPrefix": "GP-",
9007 "outerItems": [
9008 {"label": "O1", "active": true, "middleItems": [{"value": "M1"}, {"value": "M2"}]},
9009 {"label": "O2", "active": false, "middleItems": [{"value": "M3"}]},
9010 {"label": "O3", "active": true, "middleItems": [{"value": "M4"}]}
9011 ]
9012 });
9013 let mut writer = TestWriter::new();
9014 handle(
9015 &protocol,
9016 &state,
9017 &RenderOptions::new("index.html", "/"),
9018 &mut writer,
9019 )
9020 .unwrap();
9021 assert_eq!(
9022 writer.get_content(),
9023 "<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>"
9024 );
9025 }
9026
9027 #[test]
9028 fn test_nested_for_if_inner_state() {
9029 let mut fragments = HashMap::new();
9030 fragments.insert(
9031 "index.html".to_string(),
9032 FragmentList {
9033 fragments: vec![
9034 WebUIFragment::raw("<div>"),
9035 WebUIFragment::for_loop("outerItem", "outerItems", "outerTemplate"),
9036 WebUIFragment::raw("</div>"),
9037 ],
9038 contains_boundary: false,
9039 },
9040 );
9041 fragments.insert(
9042 "outerTemplate".to_string(),
9043 FragmentList {
9044 fragments: vec![
9045 WebUIFragment::raw("<section>"),
9046 WebUIFragment::signal("outerItem.label", false),
9047 WebUIFragment::for_loop("innerItem", "outerItem.innerItems", "innerTemplate"),
9048 WebUIFragment::raw("</section>"),
9049 ],
9050 contains_boundary: false,
9051 },
9052 );
9053 fragments.insert(
9054 "innerTemplate".to_string(),
9055 FragmentList {
9056 fragments: vec![
9057 WebUIFragment::raw("<article>"),
9058 WebUIFragment::if_cond(
9059 ConditionExpr::identifier("innerItem.show"),
9060 "ifTemplate",
9061 ),
9062 WebUIFragment::raw("</article>"),
9063 ],
9064 contains_boundary: false,
9065 },
9066 );
9067 fragments.insert(
9068 "ifTemplate".to_string(),
9069 FragmentList {
9070 fragments: vec![
9071 WebUIFragment::raw("<p>"),
9072 WebUIFragment::signal("innerItem.detail", false),
9073 WebUIFragment::raw("</p>"),
9074 ],
9075 contains_boundary: false,
9076 },
9077 );
9078 let protocol = WebUIProtocol::new(fragments);
9079 let state = test_json!({
9080 "outerItems": [
9081 {"label": "Outer1", "innerItems": [{"detail": "Detail1", "show": true}, {"detail": "Detail2", "show": false}]},
9082 {"label": "Outer2", "innerItems": [{"detail": "Detail3", "show": true}]}
9083 ]
9084 });
9085 let mut writer = TestWriter::new();
9086 handle(
9087 &protocol,
9088 &state,
9089 &RenderOptions::new("index.html", "/"),
9090 &mut writer,
9091 )
9092 .unwrap();
9093 assert_eq!(
9094 writer.get_content(),
9095 "<div><section>Outer1<article><p>Detail1</p></article><article></article></section><section>Outer2<article><p>Detail3</p></article></section></div>"
9096 );
9097 }
9098
9099 #[test]
9100 fn test_for_merge_local_global_monikers() {
9101 let mut fragments = HashMap::new();
9102 fragments.insert(
9103 "index.html".to_string(),
9104 FragmentList {
9105 fragments: vec![
9106 WebUIFragment::raw("<div>"),
9107 WebUIFragment::for_loop("item", "items", "template1"),
9108 WebUIFragment::raw("</div>"),
9109 ],
9110 contains_boundary: false,
9111 },
9112 );
9113 fragments.insert(
9114 "template1".to_string(),
9115 FragmentList {
9116 fragments: vec![
9117 WebUIFragment::raw("<span>"),
9118 WebUIFragment::signal("item.name", false),
9119 WebUIFragment::raw("-"),
9120 WebUIFragment::signal("item.globalValue", false),
9121 WebUIFragment::raw("-"),
9122 WebUIFragment::signal("item.localOnly", false),
9123 WebUIFragment::raw("-"),
9124 WebUIFragment::signal("item.otherVal", false),
9125 WebUIFragment::raw("</span>"),
9126 ],
9127 contains_boundary: false,
9128 },
9129 );
9130 let protocol = WebUIProtocol::new(fragments);
9131 let state = test_json!({
9132 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9133 "items": [
9134 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9135 {"name": "Local2", "localOnly": "Only2"}
9136 ]
9137 });
9138 let mut writer = TestWriter::new();
9139 handle(
9140 &protocol,
9141 &state,
9142 &RenderOptions::new("index.html", "/"),
9143 &mut writer,
9144 )
9145 .unwrap();
9146 assert_eq!(
9147 writer.get_content(),
9148 "<div><span>Local1-LOCAL-Only1-other</span><span>Local2-GLOBAL-Only2-other</span></div>"
9149 );
9150 }
9151
9152 #[test]
9153 fn test_component_in_for_global_moniker_shadow() {
9154 let mut fragments = HashMap::new();
9155 fragments.insert(
9156 "index.html".to_string(),
9157 FragmentList {
9158 fragments: vec![
9159 WebUIFragment::raw("<div>"),
9160 WebUIFragment::for_loop("item", "items", "templateComponent"),
9161 WebUIFragment::raw("</div>"),
9162 ],
9163 contains_boundary: false,
9164 },
9165 );
9166 fragments.insert(
9167 "templateComponent".to_string(),
9168 FragmentList {
9169 fragments: vec![
9170 WebUIFragment::raw("<component-tag>"),
9171 WebUIFragment::component("my-component"),
9172 WebUIFragment::raw("</component-tag>"),
9173 ],
9174 contains_boundary: false,
9175 },
9176 );
9177 fragments.insert(
9178 "my-component".to_string(),
9179 FragmentList {
9180 fragments: vec![
9181 WebUIFragment::raw("<span>"),
9182 WebUIFragment::signal("name", false),
9183 WebUIFragment::raw("-"),
9184 WebUIFragment::signal("item.globalValue", false),
9185 WebUIFragment::raw("-"),
9186 WebUIFragment::signal("localOnly", false),
9187 WebUIFragment::raw("-"),
9188 WebUIFragment::signal("item.otherVal", false),
9189 WebUIFragment::raw("</span>"),
9190 ],
9191 contains_boundary: false,
9192 },
9193 );
9194 let protocol = WebUIProtocol::new(fragments);
9195 let state = test_json!({
9196 "item": {"globalValue": "GLOBAL", "otherVal": "other"},
9197 "items": [
9198 {"name": "Local1", "globalValue": "LOCAL", "localOnly": "Only1"},
9199 {"name": "Local2", "localOnly": "Only2"}
9200 ]
9201 });
9202 let mut writer = TestWriter::new();
9203 handle(
9204 &protocol,
9205 &state,
9206 &RenderOptions::new("index.html", "/"),
9207 &mut writer,
9208 )
9209 .unwrap();
9210 assert_eq!(
9211 writer.get_content(),
9212 "<div><component-tag><span>-GLOBAL--other</span></component-tag><component-tag><span>-GLOBAL--other</span></component-tag></div>"
9213 );
9214 }
9215
9216 #[test]
9217 fn test_if_in_nested_for_local_flag() {
9218 let mut fragments = HashMap::new();
9219 fragments.insert(
9220 "index.html".to_string(),
9221 FragmentList {
9222 fragments: vec![
9223 WebUIFragment::raw("<div>"),
9224 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9225 WebUIFragment::raw("</div>"),
9226 ],
9227 contains_boundary: false,
9228 },
9229 );
9230 fragments.insert(
9231 "outerTemplate".to_string(),
9232 FragmentList {
9233 fragments: vec![
9234 WebUIFragment::raw("<section>"),
9235 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9236 WebUIFragment::raw("</section>"),
9237 ],
9238 contains_boundary: false,
9239 },
9240 );
9241 fragments.insert(
9242 "innerTemplate".to_string(),
9243 FragmentList {
9244 fragments: vec![WebUIFragment::if_cond(
9245 ConditionExpr::identifier("inner_item.flag"),
9246 "ifInner",
9247 )],
9248 contains_boundary: false,
9249 },
9250 );
9251 fragments.insert(
9252 "ifInner".to_string(),
9253 FragmentList {
9254 fragments: vec![
9255 WebUIFragment::raw("<p>"),
9256 WebUIFragment::signal("inner_item.value", false),
9257 WebUIFragment::raw("</p>"),
9258 ],
9259 contains_boundary: false,
9260 },
9261 );
9262 let protocol = WebUIProtocol::new(fragments);
9263 let state = test_json!({
9264 "list": {"outer_items": [{"inner_items": [{"flag": true, "value": "X"}, {"flag": false, "value": "Y"}]}]},
9265 "inner_item": {"flag": false}
9266 });
9267 let mut writer = TestWriter::new();
9268 handle(
9269 &protocol,
9270 &state,
9271 &RenderOptions::new("index.html", "/"),
9272 &mut writer,
9273 )
9274 .unwrap();
9275 assert_eq!(
9276 writer.get_content(),
9277 "<div><section><p>X</p></section></div>"
9278 );
9279 }
9280
9281 #[test]
9282 fn test_if_in_nested_for_global_fallback() {
9283 let mut fragments = HashMap::new();
9284 fragments.insert(
9285 "index.html".to_string(),
9286 FragmentList {
9287 fragments: vec![
9288 WebUIFragment::raw("<div>"),
9289 WebUIFragment::for_loop("outer", "list.outer_items", "outerTemplate"),
9290 WebUIFragment::raw("</div>"),
9291 ],
9292 contains_boundary: false,
9293 },
9294 );
9295 fragments.insert(
9296 "outerTemplate".to_string(),
9297 FragmentList {
9298 fragments: vec![
9299 WebUIFragment::raw("<section>"),
9300 WebUIFragment::for_loop("inner_item", "outer.inner_items", "innerTemplate"),
9301 WebUIFragment::raw("</section>"),
9302 ],
9303 contains_boundary: false,
9304 },
9305 );
9306 fragments.insert(
9307 "innerTemplate".to_string(),
9308 FragmentList {
9309 fragments: vec![WebUIFragment::if_cond(
9310 ConditionExpr::identifier("inner_item.flag"),
9311 "ifInner",
9312 )],
9313 contains_boundary: false,
9314 },
9315 );
9316 fragments.insert(
9317 "ifInner".to_string(),
9318 FragmentList {
9319 fragments: vec![
9320 WebUIFragment::raw("<p>"),
9321 WebUIFragment::signal("inner_item.value", false),
9322 WebUIFragment::raw("</p>"),
9323 ],
9324 contains_boundary: false,
9325 },
9326 );
9327 let protocol = WebUIProtocol::new(fragments);
9328 let state = test_json!({
9329 "list": {"outer_items": [{"inner_items": [{"value": "X"}, {"value": "Y"}]}]},
9330 "inner_item": {"flag": true}
9331 });
9332 let mut writer = TestWriter::new();
9333 handle(
9334 &protocol,
9335 &state,
9336 &RenderOptions::new("index.html", "/"),
9337 &mut writer,
9338 )
9339 .unwrap();
9340 assert_eq!(
9341 writer.get_content(),
9342 "<div><section><p>X</p><p>Y</p></section></div>"
9343 );
9344 }
9345
9346 #[test]
9347 fn test_if_mixed_for_monikers() {
9348 let mut fragments = HashMap::new();
9349 fragments.insert(
9350 "index.html".to_string(),
9351 FragmentList {
9352 fragments: vec![
9353 WebUIFragment::raw("<div>"),
9354 WebUIFragment::for_loop("outer", "list.outerItems", "outerTemplate"),
9355 WebUIFragment::raw("</div>"),
9356 ],
9357 contains_boundary: false,
9358 },
9359 );
9360 fragments.insert(
9361 "outerTemplate".to_string(),
9362 FragmentList {
9363 fragments: vec![
9364 WebUIFragment::raw("<section>"),
9365 WebUIFragment::signal("outer.outerLabel", false),
9366 WebUIFragment::for_loop("inner", "outer.innerItems", "innerTemplate"),
9367 WebUIFragment::raw("</section>"),
9368 ],
9369 contains_boundary: false,
9370 },
9371 );
9372 fragments.insert(
9373 "innerTemplate".to_string(),
9374 FragmentList {
9375 fragments: vec![WebUIFragment::if_cond(
9376 ConditionExpr::compound(
9377 ConditionExpr::identifier("outer.active"),
9378 LogicalOperator::And,
9379 ConditionExpr::predicate(
9380 "inner.value",
9381 ComparisonOperator::GreaterThan,
9382 "globalLimit",
9383 ),
9384 ),
9385 "ifInner",
9386 )],
9387 contains_boundary: false,
9388 },
9389 );
9390 fragments.insert(
9391 "ifInner".to_string(),
9392 FragmentList {
9393 fragments: vec![
9394 WebUIFragment::raw("<p>"),
9395 WebUIFragment::signal("inner.value", false),
9396 WebUIFragment::raw("</p>"),
9397 ],
9398 contains_boundary: false,
9399 },
9400 );
9401 let protocol = WebUIProtocol::new(fragments);
9402 let state = test_json!({
9403 "globalLimit": 10,
9404 "list": {"outerItems": [
9405 {"outerLabel": "O1", "active": true, "innerItems": [{"value": 15}, {"value": 8}]},
9406 {"outerLabel": "O2", "active": false, "innerItems": [{"value": 20}]},
9407 {"outerLabel": "O3", "active": true, "innerItems": [{"value": 5}]}
9408 ]}
9409 });
9410 let mut writer = TestWriter::new();
9411 handle(
9412 &protocol,
9413 &state,
9414 &RenderOptions::new("index.html", "/"),
9415 &mut writer,
9416 )
9417 .unwrap();
9418 assert_eq!(
9419 writer.get_content(),
9420 "<div><section>O1<p>15</p></section><section>O2</section><section>O3</section></div>"
9421 );
9422 }
9423
9424 fn make_route_protocol() -> WebUIProtocol {
9427 use webui_protocol::WebUiFragmentRoute;
9428
9429 let mut fragments = HashMap::new();
9430
9431 fragments.insert(
9433 "index.html".to_string(),
9434 FragmentList {
9435 fragments: vec![
9436 WebUIFragment::raw("<h1>Shell</h1>"),
9437 WebUIFragment::route_from(WebUiFragmentRoute {
9438 path: "/".into(),
9439 fragment_id: "dash-page".into(),
9440 exact: true,
9441 keep_alive: false,
9442 ..Default::default()
9443 }),
9444 WebUIFragment::route_from(WebUiFragmentRoute {
9445 path: "/contacts/:id".into(),
9446 fragment_id: "detail-page".into(),
9447 exact: true,
9448 keep_alive: true,
9449 ..Default::default()
9450 }),
9451 ],
9452
9453 contains_boundary: false,
9454 },
9455 );
9456
9457 fragments.insert(
9459 "dash-page".to_string(),
9460 FragmentList {
9461 fragments: vec![WebUIFragment::raw("<p>Dashboard</p>")],
9462 contains_boundary: false,
9463 },
9464 );
9465
9466 fragments.insert(
9468 "detail-page".to_string(),
9469 FragmentList {
9470 fragments: vec![WebUIFragment::raw("<p>Detail</p>")],
9471 contains_boundary: false,
9472 },
9473 );
9474
9475 WebUIProtocol::new(fragments)
9476 }
9477
9478 fn make_nested_route_protocol() -> WebUIProtocol {
9479 use webui_protocol::WebUiFragmentRoute;
9480
9481 let mut fragments = HashMap::new();
9482
9483 fragments.insert(
9484 "index.html".to_string(),
9485 FragmentList {
9486 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
9487 path: "/".into(),
9488 fragment_id: "app-shell".into(),
9489 exact: false,
9490 children: vec![WebUiFragmentRoute {
9491 path: "sections/:id".into(),
9492 fragment_id: "section-comp".into(),
9493 exact: false,
9494 children: vec![WebUiFragmentRoute {
9495 path: "topics/:topicId".into(),
9496 fragment_id: "topic-comp".into(),
9497 exact: true,
9498 children: vec![],
9499 keep_alive: false,
9500 ..Default::default()
9501 }],
9502 keep_alive: false,
9503 ..Default::default()
9504 }],
9505 keep_alive: false,
9506 ..Default::default()
9507 })],
9508
9509 contains_boundary: false,
9510 },
9511 );
9512
9513 fragments.insert(
9514 "app-shell".to_string(),
9515 FragmentList {
9516 fragments: vec![
9517 WebUIFragment::raw("<h1>Shell</h1>"),
9518 WebUIFragment::outlet(),
9519 ],
9520 contains_boundary: false,
9521 },
9522 );
9523
9524 fragments.insert(
9525 "section-comp".to_string(),
9526 FragmentList {
9527 fragments: vec![
9528 WebUIFragment::raw("<h2>Section</h2>"),
9529 WebUIFragment::outlet(),
9530 ],
9531 contains_boundary: false,
9532 },
9533 );
9534
9535 fragments.insert(
9536 "topic-comp".to_string(),
9537 FragmentList {
9538 fragments: vec![WebUIFragment::raw("<p>Topic content</p>")],
9539 contains_boundary: false,
9540 },
9541 );
9542
9543 WebUIProtocol::new(fragments)
9544 }
9545
9546 #[test]
9547 fn test_route_renders_shell_always() {
9548 let protocol = make_route_protocol();
9549 let state = test_json!({});
9550 let mut writer = TestWriter::new();
9551 handle(
9552 &protocol,
9553 &state,
9554 &RenderOptions::new("index.html", "/"),
9555 &mut writer,
9556 )
9557 .unwrap();
9558 let html = writer.get_content();
9559
9560 assert!(html.contains("<h1>Shell</h1>"), "shell should render");
9562 assert!(html.contains(" active>"), "matched route should be active");
9564 assert!(
9566 html.contains("style=\"display:none\""),
9567 "non-matched routes should be hidden"
9568 );
9569 }
9570
9571 #[test]
9572 fn test_route_matched_renders_visible() {
9573 let protocol = make_route_protocol();
9574 let state = test_json!({});
9575 let mut writer = TestWriter::new();
9576 handle(
9577 &protocol,
9578 &state,
9579 &RenderOptions::new("index.html", "/"),
9580 &mut writer,
9581 )
9582 .unwrap();
9583 let html = writer.get_content();
9584
9585 assert!(
9587 html.contains("<webui-route path=\"/\""),
9588 "dashboard route should exist"
9589 );
9590 assert!(
9591 html.contains("active>") && html.contains("<dash-page"),
9592 "matched route should be active with component tag: {html}"
9593 );
9594 assert!(
9595 html.contains("<p>Dashboard</p>"),
9596 "matched route should have content"
9597 );
9598 }
9599
9600 #[test]
9601 fn test_route_non_matched_renders_hidden_empty() {
9602 let protocol = make_route_protocol();
9603 let state = test_json!({});
9604 let mut writer = TestWriter::new();
9605 handle(
9606 &protocol,
9607 &state,
9608 &RenderOptions::new("index.html", "/"),
9609 &mut writer,
9610 )
9611 .unwrap();
9612 let html = writer.get_content();
9613
9614 assert!(
9616 html.contains("<webui-route path=\"/contacts/:id\""),
9617 "detail route element should exist"
9618 );
9619 let detail_start = html.find("path=\"/contacts/:id\"").expect("detail route");
9621 let after_detail = &html[detail_start..];
9622 assert!(
9623 after_detail.contains("style=\"display:none\">")
9624 && !after_detail.starts_with(&format!("path=\"/contacts/:id\"{}detail-page>", "")),
9625 "non-matched route should be hidden: {after_detail}"
9626 );
9627 let detail_end = after_detail.find("</webui-route>").expect("closing tag");
9629 let detail_body = &after_detail[..detail_end];
9630 assert!(
9631 !detail_body.contains("<detail-page"),
9632 "non-matched route should not render component content: {detail_body}"
9633 );
9634 }
9635
9636 #[test]
9637 fn test_route_parameterized_match() {
9638 let protocol = make_route_protocol();
9639 let state = test_json!({});
9640 let mut writer = TestWriter::new();
9641 handle(
9642 &protocol,
9643 &state,
9644 &RenderOptions::new("index.html", "/contacts/42"),
9645 &mut writer,
9646 )
9647 .unwrap();
9648 let html = writer.get_content();
9649
9650 assert!(
9652 html.contains("active>") && html.contains("<detail-page"),
9653 "detail route should be active: {html}"
9654 );
9655 assert!(html.contains("<p>Detail</p>"), "detail should have content");
9656 let dash_start = html
9658 .find("component=\"dash-page\"")
9659 .expect("dashboard route");
9660 let after_dash = &html[dash_start..];
9661 assert!(
9662 after_dash.contains("style=\"display:none\">"),
9663 "dashboard should be hidden when detail matches: {after_dash}"
9664 );
9665 let dash_end = after_dash.find("</webui-route>").expect("closing tag");
9666 let dash_body = &after_dash[..dash_end];
9667 assert!(
9668 !dash_body.contains("<dash-page"),
9669 "dashboard should not render component content: {dash_body}"
9670 );
9671 }
9672
9673 #[test]
9674 fn test_route_no_match_all_hidden_empty() {
9675 let protocol = make_route_protocol();
9676 let state = test_json!({});
9677 let mut writer = TestWriter::new();
9678 handle(
9679 &protocol,
9680 &state,
9681 &RenderOptions::new("index.html", "/nonexistent"),
9682 &mut writer,
9683 )
9684 .unwrap();
9685 let html = writer.get_content();
9686
9687 assert!(html.contains("<h1>Shell</h1>"));
9689 assert!(
9691 !html.contains("<p>Dashboard</p>"),
9692 "no route content when nothing matches"
9693 );
9694 assert!(
9695 !html.contains("<p>Detail</p>"),
9696 "no route content when nothing matches"
9697 );
9698 }
9699
9700 #[test]
9701 fn test_route_component_attr_emitted() {
9702 let protocol = make_route_protocol();
9703 let state = test_json!({});
9704 let mut writer = TestWriter::new();
9705 handle(
9706 &protocol,
9707 &state,
9708 &RenderOptions::new("index.html", "/"),
9709 &mut writer,
9710 )
9711 .unwrap();
9712 let html = writer.get_content();
9713 assert!(
9715 html.contains("component=\"dash-page\""),
9716 "component attr should be on webui-route: {html}"
9717 );
9718 assert!(
9719 html.contains("component=\"detail-page\""),
9720 "component attr should be on webui-route: {html}"
9721 );
9722 assert!(
9723 html.contains(r#"component="detail-page" exact keep-alive style="display:none">"#),
9724 "keep-alive should be emitted on an unmatched destination placeholder: {html}"
9725 );
9726 }
9727
9728 #[test]
9729 fn test_no_plugin_no_state_attributes() {
9730 let protocol = make_route_protocol();
9731 let state = test_json!({
9732 "title": "Fish & Chips",
9733 "cartOpen": true,
9734 "items": [{"name": "A&B"}]
9735 });
9736 let mut writer = TestWriter::new();
9737
9738 handle(
9739 &protocol,
9740 &state,
9741 &RenderOptions::new("index.html", "/"),
9742 &mut writer,
9743 )
9744 .unwrap();
9745
9746 let html = writer.get_content();
9747 assert!(
9749 !html.contains("data-state"),
9750 "no data-state without plugin: {html}"
9751 );
9752 assert!(
9753 !html.contains(r#"title="Fish"#),
9754 "no scalar attrs without plugin: {html}"
9755 );
9756 }
9757
9758 #[test]
9759 fn test_nested_routes_render_webui_route_as_light_dom() {
9760 let protocol = make_nested_route_protocol();
9761 let state = test_json!({"title": "Test"});
9762 let handler = WebUIHandler::new();
9763 let mut writer = TestWriter::new();
9764
9765 handler
9766 .handle(
9767 &protocol,
9768 &state,
9769 &RenderOptions::new("index.html", "/sections/frontend"),
9770 &mut writer,
9771 )
9772 .expect("render failed");
9773
9774 let html = writer.get_content();
9775
9776 assert!(
9777 html.contains("component=\"app-shell\"") && html.contains("active>"),
9778 "root route should be active: {html}"
9779 );
9780 assert!(
9782 !html.contains("<template shadowrootmode"),
9783 "webui-route should be light DOM (no shadow template): {html}"
9784 );
9785 }
9786
9787 #[test]
9788 fn test_nested_routes_render_outlet_as_light_dom() {
9789 let protocol = make_nested_route_protocol();
9790 let state = test_json!({"title": "Test"});
9791 let handler = WebUIHandler::new();
9792 let mut writer = TestWriter::new();
9793
9794 handler
9795 .handle(
9796 &protocol,
9797 &state,
9798 &RenderOptions::new("index.html", "/sections/frontend"),
9799 &mut writer,
9800 )
9801 .expect("render failed");
9802
9803 let html = writer.get_content();
9804
9805 assert!(
9807 !html.contains("<webui-outlet>"),
9808 "should not contain webui-outlet wrapper: {html}"
9809 );
9810 assert!(
9812 html.contains("<webui-route"),
9813 "should contain webui-route elements: {html}"
9814 );
9815 }
9816
9817 #[test]
9818 fn test_nested_routes_match_child_at_outlet() {
9819 let protocol = make_nested_route_protocol();
9820 let state = test_json!({"title": "Test"});
9821 let handler = WebUIHandler::new();
9822 let mut writer = TestWriter::new();
9823
9824 handler
9825 .handle(
9826 &protocol,
9827 &state,
9828 &RenderOptions::new("index.html", "/sections/frontend"),
9829 &mut writer,
9830 )
9831 .expect("render failed");
9832
9833 let html = writer.get_content();
9834
9835 assert!(
9836 html.contains("component=\"section-comp\"") && html.contains("active>"),
9837 "section route should be active: {html}"
9838 );
9839 assert!(
9840 html.contains("<h2>Section</h2>"),
9841 "section content should be present: {html}"
9842 );
9843 }
9844
9845 #[test]
9846 fn test_nested_routes_three_levels_deep() {
9847 let protocol = make_nested_route_protocol();
9848 let state = test_json!({"title": "Test"});
9849 let handler = WebUIHandler::new();
9850 let mut writer = TestWriter::new();
9851
9852 handler
9853 .handle(
9854 &protocol,
9855 &state,
9856 &RenderOptions::new("index.html", "/sections/frontend/topics/react"),
9857 &mut writer,
9858 )
9859 .expect("render failed");
9860
9861 let html = writer.get_content();
9862
9863 assert!(
9864 html.contains("component=\"app-shell\"") && html.contains("active>"),
9865 "root active: {html}"
9866 );
9867 assert!(
9868 html.contains("component=\"section-comp\"") && html.contains("active>"),
9869 "section active: {html}"
9870 );
9871 assert!(
9872 html.contains("component=\"topic-comp\"")
9873 && html.contains("exact")
9874 && html.contains("active>"),
9875 "topic active: {html}"
9876 );
9877 assert!(
9878 html.contains("<p>Topic content</p>"),
9879 "leaf content present: {html}"
9880 );
9881 }
9882
9883 #[test]
9884 fn test_nested_routes_nonmatched_siblings_hidden() {
9885 let protocol = make_nested_route_protocol();
9886 let state = test_json!({"title": "Test"});
9887 let handler = WebUIHandler::new();
9888 let mut writer = TestWriter::new();
9889
9890 handler
9891 .handle(
9892 &protocol,
9893 &state,
9894 &RenderOptions::new("index.html", "/sections/frontend"),
9895 &mut writer,
9896 )
9897 .expect("render failed");
9898
9899 let html = writer.get_content();
9900
9901 assert!(
9902 html.contains(r#"component="topic-comp" exact style="display:none">"#),
9903 "topic should be hidden: {html}"
9904 );
9905 }
9906
9907 #[test]
9908 fn test_nested_routes_preserve_sibling_declaration_order() {
9909 let mut protocol = make_nested_route_protocol();
9910 let Some(Fragment::Route(root)) = protocol
9911 .fragments
9912 .get_mut("index.html")
9913 .and_then(|entry| entry.fragments.first_mut())
9914 .and_then(|fragment| fragment.fragment.as_mut())
9915 else {
9916 panic!("nested route fixture must have a root route");
9917 };
9918 root.children.insert(
9919 0,
9920 webui_protocol::WebUiFragmentRoute {
9921 path: String::new(),
9922 fragment_id: "topic-comp".into(),
9923 exact: true,
9924 ..Default::default()
9925 },
9926 );
9927 root.children.push(webui_protocol::WebUiFragmentRoute {
9928 path: "*rest".into(),
9929 fragment_id: "topic-comp".into(),
9930 exact: true,
9931 ..Default::default()
9932 });
9933
9934 for path in ["/", "/sections/frontend", "/missing"] {
9935 let mut writer = TestWriter::new();
9936 WebUIHandler::new()
9937 .handle(
9938 &protocol,
9939 &test_json!({}),
9940 &RenderOptions::new("index.html", path),
9941 &mut writer,
9942 )
9943 .expect("render failed");
9944 let html = writer.get_content();
9945 let first = html.find(r#"<webui-route path="""#).expect("empty route");
9946 let middle = html
9947 .find(r#"<webui-route path="sections/:id""#)
9948 .expect("section route");
9949 let last = html
9950 .find(r#"<webui-route path="*rest""#)
9951 .expect("catch-all route");
9952 assert!(first < middle && middle < last, "{path}: {html}");
9953 }
9954 }
9955
9956 #[test]
9957 fn test_nested_routes_root_only() {
9958 let protocol = make_nested_route_protocol();
9959 let state = test_json!({"title": "Test"});
9960 let handler = WebUIHandler::new();
9961 let mut writer = TestWriter::new();
9962
9963 handler
9964 .handle(
9965 &protocol,
9966 &state,
9967 &RenderOptions::new("index.html", "/"),
9968 &mut writer,
9969 )
9970 .expect("render failed");
9971
9972 let html = writer.get_content();
9973
9974 assert!(
9975 html.contains("component=\"app-shell\"") && html.contains("active>"),
9976 "root active at /: {html}"
9977 );
9978 assert!(
9979 html.contains("<h1>Shell</h1>"),
9980 "shell renders at /: {html}"
9981 );
9982 assert!(
9983 html.contains(r#"component="section-comp" style="display:none">"#),
9984 "section hidden at /: {html}"
9985 );
9986 }
9987
9988 #[test]
9991 fn test_css_module_emitted_once_inline_in_component() {
9992 let template = r#"<p><slot></slot></p>"#;
9995
9996 let mut fragments = HashMap::new();
9997 fragments.insert(
9998 "index.html".to_string(),
9999 FragmentList {
10000 fragments: vec![
10001 WebUIFragment::raw("<html><head>".to_string()),
10002 structural_fragment("head_end"),
10003 WebUIFragment::raw("</head><body><div>".to_string()),
10004 WebUIFragment::component("my-card"),
10005 WebUIFragment::raw("A".to_string()),
10006 WebUIFragment::component("my-card"),
10007 WebUIFragment::raw("B</div>".to_string()),
10008 structural_fragment("body_end"),
10009 WebUIFragment::raw("</body></html>".to_string()),
10010 ],
10011 contains_boundary: false,
10012 },
10013 );
10014 fragments.insert(
10015 "my-card".to_string(),
10016 FragmentList {
10017 fragments: vec![WebUIFragment::raw(template.to_string())],
10018 contains_boundary: false,
10019 },
10020 );
10021
10022 let mut protocol = WebUIProtocol::new(fragments);
10023 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
10024 protocol
10025 .components
10026 .entry("my-card".to_string())
10027 .or_default()
10028 .css = "p{color:red}".to_string();
10029 protocol.populate_style_closures(&["index.html"]);
10030 let state = test_json!({});
10031 let mut writer = TestWriter::new();
10032
10033 handle(
10034 &protocol,
10035 &state,
10036 &RenderOptions::new("index.html", "/"),
10037 &mut writer,
10038 )
10039 .unwrap();
10040
10041 let html = writer.get_content();
10042
10043 let count = html.matches(r#"<script type="importmap""#).count();
10045 assert_eq!(
10046 count, 1,
10047 "CSS module importmap should be emitted once, got {count} in: {html}"
10048 );
10049 assert!(
10050 html.contains(r#""my-card":"data:text/css,"#),
10051 "Importmap must register my-card under a data: URI: {html}"
10052 );
10053
10054 let tmpl_count = html.matches(r#"<p><slot></slot></p>"#).count();
10056 assert_eq!(
10057 tmpl_count, 2,
10058 "Template should render twice, got {tmpl_count} in: {html}"
10059 );
10060
10061 let css_pos = html
10063 .find(r#"<script type="importmap""#)
10064 .expect("CSS module importmap missing");
10065 let body_pos = html.find("<body>").expect("<body> missing");
10066 assert!(
10067 css_pos > body_pos,
10068 "CSS module should be inline in component, not in <head>: {html}"
10069 );
10070 }
10071
10072 #[test]
10073 fn test_component_without_css_renders_normally() {
10074 let mut fragments = HashMap::new();
10076 fragments.insert(
10077 "index.html".to_string(),
10078 FragmentList {
10079 fragments: vec![WebUIFragment::component("my-card")],
10080 contains_boundary: false,
10081 },
10082 );
10083 fragments.insert(
10084 "my-card".to_string(),
10085 FragmentList {
10086 fragments: vec![WebUIFragment::raw(r#"<p>hello</p>"#.to_string())],
10087 contains_boundary: false,
10088 },
10089 );
10090
10091 let protocol = WebUIProtocol::new(fragments);
10092 let state = test_json!({});
10093 let mut writer = TestWriter::new();
10094
10095 handle(
10096 &protocol,
10097 &state,
10098 &RenderOptions::new("index.html", "/"),
10099 &mut writer,
10100 )
10101 .unwrap();
10102
10103 let html = writer.get_content();
10104 assert!(
10105 html.contains("<p>hello</p>"),
10106 "Non-module component should render normally: {html}"
10107 );
10108 }
10109
10110 #[test]
10111 fn test_non_module_strategy_no_css_in_head() {
10112 let template = r#"<p>hello</p>"#;
10115
10116 let mut fragments = HashMap::new();
10117 fragments.insert(
10118 "index.html".to_string(),
10119 FragmentList {
10120 fragments: vec![
10121 WebUIFragment::raw("<html><head>".to_string()),
10122 structural_fragment("head_end"),
10123 WebUIFragment::raw("</head><body>".to_string()),
10124 WebUIFragment::component("my-card"),
10125 WebUIFragment::raw("</body></html>".to_string()),
10126 ],
10127 contains_boundary: false,
10128 },
10129 );
10130 fragments.insert(
10131 "my-card".to_string(),
10132 FragmentList {
10133 fragments: vec![WebUIFragment::raw(template.to_string())],
10134 contains_boundary: false,
10135 },
10136 );
10137
10138 let protocol = WebUIProtocol::new(fragments);
10140 let state = test_json!({});
10141 let mut writer = TestWriter::new();
10142
10143 handle(
10144 &protocol,
10145 &state,
10146 &RenderOptions::new("index.html", "/"),
10147 &mut writer,
10148 )
10149 .unwrap();
10150
10151 let html = writer.get_content();
10152
10153 assert!(
10154 !html.contains(r#"<style type="module""#),
10155 "Non-module strategy should not emit CSS module tags in <head>: {html}"
10156 );
10157 assert!(
10158 !html.contains(r#"<script type="importmap""#),
10159 "Non-module strategy should not emit CSS module importmaps in <head>: {html}"
10160 );
10161 assert!(
10162 html.contains("<p>hello</p>"),
10163 "Component should still render: {html}"
10164 );
10165 }
10166
10167 #[test]
10168 fn test_style_strategy_embeds_inline_style_in_shadow_template() {
10169 let mut fragments = HashMap::new();
10170 fragments.insert(
10171 "index.html".to_string(),
10172 FragmentList {
10173 fragments: vec![
10174 WebUIFragment::raw("<html><head>".to_string()),
10175 structural_fragment("head_end"),
10176 WebUIFragment::raw("</head><body><my-card>".to_string()),
10177 WebUIFragment::component("my-card"),
10178 WebUIFragment::raw("</my-card></body></html>".to_string()),
10179 ],
10180 contains_boundary: false,
10181 },
10182 );
10183 fragments.insert(
10184 "my-card".to_string(),
10185 FragmentList {
10186 fragments: vec![WebUIFragment::raw(
10187 "<template shadowrootmode=\"open\"><style>.card{color:red}</style><div>card</div></template>"
10188 .to_string(),
10189 )],
10190 contains_boundary: false,
10191},
10192 );
10193
10194 let protocol = WebUIProtocol::new(fragments);
10195 let state = test_json!({});
10196 let mut writer = TestWriter::new();
10197
10198 handle(
10199 &protocol,
10200 &state,
10201 &RenderOptions::new("index.html", "/"),
10202 &mut writer,
10203 )
10204 .unwrap();
10205
10206 let html = writer.get_content();
10207
10208 assert!(
10209 html.contains("<style>.card{color:red}</style>"),
10210 "Style strategy should embed inline CSS in shadow template: {html}"
10211 );
10212 assert!(
10213 !html.contains(r#"<style type="module""#),
10214 "Style strategy should not emit legacy module CSS in <head>: {html}"
10215 );
10216 assert!(
10217 !html.contains(r#"<script type="importmap""#),
10218 "Style strategy should not emit CSS module importmaps in <head>: {html}"
10219 );
10220 }
10221
10222 #[test]
10223 fn test_link_strategy_light_dom_emits_stylesheet_in_head() {
10224 let mut fragments = HashMap::new();
10227 fragments.insert(
10228 "index.html".to_string(),
10229 FragmentList {
10230 fragments: vec![
10231 WebUIFragment::raw("<html><head>".to_string()),
10232 structural_fragment("head_end"),
10233 WebUIFragment::raw("</head><body><my-card>".to_string()),
10234 WebUIFragment::component("my-card"),
10235 WebUIFragment::raw("</my-card>".to_string()),
10236 structural_fragment("body_end"),
10237 WebUIFragment::raw("</body></html>".to_string()),
10238 ],
10239 contains_boundary: false,
10240 },
10241 );
10242 fragments.insert(
10243 "my-card".to_string(),
10244 FragmentList {
10245 fragments: vec![WebUIFragment::raw("<div>card</div>".to_string())],
10246 contains_boundary: false,
10247 },
10248 );
10249
10250 let mut protocol = WebUIProtocol::new(fragments);
10251 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10252
10253 let comp = protocol
10254 .components
10255 .entry("my-card".to_string())
10256 .or_default();
10257 comp.css_href = "my-card.css".to_string();
10258 comp.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10259 protocol.populate_style_closures(&["index.html"]);
10260
10261 let state = test_json!({});
10262 let mut writer = TestWriter::new();
10263
10264 handle(
10265 &protocol,
10266 &state,
10267 &RenderOptions::new("index.html", "/"),
10268 &mut writer,
10269 )
10270 .unwrap();
10271
10272 let html = writer.get_content();
10273
10274 let head_end = html.find("</head>").expect("</head> missing");
10275 let link_pos = html.find(r#"<link rel="stylesheet" href="my-card.css""#);
10276 assert!(
10277 link_pos.is_some_and(|p| p < head_end),
10278 "Light DOM Link strategy should emit <link rel=stylesheet> in <head>: {html}"
10279 );
10280 assert!(
10281 !html.contains(r#"<link rel="preload""#),
10282 "Light DOM Link strategy should NOT emit preload (stylesheet already fetches): {html}"
10283 );
10284 assert!(
10285 !html.contains(r#"<style type="module""#),
10286 "Link strategy should not emit legacy module CSS: {html}"
10287 );
10288 assert!(
10289 !html.contains(r#"<script type="importmap""#),
10290 "Link strategy should not emit CSS module importmaps: {html}"
10291 );
10292 }
10293
10294 #[test]
10295 fn link_strategy_emits_stylesheets_in_deterministic_document_order() {
10296 let mut fragments = HashMap::new();
10303 fragments.insert(
10304 "index.html".to_string(),
10305 FragmentList {
10306 fragments: vec![
10307 WebUIFragment::raw("<html><head>".to_string()),
10308 structural_fragment("head_end"),
10309 WebUIFragment::raw(
10310 "</head><body><comp-a></comp-a><comp-b></comp-b><comp-c></comp-c>"
10311 .to_string(),
10312 ),
10313 WebUIFragment::component("comp-a"),
10314 WebUIFragment::component("comp-b"),
10315 WebUIFragment::component("comp-c"),
10316 structural_fragment("body_end"),
10317 WebUIFragment::raw("</body></html>".to_string()),
10318 ],
10319 contains_boundary: false,
10320 },
10321 );
10322 for tag in ["comp-a", "comp-b", "comp-c"] {
10323 fragments.insert(
10324 tag.to_string(),
10325 FragmentList {
10326 fragments: vec![WebUIFragment::raw(format!("<div>{tag}</div>"))],
10327 contains_boundary: false,
10328 },
10329 );
10330 }
10331
10332 let mut protocol = WebUIProtocol::new(fragments);
10333 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10334 for tag in ["comp-a", "comp-b", "comp-c"] {
10335 let comp = protocol.components.entry(tag.to_string()).or_default();
10336 comp.css_href = format!("{tag}.css");
10337 comp.template_json = format!(r#"{{"h":"<div>{tag}</div>"}}"#);
10338 }
10339 protocol.populate_style_closures(&["index.html"]);
10340
10341 let state = test_json!({});
10342 let mut writer = TestWriter::new();
10343 handle(
10344 &protocol,
10345 &state,
10346 &RenderOptions::new("index.html", "/"),
10347 &mut writer,
10348 )
10349 .unwrap();
10350 let html = writer.get_content();
10351
10352 let pos_a = html
10353 .find(r#"href="comp-a.css""#)
10354 .expect("comp-a.css link missing");
10355 let pos_b = html
10356 .find(r#"href="comp-b.css""#)
10357 .expect("comp-b.css link missing");
10358 let pos_c = html
10359 .find(r#"href="comp-c.css""#)
10360 .expect("comp-c.css link missing");
10361 assert!(
10362 pos_a < pos_b && pos_b < pos_c,
10363 "stylesheet <link> tags must follow document/traversal order \
10364 (comp-a, comp-b, comp-c), got positions {pos_a}, {pos_b}, {pos_c}: {html}"
10365 );
10366 }
10367
10368 #[test]
10369 fn link_strategy_preloads_static_shadow_css_in_head() {
10370 let mut fragments = HashMap::new();
10371 fragments.insert(
10372 "index.html".to_string(),
10373 FragmentList {
10374 fragments: vec![
10375 WebUIFragment::raw("<html><head>".to_string()),
10376 structural_fragment("head_end"),
10377 WebUIFragment::raw("</head><body><my-card>".to_string()),
10378 WebUIFragment::component("my-card"),
10379 WebUIFragment::raw("</my-card></body></html>".to_string()),
10380 ],
10381 contains_boundary: false,
10382 },
10383 );
10384 fragments.insert(
10385 "o-loading-state".to_string(),
10386 FragmentList {
10387 fragments: vec![WebUIFragment::raw("<div>loading</div>".to_string())],
10388 contains_boundary: false,
10389 },
10390 );
10391 fragments.insert(
10392 "my-card".to_string(),
10393 FragmentList {
10394 fragments: vec![
10395 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10396 structural_fragment("shadow_styles:my-card"),
10397 WebUIFragment::raw("<div>card</div></template>".to_string()),
10398 ],
10399 contains_boundary: false,
10400 },
10401 );
10402
10403 let mut protocol = WebUIProtocol::new(fragments);
10404 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10405 let component = protocol
10406 .components
10407 .entry("my-card".to_string())
10408 .or_default();
10409 component.css_href = "my-card.css".to_string();
10410 component.template_json = r#"{"h":"<div>card</div>"}"#.to_string();
10411 component.uses_shadow_dom = true;
10412 protocol.populate_style_closures(&["index.html"]);
10413
10414 let mut writer = TestWriter::new();
10415 handle(
10416 &protocol,
10417 &test_json!({}),
10418 &RenderOptions::new("index.html", "/"),
10419 &mut writer,
10420 )
10421 .unwrap();
10422
10423 let html = writer.get_content();
10424 let head_end = html.find("</head>").expect("</head> missing");
10425 let preload = html
10426 .find(r#"<link rel="preload" as="style" href="my-card.css">"#)
10427 .expect("Shadow preload missing");
10428 let stylesheet = html
10429 .find(r#"<link rel="stylesheet" href="my-card.css""#)
10430 .expect("Shadow stylesheet missing");
10431 assert!(preload < head_end);
10432 assert!(stylesheet > head_end);
10433 assert_eq!(html.matches(r#"rel="preload" as="style""#).count(), 1);
10434 }
10435
10436 #[test]
10437 fn tree_local_styles_deduplicate_per_root_and_resume_caller_frame() {
10438 let fragments = HashMap::from([
10439 (
10440 "index.html".to_string(),
10441 FragmentList {
10442 fragments: vec![
10443 WebUIFragment::raw("<html><head>".to_string()),
10444 structural_fragment("head_end"),
10445 WebUIFragment::raw("</head><body><outer-box>".to_string()),
10446 WebUIFragment::component("outer-box"),
10447 WebUIFragment::raw("</outer-box></body></html>".to_string()),
10448 ],
10449 contains_boundary: false,
10450 },
10451 ),
10452 (
10453 "outer-box".to_string(),
10454 FragmentList {
10455 fragments: vec![
10456 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10457 structural_fragment("shadow_styles:outer-box"),
10458 WebUIFragment::raw("<light-card>".to_string()),
10459 WebUIFragment::component("light-card"),
10460 WebUIFragment::raw("</light-card><light-card>".to_string()),
10461 WebUIFragment::component("light-card"),
10462 WebUIFragment::raw("</light-card><inner-box>".to_string()),
10463 WebUIFragment::component("inner-box"),
10464 WebUIFragment::raw("</inner-box><light-card>".to_string()),
10465 WebUIFragment::component("light-card"),
10466 WebUIFragment::raw("</light-card></template>".to_string()),
10467 ],
10468 contains_boundary: false,
10469 },
10470 ),
10471 (
10472 "inner-box".to_string(),
10473 FragmentList {
10474 fragments: vec![
10475 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
10476 structural_fragment("shadow_styles:inner-box"),
10477 WebUIFragment::raw("<light-card>".to_string()),
10478 WebUIFragment::component("light-card"),
10479 WebUIFragment::raw("</light-card></template>".to_string()),
10480 ],
10481 contains_boundary: false,
10482 },
10483 ),
10484 (
10485 "light-card".to_string(),
10486 FragmentList {
10487 fragments: vec![WebUIFragment::raw("<p>card</p>".to_string())],
10488 contains_boundary: false,
10489 },
10490 ),
10491 ]);
10492 let mut protocol = WebUIProtocol::new(fragments);
10493 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
10494 for (tag, css, uses_shadow_dom) in [
10495 ("outer-box", ".outer{}", true),
10496 ("inner-box", ".inner{}", true),
10497 ("light-card", ".card{}", false),
10498 ] {
10499 let component = protocol.components.entry(tag.to_string()).or_default();
10500 component.css = css.to_string();
10501 component.uses_shadow_dom = uses_shadow_dom;
10502 }
10503 protocol.populate_style_closures(&["index.html"]);
10504
10505 let mut writer = TestWriter::new();
10506 handle(
10507 &protocol,
10508 &test_json!({}),
10509 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10510 &mut writer,
10511 )
10512 .unwrap();
10513 let html = writer.get_content();
10514
10515 assert_eq!(html.matches("data-webui-resource=\"outer-box\"").count(), 1);
10516 assert_eq!(html.matches("data-webui-resource=\"inner-box\"").count(), 1);
10517 assert_eq!(
10518 html.matches("data-webui-resource=\"light-card\"").count(),
10519 2,
10520 "the Light resource installs once in each Shadow tree: {html}"
10521 );
10522 assert_eq!(
10523 html.matches("data-webui-resource=").count(),
10524 4,
10525 "outer-box + light-card and inner-box + light-card are the four tree-local installs"
10526 );
10527 assert_eq!(
10528 html.matches("nonce=\"css-nonce\"").count(),
10529 4,
10530 "each inline style resource carries the nonce"
10531 );
10532 assert!(html.contains(r#"<meta name="webui-nonce" content="css-nonce">"#));
10533 let inner_end = html.find("</inner-box>").expect("inner host close");
10534 let caller_continuation = &html[inner_end..];
10535 let trailing_card = caller_continuation
10536 .find("<p>card</p>")
10537 .expect("caller continues after nested Shadow root");
10538 assert!(trailing_card > 0);
10539 assert!(
10540 !caller_continuation.contains("data-webui-resource=\"light-card\""),
10541 "returning from the nested Shadow root must restore the outer frame's delivered set"
10542 );
10543 }
10544
10545 #[test]
10546 fn routed_document_styles_are_hoisted_for_the_active_chain() {
10547 let route = WebUiFragmentRoute {
10548 path: "/".to_string(),
10549 fragment_id: "app-shell".to_string(),
10550 children: vec![
10551 WebUiFragmentRoute {
10552 path: String::new(),
10553 fragment_id: "dashboard-page".to_string(),
10554 exact: true,
10555 ..Default::default()
10556 },
10557 WebUiFragmentRoute {
10558 path: "details".to_string(),
10559 fragment_id: "detail-page".to_string(),
10560 exact: true,
10561 ..Default::default()
10562 },
10563 ],
10564 ..Default::default()
10565 };
10566 let fragments = HashMap::from([
10567 (
10568 "index.html".to_string(),
10569 FragmentList {
10570 fragments: vec![
10571 WebUIFragment::raw("<html><head>"),
10572 structural_fragment("head_start"),
10573 structural_fragment("head_end"),
10574 WebUIFragment::raw("</head><body>"),
10575 structural_fragment("body_start"),
10576 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
10577 WebUIFragment::route_from(route),
10578 WebUIFragment::boundary_end(0),
10579 structural_fragment("body_end"),
10580 WebUIFragment::raw("</body></html>"),
10581 ],
10582 contains_boundary: true,
10583 },
10584 ),
10585 (
10586 "app-shell".to_string(),
10587 FragmentList {
10588 fragments: vec![
10589 WebUIFragment::raw("<header>App</header>"),
10590 WebUIFragment::outlet(),
10591 ],
10592 contains_boundary: false,
10593 },
10594 ),
10595 (
10596 "dashboard-page".to_string(),
10597 FragmentList {
10598 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10599 contains_boundary: false,
10600 },
10601 ),
10602 (
10603 "detail-page".to_string(),
10604 FragmentList {
10605 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
10606 contains_boundary: false,
10607 },
10608 ),
10609 ]);
10610 let mut protocol = WebUIProtocol::new(fragments);
10611 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10612 for tag in ["app-shell", "dashboard-page", "detail-page"] {
10613 let component = protocol.components.entry(tag.to_string()).or_default();
10614 component.css_href = format!("/{tag}.css");
10615 component.template_json = r#"{"h":""}"#.to_string();
10616 }
10617 protocol.populate_style_closures(&["index.html"]);
10618
10619 let mut writer = TestWriter::new();
10620 handle(
10621 &protocol,
10622 &test_json!({}),
10623 &RenderOptions::new("index.html", "/"),
10624 &mut writer,
10625 )
10626 .unwrap();
10627 let html = writer.get_content();
10628
10629 assert_eq!(
10630 html.matches(r#"data-webui-resource="app-shell""#).count(),
10631 1,
10632 "the matched route shell must install once: {html}"
10633 );
10634 assert_eq!(
10635 html.matches(r#"data-webui-resource="dashboard-page""#)
10636 .count(),
10637 1,
10638 "the matched child route must install once: {html}"
10639 );
10640 assert!(
10641 !html.contains(r#"data-webui-resource="detail-page""#),
10642 "inactive route CSS must not be delivered: {html}"
10643 );
10644 let head_end = html.find("</head>").expect("head close");
10645 let head = &html[..head_end];
10646 for (resource, href) in [
10647 ("app-shell", "/app-shell.css"),
10648 ("dashboard-page", "/dashboard-page.css"),
10649 ] {
10650 let encoded_href = crate::html_encode::encode_safe(href);
10651 assert!(
10652 head.contains(&format!(
10653 r#"<link rel="stylesheet" href="{encoded_href}" data-webui-resource="{resource}" data-webui-strategy="link">"#
10654 )),
10655 "Document-targeted route CSS must be render-blocking in head: {html}"
10656 );
10657 assert!(
10658 !head.contains(&format!(
10659 r#"<link rel="preload" as="style" href="{encoded_href}">"#
10660 )),
10661 "a hoisted stylesheet must not also be preloaded: {html}"
10662 );
10663 }
10664 assert!(
10665 !head.contains("detail-page.css"),
10666 "inactive route CSS must not be preloaded: {html}"
10667 );
10668 let app_style = html
10669 .find(r#"data-webui-resource="app-shell""#)
10670 .expect("app style");
10671 let dashboard_style = html
10672 .find(r#"data-webui-resource="dashboard-page""#)
10673 .expect("dashboard style");
10674 let app_host = html.find("<app-shell").expect("app route host");
10675 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10676 assert!(
10677 app_style < dashboard_style
10678 && dashboard_style < head_end
10679 && head_end < app_host
10680 && app_host < dashboard_host,
10681 "matched Document route closures must be hoisted in chain order: {html}"
10682 );
10683
10684 let protocol = Protocol::new(protocol);
10685 let mut streamed = FlushTestWriter::default();
10686 WebUIHandler::new()
10687 .render_streaming(
10688 &protocol,
10689 &test_json!({}),
10690 &RenderOptions::new("index.html", "/"),
10691 &mut streamed,
10692 )
10693 .unwrap();
10694 let streamed_head_end = streamed
10695 .output
10696 .find("</head>")
10697 .expect("streamed head close");
10698 for resource in ["app-shell", "dashboard-page"] {
10699 let marker = format!(r#"data-webui-resource="{resource}""#);
10700 let position = streamed.output.find(&marker).expect("streamed route style");
10701 assert!(
10702 position < streamed_head_end,
10703 "streaming must hoist {resource} into head: {}",
10704 streamed.output
10705 );
10706 assert_eq!(
10707 streamed.output.matches(&marker).count(),
10708 1,
10709 "streaming must not emit {resource} twice: {}",
10710 streamed.output
10711 );
10712 }
10713 }
10714
10715 #[test]
10716 fn bundled_routed_document_stylesheet_is_hoisted_once() {
10717 let route = WebUiFragmentRoute {
10718 path: "/".to_string(),
10719 fragment_id: "dashboard-page".to_string(),
10720 exact: true,
10721 ..Default::default()
10722 };
10723 let mut protocol = WebUIProtocol::new(HashMap::from([
10724 (
10725 "index.html".to_string(),
10726 FragmentList {
10727 fragments: vec![
10728 WebUIFragment::raw("<html><head>"),
10729 structural_fragment("head_start"),
10730 structural_fragment("head_end"),
10731 WebUIFragment::raw("</head><body>"),
10732 WebUIFragment::route_from(route),
10733 structural_fragment("body_end"),
10734 WebUIFragment::raw("</body></html>"),
10735 ],
10736 contains_boundary: false,
10737 },
10738 ),
10739 (
10740 "dashboard-page".to_string(),
10741 FragmentList {
10742 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10743 contains_boundary: false,
10744 },
10745 ),
10746 ]));
10747 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10748 for tag in ["dashboard-page", "summary-card", "activity-list"] {
10749 protocol
10750 .components
10751 .entry(tag.to_string())
10752 .or_default()
10753 .css_href = format!("/{tag}.css");
10754 }
10755 protocol.style_closures.insert(
10756 "index.html".to_string(),
10757 webui_protocol::ComponentStyleClosure::default(),
10758 );
10759 protocol.style_closures.insert(
10760 "dashboard-page".to_string(),
10761 webui_protocol::ComponentStyleClosure {
10762 component_tags: vec![
10763 "dashboard-page".to_string(),
10764 "summary-card".to_string(),
10765 "activity-list".to_string(),
10766 ],
10767 style_chunks: vec![0],
10768 },
10769 );
10770 protocol.style_chunks.push(webui_protocol::StyleChunk {
10771 name: "_chunk-dashboard-page-3".to_string(),
10772 css: String::new(),
10773 css_href: "/_chunk-dashboard-page-3.css".to_string(),
10774 component_tags: vec![
10775 "dashboard-page".to_string(),
10776 "summary-card".to_string(),
10777 "activity-list".to_string(),
10778 ],
10779 });
10780
10781 let mut writer = TestWriter::new();
10782 handle(
10783 &protocol,
10784 &test_json!({}),
10785 &RenderOptions::new("index.html", "/"),
10786 &mut writer,
10787 )
10788 .unwrap();
10789 let html = writer.get_content();
10790 let head_end = html.find("</head>").expect("head close");
10791 let marker = r#"data-webui-resource="_chunk-dashboard-page-3""#;
10792 let style = html.find(marker).expect("bundled route stylesheet");
10793
10794 assert!(
10795 style < head_end,
10796 "bundled route CSS must be in head: {html}"
10797 );
10798 assert_eq!(
10799 html.matches(marker).count(),
10800 1,
10801 "the route host must not emit the hoisted chunk again: {html}"
10802 );
10803 let bundled_href = crate::html_encode::encode_safe("/_chunk-dashboard-page-3.css");
10804 assert!(
10805 !html.contains(&format!(
10806 r#"<link rel="preload" as="style" href="{bundled_href}">"#
10807 )),
10808 "the applied bundled stylesheet must not also be preloaded: {html}"
10809 );
10810 }
10811
10812 #[test]
10813 fn routed_document_inline_styles_are_hoisted() {
10814 for strategy in [
10815 webui_protocol::CssStrategy::Style,
10816 webui_protocol::CssStrategy::Module,
10817 ] {
10818 let route = WebUiFragmentRoute {
10819 path: "/".to_string(),
10820 fragment_id: "dashboard-page".to_string(),
10821 exact: true,
10822 ..Default::default()
10823 };
10824 let mut protocol = WebUIProtocol::new(HashMap::from([
10825 (
10826 "index.html".to_string(),
10827 FragmentList {
10828 fragments: vec![
10829 WebUIFragment::raw("<html><head>"),
10830 structural_fragment("head_start"),
10831 structural_fragment("head_end"),
10832 WebUIFragment::raw("</head><body>"),
10833 WebUIFragment::route_from(route),
10834 structural_fragment("body_end"),
10835 WebUIFragment::raw("</body></html>"),
10836 ],
10837 contains_boundary: false,
10838 },
10839 ),
10840 (
10841 "dashboard-page".to_string(),
10842 FragmentList {
10843 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10844 contains_boundary: false,
10845 },
10846 ),
10847 ]));
10848 protocol.set_css_strategy(strategy);
10849 protocol
10850 .components
10851 .entry("dashboard-page".to_string())
10852 .or_default()
10853 .css = ".dashboard{display:grid}".to_string();
10854 protocol.populate_style_closures(&["index.html"]);
10855
10856 let mut writer = TestWriter::new();
10857 handle(
10858 &protocol,
10859 &test_json!({}),
10860 &RenderOptions::new("index.html", "/").with_nonce("css-nonce"),
10861 &mut writer,
10862 )
10863 .unwrap();
10864 let html = writer.get_content();
10865 let head_end = html.find("</head>").expect("head close");
10866 let marker = r#"data-webui-resource="dashboard-page""#;
10867 let style = html.find(marker).expect("route style fallback");
10868 let host = html.find("<dashboard-page").expect("dashboard route host");
10869 let strategy_name = if strategy == webui_protocol::CssStrategy::Module {
10870 "module"
10871 } else {
10872 "style"
10873 };
10874 let style_marker = format!(
10875 r#"data-webui-resource="dashboard-page" data-webui-strategy="{strategy_name}""#
10876 );
10877
10878 assert!(
10879 style < head_end && head_end < host,
10880 "{strategy:?} route CSS must be applied in head before its host: {html}"
10881 );
10882 assert_eq!(
10883 html.matches(&style_marker).count(),
10884 1,
10885 "{strategy:?} route CSS must not be emitted twice: {html}"
10886 );
10887 }
10888 }
10889
10890 #[test]
10891 fn light_route_inside_static_shadow_root_is_only_preloaded_in_head() {
10892 let route = WebUiFragmentRoute {
10893 path: "/".to_string(),
10894 fragment_id: "dashboard-page".to_string(),
10895 exact: true,
10896 ..Default::default()
10897 };
10898 let mut protocol = WebUIProtocol::new(HashMap::from([
10899 (
10900 "index.html".to_string(),
10901 FragmentList {
10902 fragments: vec![
10903 WebUIFragment::raw("<html><head>"),
10904 structural_fragment("head_start"),
10905 structural_fragment("head_end"),
10906 WebUIFragment::raw("</head><body><outer-box>"),
10907 WebUIFragment::component("outer-box"),
10908 WebUIFragment::raw("</outer-box></body></html>"),
10909 ],
10910 contains_boundary: false,
10911 },
10912 ),
10913 (
10914 "outer-box".to_string(),
10915 FragmentList {
10916 fragments: vec![
10917 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10918 structural_fragment("shadow_styles:outer-box"),
10919 WebUIFragment::route_from(route),
10920 WebUIFragment::raw("</template>"),
10921 ],
10922 contains_boundary: false,
10923 },
10924 ),
10925 (
10926 "dashboard-page".to_string(),
10927 FragmentList {
10928 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
10929 contains_boundary: false,
10930 },
10931 ),
10932 ]));
10933 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
10934 for tag in ["outer-box", "dashboard-page"] {
10935 protocol
10936 .components
10937 .entry(tag.to_string())
10938 .or_default()
10939 .css_href = format!("/{tag}.css");
10940 }
10941 protocol
10942 .components
10943 .get_mut("outer-box")
10944 .expect("outer-box component")
10945 .uses_shadow_dom = true;
10946 protocol.populate_style_closures(&["index.html"]);
10947
10948 let mut writer = TestWriter::new();
10949 handle(
10950 &protocol,
10951 &test_json!({}),
10952 &RenderOptions::new("index.html", "/"),
10953 &mut writer,
10954 )
10955 .unwrap();
10956 let html = writer.get_content();
10957 let head_end = html.find("</head>").expect("head close");
10958 let head = &html[..head_end];
10959 let dashboard_style = html
10960 .find(r#"data-webui-resource="dashboard-page""#)
10961 .expect("tree-local dashboard stylesheet");
10962 let shadow_start = html
10963 .find("<template shadowrootmode=\"open\">")
10964 .expect("Shadow template");
10965 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
10966 let dashboard_href = crate::html_encode::encode_safe("/dashboard-page.css");
10967
10968 assert!(
10969 head.contains(&format!(
10970 r#"<link rel="preload" as="style" href="{dashboard_href}">"#
10971 )),
10972 "Shadow-targeted route CSS must start loading from head: {html}"
10973 );
10974 assert!(
10975 !head.contains(r#"data-webui-resource="dashboard-page""#),
10976 "Shadow-targeted route CSS must not be applied to Document: {html}"
10977 );
10978 assert!(
10979 shadow_start < dashboard_style && dashboard_style < dashboard_host,
10980 "the applying stylesheet must remain inside the owning ShadowRoot: {html}"
10981 );
10982 }
10983
10984 #[test]
10985 fn isolated_shadow_entry_does_not_emit_document_preloads() {
10986 let route = WebUiFragmentRoute {
10987 path: "/".to_string(),
10988 fragment_id: "dashboard-page".to_string(),
10989 exact: true,
10990 ..Default::default()
10991 };
10992 let mut protocol = WebUIProtocol::new(HashMap::from([
10993 (
10994 "outer-box".to_string(),
10995 FragmentList {
10996 fragments: vec![
10997 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
10998 structural_fragment("shadow_styles:outer-box"),
10999 WebUIFragment::route_from(route),
11000 WebUIFragment::raw("</template>"),
11001 ],
11002 contains_boundary: false,
11003 },
11004 ),
11005 (
11006 "dashboard-page".to_string(),
11007 FragmentList {
11008 fragments: vec![WebUIFragment::raw("<main>Dashboard</main>")],
11009 contains_boundary: false,
11010 },
11011 ),
11012 ]));
11013 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11014 for tag in ["outer-box", "dashboard-page"] {
11015 protocol
11016 .components
11017 .entry(tag.to_string())
11018 .or_default()
11019 .css_href = format!("/{tag}.css");
11020 }
11021 protocol
11022 .components
11023 .get_mut("outer-box")
11024 .expect("outer-box component")
11025 .uses_shadow_dom = true;
11026 protocol.populate_style_closures(&["outer-box"]);
11027
11028 let mut writer = TestWriter::new();
11029 handle(
11030 &protocol,
11031 &test_json!({}),
11032 &RenderOptions::new("outer-box", "/"),
11033 &mut writer,
11034 )
11035 .unwrap();
11036 let html = writer.get_content();
11037
11038 assert!(
11039 html.starts_with("<template shadowrootmode=\"open\">"),
11040 "isolated component output must not be prefixed with document metadata: {html}"
11041 );
11042 assert!(
11043 !html.contains(r#"<link rel="preload""#),
11044 "an isolated Shadow entry has no Document head for preloads: {html}"
11045 );
11046 assert!(
11047 html.contains(r#"data-webui-resource="dashboard-page""#),
11048 "the route stylesheet must still install inside the ShadowRoot: {html}"
11049 );
11050 }
11051
11052 #[test]
11053 fn routed_shadow_styles_stay_tree_local_and_deduplicate_static_resources() {
11054 let route = WebUiFragmentRoute {
11055 path: "/".to_string(),
11056 fragment_id: "app-shell".to_string(),
11057 children: vec![
11058 WebUiFragmentRoute {
11059 path: String::new(),
11060 fragment_id: "dashboard-page".to_string(),
11061 exact: true,
11062 ..Default::default()
11063 },
11064 WebUiFragmentRoute {
11065 path: "details".to_string(),
11066 fragment_id: "detail-page".to_string(),
11067 exact: true,
11068 ..Default::default()
11069 },
11070 ],
11071 ..Default::default()
11072 };
11073 let fragments = HashMap::from([
11074 (
11075 "index.html".to_string(),
11076 FragmentList {
11077 fragments: vec![
11078 WebUIFragment::raw("<html><head>"),
11079 structural_fragment("head_start"),
11080 structural_fragment("head_end"),
11081 WebUIFragment::raw("</head><body>"),
11082 structural_fragment("body_start"),
11083 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
11084 WebUIFragment::route_from(route),
11085 WebUIFragment::boundary_end(0),
11086 structural_fragment("body_end"),
11087 WebUIFragment::raw("</body></html>"),
11088 ],
11089 contains_boundary: true,
11090 },
11091 ),
11092 (
11093 "app-shell".to_string(),
11094 FragmentList {
11095 fragments: vec![
11096 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11097 structural_fragment("shadow_styles:app-shell"),
11098 WebUIFragment::raw("<shared-card"),
11099 structural_fragment("streaming_root:shared-card"),
11100 WebUIFragment::raw(">"),
11101 WebUIFragment::component("shared-card"),
11102 WebUIFragment::raw("</shared-card>"),
11103 WebUIFragment::outlet(),
11104 WebUIFragment::raw("</template>"),
11105 ],
11106 contains_boundary: false,
11107 },
11108 ),
11109 (
11110 "dashboard-page".to_string(),
11111 FragmentList {
11112 fragments: vec![
11113 WebUIFragment::raw("<shared-card"),
11114 structural_fragment("streaming_root:shared-card"),
11115 WebUIFragment::raw(">"),
11116 WebUIFragment::component("shared-card"),
11117 WebUIFragment::raw("</shared-card><main>Dashboard</main>"),
11118 ],
11119 contains_boundary: false,
11120 },
11121 ),
11122 (
11123 "detail-page".to_string(),
11124 FragmentList {
11125 fragments: vec![WebUIFragment::raw("<main>Detail</main>")],
11126 contains_boundary: false,
11127 },
11128 ),
11129 (
11130 "shared-card".to_string(),
11131 FragmentList {
11132 fragments: vec![WebUIFragment::raw("<p>Shared</p>")],
11133 contains_boundary: false,
11134 },
11135 ),
11136 ]);
11137 let mut protocol = WebUIProtocol::new(fragments);
11138 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11139 for tag in ["app-shell", "dashboard-page", "detail-page", "shared-card"] {
11140 let component = protocol.components.entry(tag.to_string()).or_default();
11141 component.css = format!(".{tag}{{display:block}}");
11142 component.template_json = r#"{"h":""}"#.to_string();
11143 }
11144 protocol
11145 .components
11146 .get_mut("app-shell")
11147 .expect("app shell component")
11148 .uses_shadow_dom = true;
11149 protocol.populate_style_closures(&["index.html"]);
11150
11151 let mut writer = TestWriter::new();
11152 handle(
11153 &protocol,
11154 &test_json!({}),
11155 &RenderOptions::new("index.html", "/"),
11156 &mut writer,
11157 )
11158 .unwrap();
11159 let html = writer.get_content();
11160
11161 for resource in ["app-shell", "shared-card", "dashboard-page"] {
11162 assert_eq!(
11163 html.matches(&format!(r#"data-webui-resource="{resource}""#))
11164 .count(),
11165 1,
11166 "{resource} must install once in the active ShadowRoot: {html}"
11167 );
11168 }
11169 assert!(
11170 !html.contains(r#"data-webui-resource="detail-page""#),
11171 "inactive route CSS must not enter the ShadowRoot: {html}"
11172 );
11173 let shadow_start = html
11174 .find("<template shadowrootmode=\"open\">")
11175 .expect("Shadow template");
11176 let dashboard_style = html
11177 .find(r#"data-webui-resource="dashboard-page""#)
11178 .expect("dashboard style");
11179 let dashboard_host = html.find("<dashboard-page").expect("dashboard route host");
11180 let shadow_end = html.find("</template>").expect("Shadow template close");
11181 assert!(
11182 shadow_start < dashboard_style
11183 && dashboard_style < dashboard_host
11184 && dashboard_host < shadow_end,
11185 "active route CSS must precede its generated host inside the owning ShadowRoot: {html}"
11186 );
11187
11188 let protocol = Protocol::new(protocol);
11189 let mut streamed = FlushTestWriter::default();
11190 WebUIHandler::new()
11191 .render_streaming(
11192 &protocol,
11193 &test_json!({}),
11194 &RenderOptions::new("index.html", "/"),
11195 &mut streamed,
11196 )
11197 .unwrap();
11198 for resource in ["app-shell", "shared-card"] {
11199 assert_eq!(
11200 streamed
11201 .output
11202 .matches(&format!(r#"data-webui-resource="{resource}""#))
11203 .count(),
11204 1,
11205 "streaming must preserve tree-local delivery for {resource}: {}",
11206 streamed.output
11207 );
11208 }
11209 assert!(
11210 !streamed
11211 .output
11212 .contains(r#"data-webui-resource="dashboard-page""#),
11213 "route CSS is carried by the streamed checkpoint rather than installed before the route host: {}",
11214 streamed.output
11215 );
11216 assert!(
11217 streamed
11218 .output
11219 .contains(r#""dashboard-page":{"css":".dashboard-page{display:block}""#)
11220 && streamed
11221 .output
11222 .contains(r#""dashboard-page":["dashboard-page","shared-card"]"#),
11223 "streamed route CSS metadata must preserve tree ownership and cascade order: {}",
11224 streamed.output
11225 );
11226 assert!(
11227 !streamed
11228 .output
11229 .contains(r#"data-webui-resource="detail-page""#),
11230 "streaming must omit inactive route CSS: {}",
11231 streamed.output
11232 );
11233 }
11234
11235 #[test]
11236 fn document_styles_follow_doctype_before_headless_document_content() {
11237 for entry_fragments in [
11238 vec![
11239 WebUIFragment::raw("<!DOCTYPE html><html><body>"),
11240 structural_fragment("body_start"),
11241 WebUIFragment::raw("<my-card>"),
11242 WebUIFragment::component("my-card"),
11243 WebUIFragment::raw("</my-card></body></html>"),
11244 ],
11245 vec![
11246 WebUIFragment::raw("<!doctype html><html><my-card>"),
11247 WebUIFragment::component("my-card"),
11248 WebUIFragment::raw("</my-card></html>"),
11249 ],
11250 ] {
11251 let mut protocol = WebUIProtocol::new(HashMap::from([
11252 (
11253 "index.html".to_string(),
11254 FragmentList {
11255 fragments: entry_fragments,
11256 contains_boundary: false,
11257 },
11258 ),
11259 (
11260 "my-card".to_string(),
11261 FragmentList {
11262 fragments: vec![WebUIFragment::raw("<p>card</p>")],
11263 contains_boundary: false,
11264 },
11265 ),
11266 ]));
11267 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11268 let component = protocol
11269 .components
11270 .entry("my-card".to_string())
11271 .or_default();
11272 component.css = ".card{color:red}".to_string();
11273 component.uses_shadow_dom = false;
11274 protocol.populate_style_closures(&["index.html"]);
11275
11276 let mut writer = TestWriter::new();
11277 handle(
11278 &protocol,
11279 &test_json!({}),
11280 &RenderOptions::new("index.html", "/"),
11281 &mut writer,
11282 )
11283 .expect("render");
11284 let html = writer.get_content();
11285
11286 assert!(
11287 html.to_ascii_lowercase().starts_with("<!doctype html>"),
11288 "the doctype must remain the first document token: {html}"
11289 );
11290 let style = html.find("data-webui-resource=\"my-card\"").expect("style");
11291 let document = html.find("<html").expect("document root");
11292 let component = html.find("<my-card>").expect("component host");
11293 assert!(
11294 style < document && style < component,
11295 "headless document styles must precede document content: {html}"
11296 );
11297 }
11298 }
11299
11300 #[test]
11301 fn authored_shadow_component_can_render_as_entry_fragment() {
11302 let mut protocol = WebUIProtocol::new(HashMap::from([(
11303 "my-card".to_string(),
11304 FragmentList {
11305 fragments: vec![
11306 WebUIFragment::raw("<template shadowrootmode=\"open\">"),
11307 structural_fragment("shadow_styles:my-card"),
11308 WebUIFragment::raw("<p>card</p></template>"),
11309 ],
11310 contains_boundary: false,
11311 },
11312 )]));
11313 protocol.set_css_strategy(webui_protocol::CssStrategy::Style);
11314 let component = protocol
11315 .components
11316 .entry("my-card".to_string())
11317 .or_default();
11318 component.css = ".card{color:red}".to_string();
11319 component.uses_shadow_dom = true;
11320 protocol.populate_style_closures(&["my-card"]);
11321
11322 let mut writer = TestWriter::new();
11323 handle(
11324 &protocol,
11325 &test_json!({}),
11326 &RenderOptions::new("my-card", "/"),
11327 &mut writer,
11328 )
11329 .expect("render");
11330 let html = writer.get_content();
11331
11332 assert!(html.starts_with("<template shadowrootmode=\"open\"><style "));
11333 assert_eq!(html.matches("data-webui-resource=\"my-card\"").count(), 1);
11334 assert!(html.ends_with("<p>card</p></template>"));
11335 }
11336
11337 #[test]
11338 fn component_style_closures_escape_mixed_case_style_end_tags() {
11339 for strategy in [
11340 webui_protocol::CssStrategy::Style,
11341 webui_protocol::CssStrategy::Module,
11342 ] {
11343 let mut protocol = WebUIProtocol::new(HashMap::from([
11344 (
11345 "index.html".to_string(),
11346 FragmentList {
11347 fragments: vec![
11348 WebUIFragment::raw("<html><head>"),
11349 structural_fragment("head_end"),
11350 WebUIFragment::raw("</head><body><safe-card>"),
11351 WebUIFragment::component("safe-card"),
11352 WebUIFragment::raw("</safe-card></body></html>"),
11353 ],
11354 contains_boundary: false,
11355 },
11356 ),
11357 (
11358 "safe-card".to_string(),
11359 FragmentList {
11360 fragments: vec![WebUIFragment::raw("<p>Safe</p>")],
11361 contains_boundary: false,
11362 },
11363 ),
11364 ]));
11365 protocol.set_css_strategy(strategy);
11366 protocol.components.insert(
11367 "safe-card".to_string(),
11368 webui_protocol::ComponentData {
11369 css: ".safe{content:'</StYlE>'}".to_string(),
11370 ..Default::default()
11371 },
11372 );
11373 protocol.populate_style_closures(&["index.html"]);
11374 let mut writer = TestWriter::new();
11375
11376 handle(
11377 &protocol,
11378 &test_json!({}),
11379 &RenderOptions::new("index.html", "/"),
11380 &mut writer,
11381 )
11382 .unwrap();
11383
11384 let html = writer.get_content();
11385 assert!(html.contains(".safe{content:'<\\/StYlE>'}"), "{html}");
11386 assert!(!html.contains(".safe{content:'</StYlE>'}"), "{html}");
11387 }
11388 }
11389
11390 #[test]
11391 fn shadow_closure_supports_link_and_module_ssr_paths() {
11392 for strategy in [
11393 webui_protocol::CssStrategy::Link,
11394 webui_protocol::CssStrategy::Module,
11395 ] {
11396 let mut protocol = WebUIProtocol::new(HashMap::from([
11397 (
11398 "index.html".to_string(),
11399 FragmentList {
11400 fragments: vec![
11401 WebUIFragment::raw("<html><head>".to_string()),
11402 structural_fragment("head_end"),
11403 WebUIFragment::raw("</head><body><my-card>".to_string()),
11404 WebUIFragment::component("my-card"),
11405 WebUIFragment::raw("</my-card></body></html>".to_string()),
11406 ],
11407 contains_boundary: false,
11408 },
11409 ),
11410 (
11411 "my-card".to_string(),
11412 FragmentList {
11413 fragments: vec![
11414 WebUIFragment::raw("<template shadowrootmode=\"open\">".to_string()),
11415 structural_fragment("shadow_styles:my-card"),
11416 WebUIFragment::raw("<p>card</p></template>".to_string()),
11417 ],
11418 contains_boundary: false,
11419 },
11420 ),
11421 ]));
11422 protocol.set_css_strategy(strategy);
11423 let component = protocol
11424 .components
11425 .entry("my-card".to_string())
11426 .or_default();
11427 component.css = ".card{color:red}".to_string();
11428 component.css_href = "/my-card.css".to_string();
11429 component.uses_shadow_dom = true;
11430 protocol.populate_style_closures(&["index.html"]);
11431
11432 let mut writer = TestWriter::new();
11433 handle(
11434 &protocol,
11435 &test_json!({}),
11436 &RenderOptions::new("index.html", "/"),
11437 &mut writer,
11438 )
11439 .unwrap();
11440 let html = writer.get_content();
11441 match strategy {
11442 webui_protocol::CssStrategy::Link => {
11443 let escaped_href = crate::html_encode::encode_safe("/my-card.css");
11444 assert!(html.contains(&format!(
11445 "<template shadowrootmode=\"open\"><link rel=\"stylesheet\" href=\"{escaped_href}\" data-webui-resource=\"my-card\" data-webui-strategy=\"link\">"
11446 )));
11447 }
11448 webui_protocol::CssStrategy::Module => {
11449 assert!(html.contains(
11450 "<template shadowrootmode=\"open\"><style data-webui-resource=\"my-card\" data-webui-strategy=\"module\">.card{color:red}</style>"
11451 ));
11452 assert_eq!(html.matches("<script type=\"importmap\"").count(), 1);
11453 }
11454 webui_protocol::CssStrategy::Style => unreachable!(),
11455 }
11456 }
11457 }
11458
11459 #[test]
11460 fn test_module_preloads_emit_in_head_in_compiler_order() {
11461 let mut fragments = HashMap::new();
11465 fragments.insert(
11466 "index.html".to_string(),
11467 FragmentList {
11468 fragments: vec![
11469 WebUIFragment::raw(
11470 r#"<html><head><script type="module" async src="/index.js"></script>"#
11471 .to_string(),
11472 ),
11473 structural_fragment("head_end"),
11474 WebUIFragment::raw("</head><body>".to_string()),
11475 structural_fragment("body_end"),
11476 WebUIFragment::raw("</body></html>".to_string()),
11477 ],
11478 contains_boundary: false,
11479 },
11480 );
11481
11482 let mut protocol = WebUIProtocol::new(fragments);
11483 protocol.module_preloads = vec!["/chunk-big.js".to_string(), "/chunk-small.js".to_string()];
11484
11485 let state = test_json!({});
11486 let mut writer = TestWriter::new();
11487 handle(
11488 &protocol,
11489 &state,
11490 &RenderOptions::new("index.html", "/"),
11491 &mut writer,
11492 )
11493 .unwrap();
11494
11495 let html = writer.get_content();
11496 let head_end = html.find("</head>").expect("</head> missing");
11497 let head = &html[..head_end];
11498 assert!(
11499 head.contains(
11500 r#"<link rel="modulepreload" href="/chunk-big.js"><link rel="modulepreload" href="/chunk-small.js">"#
11501 ),
11502 "hints must appear in <head> in the compiler's order: {html}"
11503 );
11504 }
11505
11506 #[test]
11507 fn test_no_module_preloads_emits_nothing() {
11508 let mut fragments = HashMap::new();
11509 fragments.insert(
11510 "index.html".to_string(),
11511 FragmentList {
11512 fragments: vec![
11513 WebUIFragment::raw("<html><head>".to_string()),
11514 structural_fragment("head_end"),
11515 WebUIFragment::raw("</head><body>".to_string()),
11516 structural_fragment("body_end"),
11517 WebUIFragment::raw("</body></html>".to_string()),
11518 ],
11519 contains_boundary: false,
11520 },
11521 );
11522 let protocol = WebUIProtocol::new(fragments);
11523
11524 let state = test_json!({});
11525 let mut writer = TestWriter::new();
11526 handle(
11527 &protocol,
11528 &state,
11529 &RenderOptions::new("index.html", "/"),
11530 &mut writer,
11531 )
11532 .unwrap();
11533
11534 assert!(
11535 !writer.get_content().contains("modulepreload"),
11536 "a build without hints must be byte-identical to before"
11537 );
11538 }
11539
11540 #[test]
11541 fn test_link_strategy_head_links_follow_document_order() {
11542 let mut fragments = HashMap::new();
11547 fragments.insert(
11548 "index.html".to_string(),
11549 FragmentList {
11550 fragments: vec![
11551 WebUIFragment::raw("<html><head>".to_string()),
11552 structural_fragment("head_end"),
11553 WebUIFragment::raw("</head><body><z-widget>".to_string()),
11554 WebUIFragment::component("z-widget"),
11555 WebUIFragment::raw("</z-widget><a-widget>".to_string()),
11556 WebUIFragment::component("a-widget"),
11557 WebUIFragment::raw("</a-widget>".to_string()),
11558 structural_fragment("body_end"),
11559 WebUIFragment::raw("</body></html>".to_string()),
11560 ],
11561 contains_boundary: false,
11562 },
11563 );
11564 fragments.insert(
11565 "z-widget".to_string(),
11566 FragmentList {
11567 fragments: vec![WebUIFragment::raw("<div>z</div>".to_string())],
11568 contains_boundary: false,
11569 },
11570 );
11571 fragments.insert(
11572 "a-widget".to_string(),
11573 FragmentList {
11574 fragments: vec![WebUIFragment::raw("<div>a</div>".to_string())],
11575 contains_boundary: false,
11576 },
11577 );
11578
11579 let mut protocol = WebUIProtocol::new(fragments);
11580 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11581
11582 let z = protocol
11583 .components
11584 .entry("z-widget".to_string())
11585 .or_default();
11586 z.css_href = "z-widget.css".to_string();
11587 z.template_json = r#"{"h":"<div>z</div>"}"#.to_string();
11588
11589 let a = protocol
11590 .components
11591 .entry("a-widget".to_string())
11592 .or_default();
11593 a.css_href = "a-widget.css".to_string();
11594 a.template_json = r#"{"h":"<div>a</div>"}"#.to_string();
11595 protocol.populate_style_closures(&["index.html"]);
11596
11597 let state = test_json!({});
11598 let mut writer = TestWriter::new();
11599
11600 handle(
11601 &protocol,
11602 &state,
11603 &RenderOptions::new("index.html", "/"),
11604 &mut writer,
11605 )
11606 .unwrap();
11607
11608 let html = writer.get_content();
11609 let head_end = html.find("</head>").expect("</head> missing");
11610 let head_section = &html[..head_end];
11611
11612 let z_pos = head_section
11613 .find(r#"<link rel="stylesheet" href="z-widget.css""#)
11614 .expect("z-widget stylesheet link missing from <head>");
11615 let a_pos = head_section
11616 .find(r#"<link rel="stylesheet" href="a-widget.css""#)
11617 .expect("a-widget stylesheet link missing from <head>");
11618
11619 assert!(
11620 z_pos < a_pos,
11621 "CSS <link> tags must follow document order (z-widget before \
11622 a-widget), not alphabetical order: {html}"
11623 );
11624 }
11625
11626 #[test]
11627 fn test_css_module_emitted_in_component_light_dom() {
11628 let mut fragments = HashMap::new();
11632 fragments.insert(
11633 "index.html".to_string(),
11634 FragmentList {
11635 fragments: vec![
11636 WebUIFragment::raw("<html><head>".to_string()),
11637 structural_fragment("head_end"),
11638 WebUIFragment::raw("</head><body><my-card>".to_string()),
11639 WebUIFragment::component("my-card"),
11640 WebUIFragment::raw("</my-card>".to_string()),
11641 WebUIFragment::raw("</body></html>".to_string()),
11642 ],
11643 contains_boundary: false,
11644 },
11645 );
11646 fragments.insert(
11647 "my-card".to_string(),
11648 FragmentList {
11649 fragments: vec![WebUIFragment::raw(r#"<p>hi</p>"#.to_string())],
11650 contains_boundary: false,
11651 },
11652 );
11653
11654 let mut protocol = WebUIProtocol::new(fragments);
11655 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11656 protocol
11657 .components
11658 .entry("my-card".to_string())
11659 .or_default()
11660 .css = "p{color:red}".to_string();
11661 protocol.populate_style_closures(&["index.html"]);
11662 let state = test_json!({});
11663 let mut writer = TestWriter::new();
11664
11665 handle(
11666 &protocol,
11667 &state,
11668 &RenderOptions::new("index.html", "/"),
11669 &mut writer,
11670 )
11671 .unwrap();
11672
11673 let html = writer.get_content();
11674
11675 let tag_open = html.find("<my-card>").expect("<my-card> missing");
11677 let css_pos = html
11678 .find(r#"<script type="importmap""#)
11679 .expect("CSS module importmap missing");
11680 let tag_close = html.rfind("</my-card>").expect("</my-card> missing");
11681 assert!(
11682 css_pos > tag_open && css_pos < tag_close,
11683 "CSS module should be inside component light DOM: {html}"
11684 );
11685
11686 let head_end = html.find("</head>").expect("</head> missing");
11688 assert!(
11689 css_pos > head_end,
11690 "CSS module should not be in <head>: {html}"
11691 );
11692 }
11693
11694 #[test]
11695 fn styled_protocol_without_closures_is_rejected_for_full_ssr() {
11696 let mut protocol = WebUIProtocol::default();
11697 protocol.fragments = HashMap::from([
11698 (
11699 "index.html".to_string(),
11700 FragmentList {
11701 fragments: vec![
11702 WebUIFragment::raw("<legacy-card>"),
11703 WebUIFragment::component("legacy-card"),
11704 WebUIFragment::raw("</legacy-card>"),
11705 ],
11706 contains_boundary: false,
11707 },
11708 ),
11709 (
11710 "legacy-card".to_string(),
11711 FragmentList {
11712 fragments: vec![WebUIFragment::raw("<p>Legacy</p>")],
11713 contains_boundary: false,
11714 },
11715 ),
11716 ]);
11717 protocol.components.insert(
11718 "legacy-card".to_string(),
11719 webui_protocol::ComponentData {
11720 css: ".legacy{color:red}".to_string(),
11721 ..Default::default()
11722 },
11723 );
11724 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11725 let mut writer = TestWriter::new();
11726
11727 let error = handle(
11728 &protocol,
11729 &test_json!({}),
11730 &RenderOptions::new("index.html", "/"),
11731 &mut writer,
11732 )
11733 .expect_err("styled protocols require closure metadata");
11734 assert!(error
11735 .to_string()
11736 .contains("component style closure metadata is required"));
11737 }
11738
11739 #[test]
11740 fn test_css_module_emitted_for_route_components() {
11741 let template = r#"<h1>Dashboard</h1>"#;
11743
11744 let mut fragments = HashMap::new();
11745 fragments.insert(
11746 "index.html".to_string(),
11747 FragmentList {
11748 fragments: vec![
11749 WebUIFragment::raw("<html><head>".to_string()),
11750 structural_fragment("head_end"),
11751 WebUIFragment::raw("</head><body>".to_string()),
11752 WebUIFragment::route("/", "dash-page"),
11753 structural_fragment("body_end"),
11754 WebUIFragment::raw("</body></html>".to_string()),
11755 ],
11756 contains_boundary: false,
11757 },
11758 );
11759 fragments.insert(
11760 "dash-page".to_string(),
11761 FragmentList {
11762 fragments: vec![WebUIFragment::raw(template.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 let comp = protocol
11770 .components
11771 .entry("dash-page".to_string())
11772 .or_default();
11773 comp.css = "h1{font-size:2rem}".to_string();
11774 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
11775 protocol.populate_style_closures(&["index.html"]);
11776 let state = test_json!({});
11777 let mut writer = TestWriter::new();
11778
11779 handle(
11780 &protocol,
11781 &state,
11782 &RenderOptions::new("index.html", "/"),
11783 &mut writer,
11784 )
11785 .unwrap();
11786
11787 let html = writer.get_content();
11788
11789 assert!(
11790 html.contains(r#""dash-page":"data:text/css,h1{font-size:2rem}""#),
11791 "Route component should have CSS module importmap with data: URI: {html}"
11792 );
11793 assert!(
11794 html.contains("<h1>Dashboard</h1>"),
11795 "Route component should render content: {html}"
11796 );
11797 let fallback = html
11798 .find(r#"data-webui-resource="dash-page" data-webui-strategy="module""#)
11799 .expect("route Module fallback");
11800 let host_open = html.find("<dash-page>").expect("route host");
11801 let content = html.find("<h1>Dashboard</h1>").expect("route content");
11802 assert!(
11803 fallback < host_open && host_open < content,
11804 "the Module fallback must precede the generated Light route host: {html}"
11805 );
11806 let importmap = html
11812 .find(r#"<script type="importmap""#)
11813 .expect("route CSS module importmap");
11814 assert!(
11815 host_open < importmap,
11816 "the CSS module importmap must live inside the route host, not as a route child: {html}"
11817 );
11818 }
11819
11820 #[test]
11821 fn test_head_css_link_skipped_for_components_without_css() {
11822 let mut fragments = HashMap::new();
11825 fragments.insert(
11826 "index.html".to_string(),
11827 FragmentList {
11828 fragments: vec![
11829 WebUIFragment::raw("<html><head>".to_string()),
11830 structural_fragment("head_end"),
11831 WebUIFragment::raw("</head><body>".to_string()),
11832 WebUIFragment::component("has-css"),
11833 WebUIFragment::component("no-css"),
11834 structural_fragment("body_end"),
11835 WebUIFragment::raw("</body></html>".to_string()),
11836 ],
11837 contains_boundary: false,
11838 },
11839 );
11840 fragments.insert(
11841 "has-css".to_string(),
11842 FragmentList {
11843 fragments: vec![WebUIFragment::raw("<p>styled</p>".to_string())],
11844 contains_boundary: false,
11845 },
11846 );
11847 fragments.insert(
11848 "no-css".to_string(),
11849 FragmentList {
11850 fragments: vec![WebUIFragment::raw("<p>plain</p>".to_string())],
11851 contains_boundary: false,
11852 },
11853 );
11854
11855 let mut protocol = WebUIProtocol::new(fragments);
11856 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
11857
11858 protocol
11860 .components
11861 .entry("has-css".to_string())
11862 .or_default()
11863 .css_href = "has-css.css".to_string();
11864 protocol
11865 .components
11866 .entry("no-css".to_string())
11867 .or_default()
11868 .template_json = r#"{"h":"<p>plain</p>"}"#.to_string();
11869 protocol.populate_style_closures(&["index.html"]);
11870
11871 let state = test_json!({});
11872 let mut writer = TestWriter::new();
11873
11874 let handler = WebUIHandler::with_plugin(|| {
11875 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11876 });
11877 handler
11878 .handle(
11879 &protocol,
11880 &state,
11881 &RenderOptions::new("index.html", "/"),
11882 &mut writer,
11883 )
11884 .unwrap();
11885
11886 let html = writer.get_content();
11887 assert!(
11888 html.contains(r#"<link rel="stylesheet" href="has-css.css""#),
11889 "Component with CSS should get a <link rel=stylesheet> in <head>: {html}"
11890 );
11891 assert!(
11892 !html.contains("no-css.css"),
11893 "Component without CSS must NOT get a <link> in <head>: {html}"
11894 );
11895 }
11896
11897 #[test]
11898 fn test_reachable_unrendered_components_get_templates_and_css_but_not_inventory() {
11899 let mut fragments = HashMap::new();
11907 fragments.insert(
11908 "index.html".to_string(),
11909 FragmentList {
11910 fragments: vec![
11911 WebUIFragment::raw("<html><head>".to_string()),
11912 structural_fragment("head_end"),
11913 WebUIFragment::raw("</head><body><app-shell>".to_string()),
11914 WebUIFragment::component("app-shell"),
11915 WebUIFragment::raw("</app-shell>".to_string()),
11916 structural_fragment("body_end"),
11917 WebUIFragment::raw("</body></html>".to_string()),
11918 ],
11919 contains_boundary: false,
11920 },
11921 );
11922 fragments.insert(
11924 "app-shell".to_string(),
11925 FragmentList {
11926 fragments: vec![
11927 WebUIFragment::raw("<div>Shell</div>".to_string()),
11928 WebUIFragment::component("cart-panel"),
11929 ],
11930 contains_boundary: false,
11931 },
11932 );
11933 fragments.insert(
11935 "cart-panel".to_string(),
11936 FragmentList {
11937 fragments: vec![
11938 WebUIFragment::raw("<aside>".to_string()),
11939 WebUIFragment::if_cond(ConditionExpr::identifier("hasItems"), "cart-items"),
11940 WebUIFragment::raw("</aside>".to_string()),
11941 ],
11942 contains_boundary: false,
11943 },
11944 );
11945 fragments.insert(
11947 "cart-items".to_string(),
11948 FragmentList {
11949 fragments: vec![WebUIFragment::component("product-card")],
11950 contains_boundary: false,
11951 },
11952 );
11953 fragments.insert(
11954 "product-card".to_string(),
11955 FragmentList {
11956 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
11957 contains_boundary: false,
11958 },
11959 );
11960
11961 let mut protocol = WebUIProtocol::new(fragments);
11962 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
11963 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
11964 for name in ["app-shell", "cart-panel", "product-card"] {
11965 let comp = protocol.components.entry(name.to_string()).or_default();
11966 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
11967 comp.css = format!(".{name}{{display:block}}");
11968 if name == "cart-panel" {
11969 comp.hydration_mode = StateProjectionMode::Keys as i32;
11970 comp.hydration_keys = vec!["hasItems".to_string()];
11971 }
11972 if name == "product-card" {
11973 comp.template_functions = r#"[function(v,s){return !!v("ready",s)}]"#.to_string();
11974 }
11975 }
11976 protocol.populate_style_closures(&["index.html"]);
11977
11978 let state = test_json!({ "hasItems": false });
11980 let mut writer = TestWriter::new();
11981
11982 let handler = WebUIHandler::with_plugin(|| {
11983 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
11984 });
11985 handler
11986 .handle(
11987 &protocol,
11988 &state,
11989 &RenderOptions::new("index.html", "/"),
11990 &mut writer,
11991 )
11992 .unwrap();
11993
11994 let html = writer.get_content();
11995
11996 assert!(
11997 html.contains(r#"<script type="application/json" id="webui-data">"#),
11998 "non-executable SSR metadata should be emitted in the webui-data block: {html}"
11999 );
12000 assert!(
12001 html.contains(r#""state":{"hasItems":false}"#),
12002 "SSR state should live in the JSON data block: {html}"
12003 );
12004 assert!(
12005 html.contains(r#""inventory":"#),
12006 "SSR inventory should live in the JSON data block: {html}"
12007 );
12008 assert!(
12009 !html.contains("window.__webui={\""),
12010 "executable bootstrap must not embed the window.__webui JSON literal: {html}"
12011 );
12012 assert!(
12013 !html.contains(r#"document.getElementById("webui-data")"#),
12014 "SSR must not parse webui-data; client packages own that lazy load: {html}"
12015 );
12016 assert!(
12017 !html.contains("window.__webui=w;"),
12018 "executable bootstrap must not replace existing window.__webui registrations: {html}"
12019 );
12020 assert!(
12021 !html.contains("w.templateFns={\""),
12022 "template function emission must not replace existing templateFns registrations: {html}"
12023 );
12024 assert!(
12025 html.contains(r#"var f=w.templateFns||(w.templateFns={});f["product-card"]=[function(v,s){return !!v("ready",s)}];"#),
12026 "template functions should merge into the flat templateFns registry: {html}"
12027 );
12028
12029 assert!(
12032 html.contains(r#""product-card":{"h":"<div class=\"product-card\"><\/div>"}"#),
12033 "product-card template should be emitted even when unrendered: {html}"
12034 );
12035
12036 assert!(
12039 html.contains(r#""product-card":"data:text/css,"#),
12040 "reachable product-card CSS module importmap should be emitted: {html}"
12041 );
12042
12043 assert!(
12045 html.contains(r#""app-shell":{"h":"<div class=\"app-shell\"><\/div>"}"#),
12046 "rendered app-shell template should be emitted: {html}"
12047 );
12048 assert!(
12049 html.contains(r#""cart-panel":{"h":"<div class=\"cart-panel\"><\/div>"}"#),
12050 "rendered cart-panel template should be emitted: {html}"
12051 );
12052 }
12053
12054 #[test]
12055 fn component_asset_manifest_falls_back_to_body_start() {
12056 let fragments = HashMap::from([(
12057 "index.html".to_string(),
12058 FragmentList {
12059 fragments: vec![
12060 WebUIFragment::raw("<body>".to_string()),
12061 structural_fragment("body_start"),
12062 WebUIFragment::raw("<main>Body-only host</main></body>".to_string()),
12063 ],
12064 contains_boundary: false,
12065 },
12066 )]);
12067 let mut protocol = WebUIProtocol::new(fragments);
12068 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12069 root: "lazy-panel".to_string(),
12070 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12071 }];
12072 let mut writer = TestWriter::new();
12073
12074 handle(
12075 &protocol,
12076 &Value::Null,
12077 &RenderOptions::new("index.html", "/"),
12078 &mut writer,
12079 )
12080 .unwrap();
12081
12082 assert_eq!(
12083 writer.get_content(),
12084 r#"<body><script type="application/json" id="webui-component-assets">{"lazy-panel":["/assets/lazy-panel.css"]}</script><main>Body-only host</main></body>"#
12085 );
12086 }
12087
12088 #[test]
12089 fn component_asset_styles_emit_once_when_body_precedes_head() {
12090 let fragments = HashMap::from([(
12091 "index.html".to_string(),
12092 FragmentList {
12093 fragments: vec![
12094 WebUIFragment::raw("<body>".to_string()),
12095 structural_fragment("body_start"),
12096 WebUIFragment::raw("</body><head>".to_string()),
12097 structural_fragment("head_end"),
12098 WebUIFragment::raw("</head>".to_string()),
12099 ],
12100 contains_boundary: false,
12101 },
12102 )]);
12103 let mut protocol = WebUIProtocol::new(fragments);
12104 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
12105 root: "lazy-panel".to_string(),
12106 style_hrefs: vec!["/assets/lazy-panel.css".to_string()],
12107 }];
12108 let mut writer = TestWriter::new();
12109
12110 handle(
12111 &protocol,
12112 &Value::Null,
12113 &RenderOptions::new("index.html", "/"),
12114 &mut writer,
12115 )
12116 .unwrap();
12117
12118 assert_eq!(
12119 writer
12120 .get_content()
12121 .matches(r#"id="webui-component-assets""#)
12122 .count(),
12123 1
12124 );
12125 }
12126
12127 #[test]
12128 fn light_component_asset_styles_apply_at_head_end() {
12129 let fragments = HashMap::from([(
12130 "index.html".to_string(),
12131 FragmentList {
12132 fragments: vec![
12133 WebUIFragment::raw("<html><head>".to_string()),
12134 structural_fragment("head_end"),
12135 WebUIFragment::raw("</head><body></body></html>".to_string()),
12136 ],
12137 contains_boundary: false,
12138 },
12139 )]);
12140 let mut protocol = WebUIProtocol::new(fragments);
12141 protocol.set_css_strategy(webui_protocol::CssStrategy::Link);
12142 for root in ["lazy-panel", "secondary-panel"] {
12143 protocol.components.insert(
12144 root.to_string(),
12145 webui_protocol::ComponentData {
12146 uses_shadow_dom: false,
12147 ..Default::default()
12148 },
12149 );
12150 }
12151 protocol.component_asset_style_preloads = vec![
12152 ComponentAssetStylePreload {
12153 root: "lazy-panel".to_string(),
12154 style_hrefs: vec![
12155 "/assets/lazy-panel.css".to_string(),
12156 "/assets/shared.css".to_string(),
12157 ],
12158 },
12159 ComponentAssetStylePreload {
12160 root: "secondary-panel".to_string(),
12161 style_hrefs: vec![
12162 "/assets/secondary-panel.css".to_string(),
12163 "/assets/shared.css".to_string(),
12164 ],
12165 },
12166 ];
12167 let mut writer = TestWriter::new();
12168
12169 handle(
12170 &protocol,
12171 &Value::Null,
12172 &RenderOptions::new("index.html", "/"),
12173 &mut writer,
12174 )
12175 .unwrap();
12176
12177 let html = writer.get_content();
12178 assert_eq!(html.matches(r#"rel="stylesheet""#).count(), 3);
12179 assert_eq!(html.matches("/assets/shared.css").count(), 1);
12180 assert!(!html.contains(COMPONENT_ASSET_MANIFEST_ID));
12181 let links_end = html
12182 .find(r#"<link rel="stylesheet" href="/assets/secondary-panel.css">"#)
12183 .expect("asset stylesheet must be emitted");
12184 let head_end = html.find("</head>").expect("head must close");
12185 assert!(links_end < head_end);
12186 }
12187
12188 #[test]
12198 fn test_css_module_emits_nonce_attribute_when_nonce_set() {
12199 let template = r#"<h1>Dashboard</h1>"#;
12201
12202 let mut fragments = HashMap::new();
12203 fragments.insert(
12204 "index.html".to_string(),
12205 FragmentList {
12206 fragments: vec![
12207 WebUIFragment::raw("<html><head>".to_string()),
12208 structural_fragment("head_end"),
12209 WebUIFragment::raw("</head><body>".to_string()),
12210 WebUIFragment::route("/", "dash-page"),
12211 structural_fragment("body_end"),
12212 WebUIFragment::raw("</body></html>".to_string()),
12213 ],
12214 contains_boundary: false,
12215 },
12216 );
12217 fragments.insert(
12218 "dash-page".to_string(),
12219 FragmentList {
12220 fragments: vec![WebUIFragment::raw(template.to_string())],
12221 contains_boundary: false,
12222 },
12223 );
12224
12225 let mut protocol = WebUIProtocol::new(fragments);
12226 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12227 let comp = protocol
12228 .components
12229 .entry("dash-page".to_string())
12230 .or_default();
12231 comp.css = "h1{font-size:2rem}".to_string();
12232 comp.template_json = r#"{"h":"<h1>Dashboard</h1>"}"#.to_string();
12233 protocol.populate_style_closures(&["index.html"]);
12234 let state = test_json!({});
12235 let mut writer = TestWriter::new();
12236
12237 handle(
12238 &protocol,
12239 &state,
12240 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12241 &mut writer,
12242 )
12243 .unwrap();
12244
12245 let html = writer.get_content();
12246
12247 assert!(
12248 html.contains(
12249 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
12250 ),
12251 "CSS module importmap tag should include nonce attribute in canonical order: {html}"
12252 );
12253 }
12254
12255 #[test]
12256 fn test_unrendered_css_module_emits_nonce_attribute_when_nonce_set() {
12257 let mut fragments = HashMap::new();
12262 fragments.insert(
12263 "index.html".to_string(),
12264 FragmentList {
12265 fragments: vec![
12266 WebUIFragment::raw("<html><head>".to_string()),
12267 structural_fragment("head_end"),
12268 WebUIFragment::raw("</head><body><app-shell>".to_string()),
12269 WebUIFragment::component("app-shell"),
12270 WebUIFragment::raw("</app-shell>".to_string()),
12271 structural_fragment("body_end"),
12272 WebUIFragment::raw("</body></html>".to_string()),
12273 ],
12274 contains_boundary: false,
12275 },
12276 );
12277 fragments.insert(
12278 "app-shell".to_string(),
12279 FragmentList {
12280 fragments: vec![WebUIFragment::if_cond(
12281 ConditionExpr::identifier("hasItems"),
12282 "cart-items",
12283 )],
12284 contains_boundary: false,
12285 },
12286 );
12287 fragments.insert(
12288 "cart-items".to_string(),
12289 FragmentList {
12290 fragments: vec![WebUIFragment::component("product-card")],
12291 contains_boundary: false,
12292 },
12293 );
12294 fragments.insert(
12295 "product-card".to_string(),
12296 FragmentList {
12297 fragments: vec![WebUIFragment::raw("<div>Card</div>".to_string())],
12298 contains_boundary: false,
12299 },
12300 );
12301
12302 let mut protocol = WebUIProtocol::new(fragments);
12303 protocol.set_css_strategy(webui_protocol::CssStrategy::Module);
12304 for name in ["app-shell", "product-card"] {
12305 let comp = protocol.components.entry(name.to_string()).or_default();
12306 comp.template_json = format!(r#"{{"h":"<div class=\"{name}\"></div>"}}"#);
12307 comp.css = format!(".{name}{{display:block}}");
12308 }
12309 protocol.populate_style_closures(&["index.html"]);
12310
12311 let state = test_json!({ "hasItems": false });
12314 let mut writer = TestWriter::new();
12315
12316 let handler = WebUIHandler::with_plugin(|| {
12317 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12318 });
12319 handler
12320 .handle(
12321 &protocol,
12322 &state,
12323 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
12324 &mut writer,
12325 )
12326 .unwrap();
12327
12328 let html = writer.get_content();
12329
12330 assert!(
12331 html.contains(
12332 r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="product-card">{"imports":{"product-card":"data:text/css,.product-card{display:block}"}}</script>"#
12333 ),
12334 "Unrendered (body_end) CSS module importmap tag should include nonce attribute in canonical order: {html}"
12335 );
12336 }
12337
12338 #[test]
12339 fn projected_state_excludes_non_hydration_keys() -> Result<()> {
12340 let mut fragments = HashMap::new();
12341 fragments.insert(
12342 "index.html".to_string(),
12343 FragmentList {
12344 fragments: vec![
12345 WebUIFragment::raw("<html><body><style>".to_string()),
12346 WebUIFragment::signal("tokens.light", true),
12347 WebUIFragment::raw("</style>".to_string()),
12348 structural_fragment("body_end"),
12349 WebUIFragment::raw("</body></html>".to_string()),
12350 ],
12351 contains_boundary: false,
12352 },
12353 );
12354 fragments.insert(
12355 "app-shell".to_string(),
12356 FragmentList {
12357 fragments: vec![WebUIFragment::raw("<span>shell</span>".to_string())],
12358 contains_boundary: false,
12359 },
12360 );
12361 let index_fragments = fragments
12362 .get_mut("index.html")
12363 .expect("index fixture should exist");
12364 index_fragments
12365 .fragments
12366 .insert(1, WebUIFragment::component("app-shell"));
12367 let mut protocol = WebUIProtocol::new(fragments);
12368 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12369 protocol.components.insert(
12374 "app-shell".to_string(),
12375 webui_protocol::ComponentData {
12376 hydration_mode: StateProjectionMode::Keys as i32,
12377 hydration_keys: vec!["name".to_string()],
12378 ..Default::default()
12379 },
12380 );
12381 let state = test_json!({
12382 "name": "Alice",
12383 "tokens": {
12384 "light": "--color-brand: red;"
12385 }
12386 });
12387 let mut writer = TestWriter::new();
12388 let handler = WebUIHandler::with_plugin(|| {
12389 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12390 });
12391 handler.handle(
12392 &protocol,
12393 &state,
12394 &RenderOptions::new("index.html", "/"),
12395 &mut writer,
12396 )?;
12397 let output = writer.get_content();
12398
12399 assert!(output.contains("--color-brand: red;"));
12401 assert!(output.contains(r#""name":"Alice""#));
12403 assert!(!output.contains(r#""tokens""#));
12404 Ok(())
12405 }
12406
12407 #[test]
12408 fn full_initial_strategy_preserves_complete_state() -> Result<()> {
12409 let mut fragments = HashMap::new();
12410 fragments.insert(
12411 "index.html".to_string(),
12412 FragmentList {
12413 fragments: vec![
12414 WebUIFragment::raw("<html><body>"),
12415 structural_fragment("body_end"),
12416 WebUIFragment::raw("</body></html>"),
12417 ],
12418 contains_boundary: false,
12419 },
12420 );
12421 let protocol = WebUIProtocol::new(fragments);
12422 let state = test_json!({
12423 "client": "visible",
12424 "serverOnly": "also preserved",
12425 });
12426 let handler = WebUIHandler::with_plugin(|| {
12427 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12428 });
12429 let mut writer = TestWriter::new();
12430 handler.handle(
12431 &protocol,
12432 &state,
12433 &RenderOptions::new("index.html", "/"),
12434 &mut writer,
12435 )?;
12436 let output = writer.get_content();
12437 assert!(output.contains(r#""client":"visible""#));
12438 assert!(output.contains(r#""serverOnly":"also preserved""#));
12439 Ok(())
12440 }
12441
12442 #[test]
12443 fn uncertain_hydration_surface_preserves_complete_state() -> Result<()> {
12444 let mut fragments = HashMap::new();
12445 fragments.insert(
12446 "index.html".to_string(),
12447 FragmentList {
12448 fragments: vec![
12449 WebUIFragment::raw("<html><body>"),
12450 WebUIFragment::component("app-shell"),
12451 structural_fragment("body_end"),
12452 WebUIFragment::raw("</body></html>"),
12453 ],
12454 contains_boundary: false,
12455 },
12456 );
12457 fragments.insert(
12458 "app-shell".to_string(),
12459 FragmentList {
12460 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12461 contains_boundary: false,
12462 },
12463 );
12464 let mut protocol = WebUIProtocol::new(fragments);
12465 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12466 protocol.components.insert(
12467 "app-shell".to_string(),
12468 webui_protocol::ComponentData {
12469 hydration_mode: StateProjectionMode::All as i32,
12470 ..Default::default()
12471 },
12472 );
12473 let state = test_json!({
12474 "known": "value",
12475 "possiblyInherited": "must not be dropped",
12476 });
12477 let handler = WebUIHandler::with_plugin(|| {
12478 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12479 });
12480 let mut writer = TestWriter::new();
12481 handler.handle(
12482 &protocol,
12483 &state,
12484 &RenderOptions::new("index.html", "/"),
12485 &mut writer,
12486 )?;
12487 let output = writer.get_content();
12488 assert!(output.contains(r#""known":"value""#));
12489 assert!(output.contains(r#""possiblyInherited":"must not be dropped""#));
12490 Ok(())
12491 }
12492
12493 #[test]
12494 fn missing_component_projection_metadata_preserves_complete_state() -> Result<()> {
12495 let mut fragments = HashMap::new();
12496 fragments.insert(
12497 "index.html".to_string(),
12498 FragmentList {
12499 fragments: vec![
12500 WebUIFragment::raw("<html><body>"),
12501 WebUIFragment::component("app-shell"),
12502 structural_fragment("body_end"),
12503 WebUIFragment::raw("</body></html>"),
12504 ],
12505 contains_boundary: false,
12506 },
12507 );
12508 fragments.insert(
12509 "app-shell".to_string(),
12510 FragmentList {
12511 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12512 contains_boundary: false,
12513 },
12514 );
12515 let mut protocol = WebUIProtocol::new(fragments);
12516 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12517 let state = test_json!({
12518 "known": "value",
12519 "serverOnly": "must not be dropped",
12520 });
12521 let handler = WebUIHandler::with_plugin(|| {
12522 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12523 });
12524 let mut writer = TestWriter::new();
12525 handler.handle(
12526 &protocol,
12527 &state,
12528 &RenderOptions::new("index.html", "/"),
12529 &mut writer,
12530 )?;
12531 let output = writer.get_content();
12532 assert!(output.contains(r#""known":"value""#));
12533 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12534 Ok(())
12535 }
12536
12537 #[test]
12538 fn unknown_projection_mode_preserves_complete_state() -> Result<()> {
12539 let mut fragments = HashMap::new();
12540 fragments.insert(
12541 "index.html".to_string(),
12542 FragmentList {
12543 fragments: vec![
12544 WebUIFragment::raw("<html><body>"),
12545 WebUIFragment::component("app-shell"),
12546 structural_fragment("body_end"),
12547 WebUIFragment::raw("</body></html>"),
12548 ],
12549 contains_boundary: false,
12550 },
12551 );
12552 fragments.insert(
12553 "app-shell".to_string(),
12554 FragmentList {
12555 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
12556 contains_boundary: false,
12557 },
12558 );
12559 let mut protocol = WebUIProtocol::new(fragments);
12560 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12561 protocol.components.insert(
12562 "app-shell".to_string(),
12563 webui_protocol::ComponentData {
12564 hydration_mode: i32::MAX,
12565 ..Default::default()
12566 },
12567 );
12568 let state = test_json!({
12569 "known": "value",
12570 "serverOnly": "must not be dropped",
12571 });
12572 let handler = WebUIHandler::with_plugin(|| {
12573 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12574 });
12575 let mut writer = TestWriter::new();
12576 handler.handle(
12577 &protocol,
12578 &state,
12579 &RenderOptions::new("index.html", "/"),
12580 &mut writer,
12581 )?;
12582 let output = writer.get_content();
12583 assert!(output.contains(r#""known":"value""#));
12584 assert!(output.contains(r#""serverOnly":"must not be dropped""#));
12585 Ok(())
12586 }
12587
12588 #[test]
12589 fn legacy_navigation_keys_with_default_mode_remain_keyed() {
12590 let mut protocol = WebUIProtocol::new(HashMap::new());
12591 protocol.components.insert(
12592 "app-shell".to_string(),
12593 webui_protocol::ComponentData {
12594 navigation_keys: vec!["selected".to_string()],
12595 ..Default::default()
12596 },
12597 );
12598
12599 match collect_navigation_state(&protocol, ["app-shell"]) {
12600 StateSelection::Keys(keys) => assert_eq!(keys, vec!["selected"]),
12601 StateSelection::Full
12602 | StateSelection::KeyIds(_)
12603 | StateSelection::FullExceptKeyIds(_) => {
12604 panic!("legacy navigation keys should remain owned and projected")
12605 }
12606 }
12607 }
12608
12609 #[test]
12610 fn legacy_navigation_without_projection_metadata_preserves_full_state() {
12611 let mut protocol = WebUIProtocol::new(HashMap::new());
12612 protocol.components.insert(
12613 "app-shell".to_string(),
12614 webui_protocol::ComponentData::default(),
12615 );
12616
12617 assert!(matches!(
12618 collect_navigation_state(&protocol, ["app-shell"]),
12619 StateSelection::Full
12620 ));
12621 }
12622
12623 #[test]
12624 fn empty_reachable_hydration_keys_exclude_all_state() -> Result<()> {
12625 let mut fragments = HashMap::new();
12626 fragments.insert(
12627 "index.html".to_string(),
12628 FragmentList {
12629 fragments: vec![
12630 WebUIFragment::raw("<html><body>".to_string()),
12631 structural_fragment("body_end"),
12632 WebUIFragment::raw("</body></html>".to_string()),
12633 ],
12634 contains_boundary: false,
12635 },
12636 );
12637 let mut protocol = WebUIProtocol::new(fragments);
12638 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12639 let state = test_json!({
12640 "title": "Legacy state",
12641 "serverOnly": "preserved",
12642 });
12643 let handler = WebUIHandler::with_plugin(|| {
12644 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12645 });
12646
12647 let mut writer = TestWriter::new();
12648 handler.handle(
12649 &protocol,
12650 &state,
12651 &RenderOptions::new("index.html", "/"),
12652 &mut writer,
12653 )?;
12654 assert!(writer.get_content().contains(r#""state":{}"#));
12655 assert!(!writer.get_content().contains("Legacy state"));
12656 assert!(!writer.get_content().contains("preserved"));
12657 Ok(())
12658 }
12659
12660 #[test]
12661 fn scriptless_component_state_is_navigation_only() -> Result<()> {
12662 let mut fragments = HashMap::new();
12663 fragments.insert(
12664 "index.html".to_string(),
12665 FragmentList {
12666 fragments: vec![
12667 WebUIFragment::raw("<html><body>"),
12668 WebUIFragment::component("items-page"),
12669 structural_fragment("body_end"),
12670 WebUIFragment::raw("</body></html>"),
12671 ],
12672 contains_boundary: false,
12673 },
12674 );
12675 fragments.insert(
12676 "items-page".to_string(),
12677 FragmentList {
12678 fragments: vec![WebUIFragment::raw("<p>Items</p>")],
12679 contains_boundary: false,
12680 },
12681 );
12682 let mut protocol = WebUIProtocol::new(fragments);
12683 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12684 protocol.components.insert(
12685 "items-page".to_string(),
12686 webui_protocol::ComponentData {
12687 template_json: r#"{"h":"<p>Items</p>","th":1}"#.into(),
12688 navigation_mode: Some(StateProjectionMode::Keys as i32),
12689 navigation_keys: vec!["items".into()],
12690 ..Default::default()
12691 },
12692 );
12693 let state = test_json!({
12694 "items": ["STATE_SENTINEL"],
12695 "serverOnly": "SECRET_SENTINEL",
12696 });
12697 let handler = WebUIHandler::with_plugin(|| {
12698 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12699 });
12700 let mut writer = TestWriter::new();
12701
12702 handler.handle(
12703 &protocol,
12704 &state,
12705 &RenderOptions::new("index.html", "/"),
12706 &mut writer,
12707 )?;
12708
12709 let output = writer.get_content();
12710 assert!(output.contains(r#""state":{}"#));
12711 assert!(!output.contains("STATE_SENTINEL"));
12712 assert!(!output.contains("SECRET_SENTINEL"));
12713 Ok(())
12714 }
12715
12716 #[test]
12717 fn write_selected_state_projects_and_escapes() {
12718 let state = test_json!({
12721 "drop": "secret",
12722 "keep": "</script><b>"
12723 });
12724 let keys = ["keep"];
12725 let selection = StateSelection::Keys(keys.to_vec());
12726 let mut sink = TestWriter::new();
12727 let mut scratch = Vec::new();
12728 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12729 assert_eq!(sink.get_content(), r#"{"keep":"<\/script><b>"}"#);
12730 }
12731
12732 #[test]
12733 fn write_selected_state_non_object_projection_emits_empty_object() {
12734 let state = test_json!("scalar state has nothing hydratable");
12735 let keys: [&str; 0] = [];
12736 let selection = StateSelection::Keys(keys.to_vec());
12737 let mut sink = TestWriter::new();
12738 let mut scratch = Vec::new();
12739 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12740 assert_eq!(sink.get_content(), "{}");
12741 }
12742
12743 #[test]
12744 fn write_selected_state_schema_first_skips_missing_and_duplicate_keys() {
12745 let state = test_json!({
12746 "keptA": 1,
12747 "keptB": 2,
12748 "serverOnlyA": 3,
12749 "serverOnlyB": 4,
12750 });
12751 let keys = ["keptA", "keptA", "keptB", "missing"];
12752 let selection = StateSelection::Keys(keys.to_vec());
12753 let mut sink = TestWriter::new();
12754 let mut scratch = Vec::new();
12755 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12756 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12757 }
12758
12759 #[test]
12760 fn write_selected_state_map_first_matches_schema_first_output() {
12761 let state = test_json!({
12762 "keptA": 1,
12763 "keptB": 2,
12764 });
12765 let keys = ["keptA", "keptB", "missingA", "missingB"];
12766 let selection = StateSelection::Keys(keys.to_vec());
12767 let mut sink = TestWriter::new();
12768 let mut scratch = Vec::new();
12769 write_selected_state(&mut sink, &mut scratch, &state, &selection).unwrap();
12770 assert_eq!(sink.get_content(), r#"{"keptA":1,"keptB":2}"#);
12771 }
12772
12773 #[test]
12774 fn write_selected_state_full_preserves_and_escapes_state() {
12775 let state = test_json!({
12776 "serverOnly": "</script><b>",
12777 "value": 42,
12778 });
12779 let mut sink = TestWriter::new();
12780 let mut scratch = Vec::new();
12781 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
12782 assert_eq!(
12783 sink.get_content(),
12784 r#"{"serverOnly":"<\/script><b>","value":42}"#
12785 );
12786 }
12787
12788 #[test]
12789 fn component_asset_style_manifest_is_serialized_once_and_script_safe() {
12790 let preloads = vec![ComponentAssetStylePreload {
12791 root: "lazy-panel".to_string(),
12792 style_hrefs: vec!["/assets/</script><script>alert(1)</script>.css".to_string()],
12793 }];
12794
12795 let manifest = serialize_component_asset_style_manifest(&preloads).unwrap();
12796
12797 assert_eq!(
12798 manifest,
12799 r#"{"lazy-panel":["/assets/<\/script><script>alert(1)<\/script>.css"]}"#
12800 );
12801 }
12802
12803 #[test]
12804 fn json_scratch_capacity_reused_across_checkpoint_payloads() {
12805 let mut sink = TestWriter::new();
12811 let mut scratch: Vec<u8> = Vec::new();
12812 assert_eq!(scratch.capacity(), 0);
12814
12815 let mut large = serde_json::Map::new();
12817 for index in 0..64 {
12818 large.insert(
12819 format!("component_{index:04}"),
12820 Value::String("x".repeat(48)),
12821 );
12822 }
12823 let large = Value::Object(large);
12824 write_script_safe_json(&mut sink, &mut scratch, &large).unwrap();
12825 let high_water = scratch.capacity();
12826 assert!(high_water > 0);
12827
12828 for seq in 0..8 {
12831 let small = test_json!({ "inventory": "", "state": {}, "seq": seq });
12832 write_script_safe_json(&mut sink, &mut scratch, &small).unwrap();
12833 assert_eq!(
12834 scratch.capacity(),
12835 high_water,
12836 "scratch reallocated on checkpoint {seq}"
12837 );
12838 }
12839 }
12840
12841 #[test]
12842 fn bootstrap_state_excludes_inactive_route_hydration_keys() -> Result<()> {
12843 let mut fragments = HashMap::new();
12844 fragments.insert(
12845 "index.html".to_string(),
12846 FragmentList {
12847 fragments: vec![
12848 WebUIFragment::raw("<html><body>"),
12849 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12850 path: "/".to_string(),
12851 fragment_id: "home-page".to_string(),
12852 exact: true,
12853 ..Default::default()
12854 }),
12855 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
12856 path: "/admin".to_string(),
12857 fragment_id: "admin-page".to_string(),
12858 exact: true,
12859 ..Default::default()
12860 }),
12861 structural_fragment("body_end"),
12862 WebUIFragment::raw("</body></html>"),
12863 ],
12864
12865 contains_boundary: false,
12866 },
12867 );
12868 fragments.insert(
12869 "home-page".to_string(),
12870 FragmentList {
12871 fragments: vec![WebUIFragment::raw("<p>Home</p>")],
12872 contains_boundary: false,
12873 },
12874 );
12875 fragments.insert(
12876 "admin-page".to_string(),
12877 FragmentList {
12878 fragments: vec![WebUIFragment::raw("<p>Admin</p>")],
12879 contains_boundary: false,
12880 },
12881 );
12882
12883 let mut protocol = WebUIProtocol::new(fragments);
12884 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
12885 protocol.components.insert(
12886 "home-page".to_string(),
12887 webui_protocol::ComponentData {
12888 template_json: "{}".to_string(),
12889 hydration_mode: StateProjectionMode::Keys as i32,
12890 hydration_keys: vec!["homeTitle".to_string()],
12891 ..Default::default()
12892 },
12893 );
12894 protocol.components.insert(
12895 "admin-page".to_string(),
12896 webui_protocol::ComponentData {
12897 template_json: "{}".to_string(),
12898 hydration_mode: StateProjectionMode::Keys as i32,
12899 hydration_keys: vec!["adminToken".to_string()],
12900 ..Default::default()
12901 },
12902 );
12903 let state = test_json!({
12904 "homeTitle": "Welcome",
12905 "adminToken": "TOP_SECRET_SENTINEL",
12906 });
12907 let handler = WebUIHandler::with_plugin(|| {
12908 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
12909 });
12910 let mut writer = TestWriter::new();
12911 handler.handle(
12912 &protocol,
12913 &state,
12914 &RenderOptions::new("index.html", "/"),
12915 &mut writer,
12916 )?;
12917 let output = writer.get_content();
12918 assert!(output.contains(r#""homeTitle":"Welcome""#));
12919 assert!(!output.contains("TOP_SECRET_SENTINEL"));
12920 assert!(!output.contains(r#""adminToken""#));
12921 Ok(())
12922 }
12923
12924 #[test]
12925 fn test_component_attr_name_aria() {
12926 assert_eq!(component_attr_name("aria-describedby"), "ariaDescribedBy");
12928 assert_eq!(component_attr_name("aria-labelledby"), "ariaLabelledBy");
12929 assert_eq!(
12930 component_attr_name("aria-activedescendant"),
12931 "ariaActiveDescendant"
12932 );
12933 assert_eq!(component_attr_name("aria-label"), "ariaLabel");
12934 assert_eq!(component_attr_name("aria-hidden"), "ariaHidden");
12935 }
12936
12937 #[test]
12938 fn test_component_attr_name_html_global() {
12939 assert_eq!(component_attr_name("readonly"), "readOnly");
12940 assert_eq!(component_attr_name("tabindex"), "tabIndex");
12941 assert_eq!(component_attr_name("accesskey"), "accessKey");
12942 assert_eq!(component_attr_name("contenteditable"), "contentEditable");
12943 assert_eq!(component_attr_name("crossorigin"), "crossOrigin");
12944 assert_eq!(component_attr_name("inputmode"), "inputMode");
12945 assert_eq!(component_attr_name("maxlength"), "maxLength");
12946 assert_eq!(component_attr_name("minlength"), "minLength");
12947 assert_eq!(component_attr_name("novalidate"), "noValidate");
12948 assert_eq!(component_attr_name("formaction"), "formAction");
12949 assert_eq!(component_attr_name("ismap"), "isMap");
12950 assert_eq!(component_attr_name("usemap"), "useMap");
12951 }
12952
12953 #[test]
12954 fn test_component_attr_name_strips_colon() {
12955 assert_eq!(component_attr_name(":readonly"), "readOnly");
12956 assert_eq!(component_attr_name(":aria-describedby"), "ariaDescribedBy");
12957 assert_eq!(component_attr_name(":data-title"), "dataTitle");
12958 }
12959
12960 #[test]
12961 fn test_component_attr_name_regular() {
12962 assert_eq!(component_attr_name("data-title"), "dataTitle");
12963 assert_eq!(component_attr_name("key-hyphen"), "keyHyphen");
12964 assert_eq!(component_attr_name("simple"), "simple");
12965 }
12966
12967 fn make_query_route_protocol() -> WebUIProtocol {
12970 use webui_protocol::WebUiFragmentRoute;
12971
12972 let mut fragments = HashMap::new();
12973
12974 fragments.insert(
12975 "index.html".to_string(),
12976 FragmentList {
12977 fragments: vec![WebUIFragment::route_from(WebUiFragmentRoute {
12978 path: "/".into(),
12979 fragment_id: "app-shell".into(),
12980 exact: false,
12981 children: vec![
12982 WebUiFragmentRoute {
12983 path: "compose".into(),
12984 fragment_id: "compose-page".into(),
12985 exact: true,
12986 allowed_query: "action,to,subject".into(),
12987 keep_alive: false,
12988 ..Default::default()
12989 },
12990 WebUiFragmentRoute {
12991 path: "settings".into(),
12992 fragment_id: "settings-page".into(),
12993 exact: true,
12994 keep_alive: false,
12995 ..Default::default()
12996 },
12997 ],
12998 keep_alive: false,
12999 ..Default::default()
13000 })],
13001
13002 contains_boundary: false,
13003 },
13004 );
13005
13006 fragments.insert(
13007 "app-shell".to_string(),
13008 FragmentList {
13009 fragments: vec![WebUIFragment::raw("<h1>App</h1>"), WebUIFragment::outlet()],
13010 contains_boundary: false,
13011 },
13012 );
13013 fragments.insert(
13014 "compose-page".to_string(),
13015 FragmentList {
13016 fragments: vec![WebUIFragment::raw("<p>Compose</p>")],
13017 contains_boundary: false,
13018 },
13019 );
13020 fragments.insert(
13021 "settings-page".to_string(),
13022 FragmentList {
13023 fragments: vec![WebUIFragment::raw("<p>Settings</p>")],
13024 contains_boundary: false,
13025 },
13026 );
13027
13028 WebUIProtocol::new(fragments)
13029 }
13030
13031 #[test]
13032 fn test_matched_route_omits_query_attr_from_dom() {
13033 let protocol = make_query_route_protocol();
13034 let state = test_json!({});
13035 let handler = WebUIHandler::new();
13036 let mut writer = TestWriter::new();
13037
13038 handler
13039 .handle(
13040 &protocol,
13041 &state,
13042 &RenderOptions::new("index.html", "/compose"),
13043 &mut writer,
13044 )
13045 .expect("render failed");
13046
13047 let html = writer.get_content();
13048 assert!(
13050 !html.contains(r#"query="action,to,subject""#),
13051 "query attr should not be in DOM output (moved to SSR chain JSON): {html}"
13052 );
13053 }
13054
13055 #[test]
13056 fn test_nonmatched_route_omits_query_attr_from_dom() {
13057 let protocol = make_query_route_protocol();
13058 let state = test_json!({});
13059 let handler = WebUIHandler::new();
13060 let mut writer = TestWriter::new();
13061
13062 handler
13063 .handle(
13064 &protocol,
13065 &state,
13066 &RenderOptions::new("index.html", "/settings"),
13067 &mut writer,
13068 )
13069 .expect("render failed");
13070
13071 let html = writer.get_content();
13072 assert!(
13074 !html.contains(r#"query="#),
13075 "hidden route should not have query attr: {html}"
13076 );
13077 }
13078
13079 #[test]
13080 fn test_route_without_query_has_no_query_attr() {
13081 let protocol = make_query_route_protocol();
13082 let state = test_json!({});
13083 let handler = WebUIHandler::new();
13084 let mut writer = TestWriter::new();
13085
13086 handler
13087 .handle(
13088 &protocol,
13089 &state,
13090 &RenderOptions::new("index.html", "/settings"),
13091 &mut writer,
13092 )
13093 .expect("render failed");
13094
13095 let html = writer.get_content();
13096 let settings_idx = html
13098 .find(r#"component="settings-page""#)
13099 .expect("settings route should exist");
13100 let settings_tag = &html[settings_idx.saturating_sub(60)..settings_idx + 40];
13101 assert!(
13102 !settings_tag.contains("query="),
13103 "route without allowed_query should not emit query attr: {settings_tag}"
13104 );
13105 }
13106
13107 fn build_head_body_protocol() -> WebUIProtocol {
13112 let mut fragments = HashMap::new();
13113 fragments.insert(
13114 "index.html".to_string(),
13115 FragmentList {
13116 fragments: vec![
13117 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13118 structural_fragment("head_end"),
13119 WebUIFragment::raw("</head><body>hello".to_string()),
13120 structural_fragment("body_end"),
13121 WebUIFragment::raw("</body></html>".to_string()),
13122 ],
13123 contains_boundary: false,
13124 },
13125 );
13126 WebUIProtocol::new(fragments)
13127 }
13128
13129 #[test]
13130 fn head_inject_emits_at_head_end_boundary() {
13131 let protocol = build_head_body_protocol();
13132 let state = test_json!({});
13133 let mut writer = TestWriter::new();
13134 let opts = RenderOptions::new("index.html", "/").with_head_inject("<link rel=preload>");
13135 handle(&protocol, &state, &opts, &mut writer).unwrap();
13136 let html = writer.get_content();
13137 let inject_idx = html
13139 .find("<link rel=preload>")
13140 .expect("inject HTML missing");
13141 let head_close_idx = html.find("</head>").expect("</head> missing");
13142 assert!(
13143 inject_idx < head_close_idx,
13144 "head_inject must appear before </head>: {html}"
13145 );
13146 assert_eq!(html.matches("<link rel=preload>").count(), 1);
13148 }
13149
13150 #[test]
13151 fn component_render_policy_css_emits_once_in_head_with_nonce() {
13152 let mut protocol = build_head_body_protocol();
13153 protocol.component_render_css = concat!(
13154 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13155 "contain-intrinsic-block-size:auto 18rem;}"
13156 )
13157 .to_string();
13158 let state = test_json!({});
13159 let mut writer = TestWriter::new();
13160 let opts = RenderOptions::new("index.html", "/").with_nonce("test-nonce");
13161 handle(&protocol, &state, &opts, &mut writer).unwrap();
13162 let html = writer.get_content();
13163
13164 let style = concat!(
13165 r#"<style data-webui-render-policy nonce="test-nonce">"#,
13166 r#"lazy-card:not([w-render="eager"]){content-visibility:auto;"#,
13167 "contain-intrinsic-block-size:auto 18rem;}</style>"
13168 );
13169 let style_index = html.find(style).expect("render policy style missing");
13170 let head_close = html.find("</head>").expect("</head> missing");
13171 assert!(style_index < head_close);
13172 assert_eq!(html.matches("data-webui-render-policy").count(), 1);
13173 }
13174
13175 #[test]
13176 fn body_inject_emits_at_body_end_boundary() {
13177 let protocol = build_head_body_protocol();
13178 let state = test_json!({});
13179 let mut writer = TestWriter::new();
13180 let opts = RenderOptions::new("index.html", "/").with_body_inject("<script>lr</script>");
13181 handle(&protocol, &state, &opts, &mut writer).unwrap();
13182 let html = writer.get_content();
13183 let inject_idx = html
13184 .find("<script>lr</script>")
13185 .expect("inject HTML missing");
13186 let body_close_idx = html.find("</body>").expect("</body> missing");
13187 assert!(
13188 inject_idx < body_close_idx,
13189 "body_inject must appear before </body>: {html}"
13190 );
13191 assert_eq!(html.matches("<script>lr</script>").count(), 1);
13192 }
13193
13194 fn build_all_boundaries_protocol() -> WebUIProtocol {
13199 let mut fragments = HashMap::new();
13200 fragments.insert(
13201 "index.html".to_string(),
13202 FragmentList {
13203 fragments: vec![
13204 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13205 structural_fragment("head_end"),
13206 WebUIFragment::raw("</head><body>".to_string()),
13207 structural_fragment("body_start"),
13208 WebUIFragment::raw("hello".to_string()),
13209 structural_fragment("body_end"),
13210 WebUIFragment::raw("</body></html>".to_string()),
13211 ],
13212 contains_boundary: false,
13213 },
13214 );
13215 WebUIProtocol::new(fragments)
13216 }
13217
13218 fn state_inject_options<'a>() -> RenderOptions<'a> {
13219 RenderOptions::new("index.html", "/")
13220 }
13221
13222 fn render_with(protocol: &WebUIProtocol, state: &Value, options: &RenderOptions<'_>) -> String {
13223 let mut writer = TestWriter::new();
13224 handle(protocol, state, options, &mut writer).unwrap();
13225 writer.get_content().to_string()
13226 }
13227
13228 #[test]
13229 fn state_inject_emits_at_every_structural_boundary() {
13230 let protocol = build_all_boundaries_protocol();
13231 let state = test_json!({
13232 "$webui": {
13233 "headEnd": "<meta name=he>",
13234 "bodyStart": "<span id=bs></span>",
13235 "bodyEnd": "<script>be</script>",
13236 }
13237 });
13238 let html = render_with(&protocol, &state, &state_inject_options());
13239
13240 let head_end = html.find("<meta name=he>").expect("headEnd missing");
13241 let head_close = html.find("</head>").expect("</head> missing");
13242 let body_open = html.find("<body>").expect("<body> missing");
13243 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
13244 let hello = html.find("hello").expect("body content missing");
13245 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
13246 let body_close = html.find("</body>").expect("</body> missing");
13247
13248 assert!(
13249 head_end < head_close,
13250 "headEnd must precede </head>: {html}"
13251 );
13252 assert!(
13253 body_open < body_start && body_start < hello,
13254 "bodyStart must sit immediately after <body>: {html}"
13255 );
13256 assert!(
13257 hello < body_end && body_end < body_close,
13258 "bodyEnd must precede </body>: {html}"
13259 );
13260
13261 for needle in [
13262 "<meta name=he>",
13263 "<span id=bs></span>",
13264 "<script>be</script>",
13265 ] {
13266 assert_eq!(html.matches(needle).count(), 1, "duplicated {needle}");
13267 }
13268 }
13269
13270 #[test]
13271 fn state_inject_follows_render_options_inject() {
13272 let protocol = build_all_boundaries_protocol();
13273 let state = test_json!({
13274 "$webui": { "headEnd": "<!--state-he-->", "bodyEnd": "<!--state-be-->" }
13275 });
13276 let options = RenderOptions::new("index.html", "/")
13277 .with_head_inject("<!--opt-he-->")
13278 .with_body_inject("<!--opt-be-->");
13279 let html = render_with(&protocol, &state, &options);
13280
13281 let opt_he = html.find("<!--opt-he-->").expect("option headEnd missing");
13282 let state_he = html.find("<!--state-he-->").expect("state headEnd missing");
13283 let opt_be = html.find("<!--opt-be-->").expect("option bodyEnd missing");
13284 let state_be = html.find("<!--state-be-->").expect("state bodyEnd missing");
13285
13286 assert!(
13287 opt_he < state_he,
13288 "RenderOptions head_inject must precede the state-supplied value: {html}"
13289 );
13290 assert!(
13291 opt_be < state_be,
13292 "RenderOptions body_inject must precede the state-supplied value: {html}"
13293 );
13294 }
13295
13296 #[test]
13297 fn malformed_state_inject_values_are_inert() {
13298 let protocol = build_all_boundaries_protocol();
13299 for state in [
13302 test_json!({}),
13303 test_json!({ "$webui": "not-an-object" }),
13304 test_json!({ "$webui": [] }),
13305 test_json!({ "$webui": null }),
13306 test_json!({ "$webui": { "headEnd": null, "bodyStart": "", "bodyEnd": 42 } }),
13307 test_json!({ "$webui": { "unknownMember": "<b>x</b>" } }),
13308 ] {
13309 let html = render_with(&protocol, &state, &state_inject_options());
13310 assert!(
13311 html.contains("<html><head><title>x</title></head><body>hello</body></html>"),
13312 "malformed reserved state must render the document unchanged: {html}"
13313 );
13314 assert!(!html.contains("<b>x</b>"), "unknown member leaked: {html}");
13315 assert!(!html.contains("42"), "non-string member leaked: {html}");
13316 }
13317 }
13318
13319 #[test]
13320 fn state_inject_never_reaches_the_hydration_payload() {
13321 let protocol = build_all_boundaries_protocol();
13322 let state = test_json!({
13323 "visible": "keep",
13324 "$webui": { "bodyEnd": "<script>secret</script>" }
13325 });
13326 let handler = WebUIHandler::with_plugin(|| {
13327 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13328 });
13329 let runtime = Protocol::new(protocol.clone());
13330 let mut writer = TestWriter::new();
13331 handler
13332 .render(&runtime, &state, &state_inject_options(), &mut writer)
13333 .unwrap();
13334 let html = writer.get_content();
13335
13336 let data_start = html
13337 .find(r#"<script type="application/json" id="webui-data""#)
13338 .expect("hydration block missing");
13339 let data_end = html[data_start..]
13340 .find("</script>")
13341 .map(|offset| data_start + offset)
13342 .expect("hydration block never closes");
13343 let payload = &html[data_start..data_end];
13344 assert!(
13345 !payload.contains("$webui"),
13346 "reserved key must be stripped from the hydration payload: {payload}"
13347 );
13348 assert!(
13349 payload.contains("visible"),
13350 "ordinary state must survive the filter: {payload}"
13351 );
13352 let inject = html
13355 .find("<script>secret</script>")
13356 .expect("inject missing");
13357 let body_close = html.find("</body>").expect("</body> missing");
13358 assert!(
13359 data_end < inject && inject < body_close,
13360 "state bodyEnd must follow the hydration block and precede </body>: {html}"
13361 );
13362 }
13363
13364 #[test]
13365 fn projected_hydration_strips_reserved_state_inject_key() {
13366 let mut fragments = HashMap::new();
13367 fragments.insert(
13368 "index.html".to_string(),
13369 FragmentList {
13370 fragments: vec![
13371 WebUIFragment::raw("<html><body>"),
13372 WebUIFragment::component("app-shell"),
13373 structural_fragment("body_end"),
13374 WebUIFragment::raw("</body></html>"),
13375 ],
13376 contains_boundary: false,
13377 },
13378 );
13379 fragments.insert(
13380 "app-shell".to_string(),
13381 FragmentList {
13382 fragments: vec![WebUIFragment::raw("<p>Shell</p>")],
13383 contains_boundary: false,
13384 },
13385 );
13386 let mut document = WebUIProtocol::new(fragments);
13387 document.initial_state_strategy = InitialStateStrategy::Components as i32;
13388 document.components.insert(
13389 "app-shell".to_string(),
13390 webui_protocol::ComponentData {
13391 hydration_mode: StateProjectionMode::Keys as i32,
13392 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "visible".to_string()],
13393 ..Default::default()
13394 },
13395 );
13396 let state = test_json!({
13397 "$webui": { "bodyEnd": "<script>secret</script>" },
13398 "serverOnly": "drop",
13399 "visible": "keep",
13400 });
13401 let handler = WebUIHandler::with_plugin(|| {
13402 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
13403 });
13404 let mut writer = TestWriter::new();
13405
13406 handler
13407 .render(
13408 &Protocol::new(document),
13409 &state,
13410 &RenderOptions::new("index.html", "/"),
13411 &mut writer,
13412 )
13413 .unwrap();
13414 let html = writer.get_content();
13415 let data_start = html
13416 .find(r#"<script type="application/json" id="webui-data""#)
13417 .expect("hydration block missing");
13418 let data_end = html[data_start..]
13419 .find("</script>")
13420 .map(|offset| data_start + offset)
13421 .expect("hydration block never closes");
13422 let payload = &html[data_start..data_end];
13423
13424 assert!(
13425 !payload.contains(STATE_INJECT_KEY),
13426 "reserved key leaked: {payload}"
13427 );
13428 assert!(payload.contains(r#""visible":"keep""#), "{payload}");
13429 assert!(!payload.contains("serverOnly"), "{payload}");
13430 assert!(html.contains("<script>secret</script>"), "{html}");
13431 }
13432
13433 #[test]
13434 fn write_selected_state_strips_reserved_key_from_full_state() {
13435 let state = test_json!({ "a": 1, "$webui": { "bodyEnd": "<b>x</b>" }, "z": 2 });
13436 let mut sink = TestWriter::new();
13437 let mut scratch = Vec::new();
13438 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13439 let json = sink.get_content();
13440 assert!(!json.contains("$webui"), "reserved key leaked: {json}");
13441 assert!(
13442 json.contains("\"a\":1") && json.contains("\"z\":2"),
13443 "{json}"
13444 );
13445 }
13446
13447 #[test]
13448 fn write_selected_state_strips_reserved_key_from_key_id_projection() {
13449 let mut protocol = WebUIProtocol::new(HashMap::new());
13452 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
13453 protocol.fragments.insert(
13454 "keep-card".to_string(),
13455 webui_protocol::FragmentList::default(),
13456 );
13457 protocol.components.insert(
13458 "keep-card".to_string(),
13459 webui_protocol::ComponentData {
13460 hydration_mode: StateProjectionMode::Keys as i32,
13461 hydration_keys: vec![STATE_INJECT_KEY.to_string(), "keep".to_string()],
13462 ..Default::default()
13463 },
13464 );
13465 let protocol = Protocol::new(protocol);
13466 let index = protocol.component_reachability();
13467 let component = protocol.component_index()["keep-card"];
13468 let mut ids = Vec::new();
13469 assert!(!collect_hydration_key_ids_into(
13470 protocol.protocol(),
13471 index,
13472 [component],
13473 &mut ids
13474 ));
13475
13476 let state = test_json!({
13477 "$webui": { "bodyEnd": "<b>x</b>" },
13478 "keep": 1,
13479 });
13480 let mut sink = TestWriter::new();
13481 let mut scratch = Vec::new();
13482
13483 write_selected_state(
13484 &mut sink,
13485 &mut scratch,
13486 &state,
13487 &StateSelection::KeyIds(HydrationKeySelection { ids: &ids, index }),
13488 )
13489 .unwrap();
13490
13491 assert_eq!(sink.get_content(), r#"{"keep":1}"#);
13492 }
13493
13494 #[test]
13495 fn write_selected_state_full_is_unchanged_without_reserved_key() {
13496 let state = test_json!({ "a": 1, "z": 2 });
13497 let mut sink = TestWriter::new();
13498 let mut scratch = Vec::new();
13499 write_selected_state(&mut sink, &mut scratch, &state, &StateSelection::Full).unwrap();
13500 assert_eq!(sink.get_content(), r#"{"a":1,"z":2}"#);
13501 }
13502
13503 #[test]
13504 fn body_start_hook_dedupes_on_malformed_protocol() {
13505 let mut fragments = HashMap::new();
13506 fragments.insert(
13507 "index.html".to_string(),
13508 FragmentList {
13509 fragments: vec![
13510 WebUIFragment::raw("<html><head></head><body>".to_string()),
13511 structural_fragment("body_start"),
13512 structural_fragment("body_start"),
13513 WebUIFragment::raw("</body></html>".to_string()),
13514 ],
13515 contains_boundary: false,
13516 },
13517 );
13518 let protocol = WebUIProtocol::new(fragments);
13519 let state = test_json!({ "$webui": { "bodyStart": "<i>once</i>" } });
13520 let html = render_with(&protocol, &state, &state_inject_options());
13521 assert_eq!(
13522 html.matches("<i>once</i>").count(),
13523 1,
13524 "duplicate body_start signals must not duplicate the inject: {html}"
13525 );
13526 }
13527
13528 #[test]
13529 fn injects_are_no_op_when_unset() {
13530 let protocol = build_head_body_protocol();
13531 let state = test_json!({});
13532 let mut writer = TestWriter::new();
13533 handle(
13534 &protocol,
13535 &state,
13536 &RenderOptions::new("index.html", "/"),
13537 &mut writer,
13538 )
13539 .unwrap();
13540 let html = writer.get_content();
13541 assert!(!html.contains("<link rel=preload>"));
13542 assert!(!html.contains("<script>lr</script>"));
13543 }
13544
13545 #[test]
13546 fn empty_inject_string_treated_as_unset() {
13547 let protocol = build_head_body_protocol();
13548 let state = test_json!({});
13549 let mut writer = TestWriter::new();
13550 let opts = RenderOptions::new("index.html", "/")
13551 .with_head_inject("")
13552 .with_body_inject("");
13553 handle(&protocol, &state, &opts, &mut writer).unwrap();
13554 let html = writer.get_content();
13557 assert!(html.contains("</head>"));
13558 assert!(html.contains("</body>"));
13559 }
13560
13561 #[test]
13562 fn inject_html_is_passed_through_verbatim() {
13563 let protocol = build_head_body_protocol();
13567 let state = test_json!({});
13568 let mut writer = TestWriter::new();
13569 let opts =
13570 RenderOptions::new("index.html", "/").with_body_inject("<script>var x=1;</script>");
13571 handle(&protocol, &state, &opts, &mut writer).unwrap();
13572 assert!(writer.get_content().contains("<script>var x=1;</script>"));
13573 }
13574
13575 #[test]
13582 fn injects_robust_against_marker_literals_in_content() {
13583 let mut fragments = HashMap::new();
13584 fragments.insert(
13588 "index.html".to_string(),
13589 FragmentList {
13590 fragments: vec![
13591 WebUIFragment::raw("<html><head><title>x</title>".to_string()),
13592 structural_fragment("head_end"),
13593 WebUIFragment::raw(
13594 "</head><body><!-- </body> </head> --><p>hi</p>".to_string(),
13595 ),
13596 structural_fragment("body_end"),
13597 WebUIFragment::raw("</body></html>".to_string()),
13598 ],
13599 contains_boundary: false,
13600 },
13601 );
13602 let protocol = WebUIProtocol::new(fragments);
13603 let state = test_json!({});
13604 let mut writer = TestWriter::new();
13605 let opts = RenderOptions::new("index.html", "/")
13606 .with_head_inject("<HEAD-INJ>")
13607 .with_body_inject("<BODY-INJ>");
13608 handle(&protocol, &state, &opts, &mut writer).unwrap();
13609 let html = writer.get_content();
13610 let head_inj_idx = html.find("<HEAD-INJ>").expect("head inject missing");
13613 let head_close_idx = html.find("</head>").expect("</head> missing");
13614 assert!(head_inj_idx < head_close_idx);
13615 let body_inj_idx = html.find("<BODY-INJ>").expect("body inject missing");
13619 let body_close_idx = html.rfind("</body>").expect("</body> missing");
13621 assert!(
13622 body_inj_idx < body_close_idx,
13623 "body_inject must precede the structural </body>: {html}"
13624 );
13625 assert!(html.contains("<!-- </body> </head> -->"));
13627 }
13628
13629 #[test]
13633 fn both_injects_fire_at_correct_boundaries() {
13634 let protocol = build_head_body_protocol();
13635 let state = test_json!({});
13636 let mut writer = TestWriter::new();
13637 let opts = RenderOptions::new("index.html", "/")
13638 .with_head_inject("<META-HEAD>")
13639 .with_body_inject("<SCRIPT-BODY>");
13640 handle(&protocol, &state, &opts, &mut writer).unwrap();
13641 let html = writer.get_content();
13642 let head_idx = html.find("<META-HEAD>").expect("head inject missing");
13643 let head_close = html.find("</head>").expect("</head> missing");
13644 let body_idx = html.find("<SCRIPT-BODY>").expect("body inject missing");
13645 let body_close = html.find("</body>").expect("</body> missing");
13646 assert!(head_idx < head_close, "head_inject before </head>");
13647 assert!(head_close < body_idx, "body_inject after </head>");
13648 assert!(body_idx < body_close, "body_inject before </body>");
13649 assert_eq!(html.matches("<META-HEAD>").count(), 1);
13650 assert_eq!(html.matches("<SCRIPT-BODY>").count(), 1);
13651 }
13652
13653 #[test]
13659 fn injects_dedupe_against_duplicate_signals() {
13660 let mut fragments = HashMap::new();
13661 fragments.insert(
13662 "index.html".to_string(),
13663 FragmentList {
13664 fragments: vec![
13665 WebUIFragment::raw("<html><head>".to_string()),
13666 structural_fragment("head_end"),
13667 structural_fragment("head_end"), structural_fragment("head_end"), WebUIFragment::raw("</head><body>".to_string()),
13670 structural_fragment("body_end"),
13671 structural_fragment("body_end"), WebUIFragment::raw("</body></html>".to_string()),
13673 ],
13674 contains_boundary: false,
13675 },
13676 );
13677 let protocol = WebUIProtocol::new(fragments);
13678 let state = test_json!({});
13679 let mut writer = TestWriter::new();
13680 let opts = RenderOptions::new("index.html", "/")
13681 .with_head_inject("<HINJ>")
13682 .with_body_inject("<BINJ>");
13683 handle(&protocol, &state, &opts, &mut writer).unwrap();
13684 let html = writer.get_content();
13685 assert_eq!(
13686 html.matches("<HINJ>").count(),
13687 1,
13688 "head_inject must emit exactly once even with duplicate head_end signals"
13689 );
13690 assert_eq!(
13691 html.matches("<BINJ>").count(),
13692 1,
13693 "body_inject must emit exactly once even with duplicate body_end signals"
13694 );
13695 }
13696
13697 #[test]
13701 fn injects_no_op_when_no_head_or_body_signals() {
13702 let mut fragments = HashMap::new();
13703 fragments.insert(
13704 "index.html".to_string(),
13705 FragmentList {
13706 fragments: vec![WebUIFragment::raw(
13707 "<my-component>hi</my-component>".to_string(),
13708 )],
13709 contains_boundary: false,
13710 },
13711 );
13712 let protocol = WebUIProtocol::new(fragments);
13713 let state = test_json!({});
13714 let mut writer = TestWriter::new();
13715 let opts = RenderOptions::new("index.html", "/")
13716 .with_head_inject("<HINJ>")
13717 .with_body_inject("<BINJ>");
13718 handle(&protocol, &state, &opts, &mut writer).unwrap();
13719 let html = writer.get_content();
13720 assert!(!html.contains("<HINJ>"), "head_inject must not appear");
13721 assert!(!html.contains("<BINJ>"), "body_inject must not appear");
13722 assert!(html.contains("<my-component>"));
13723 }
13724
13725 #[test]
13731 fn concurrent_renders_with_different_injects_do_not_cross_contaminate() {
13732 let protocol = std::sync::Arc::new(build_head_body_protocol());
13733 let state = std::sync::Arc::new(test_json!({}));
13734 let handler = std::sync::Arc::new(WebUIHandler::new());
13735
13736 const N_THREADS: usize = 16;
13737 let mut handles = Vec::with_capacity(N_THREADS);
13738 for tid in 0..N_THREADS {
13739 let h = std::sync::Arc::clone(&handler);
13740 let p = std::sync::Arc::clone(&protocol);
13741 let s = std::sync::Arc::clone(&state);
13742 handles.push(std::thread::spawn(move || {
13743 let head = format!("<HEAD-T{tid}>");
13744 let body = format!("<BODY-T{tid}>");
13745 let mut writer = TestWriter::new();
13746 let opts = RenderOptions::new("index.html", "/")
13747 .with_head_inject(&head)
13748 .with_body_inject(&body);
13749 h.handle(&p, &s, &opts, &mut writer).unwrap();
13750 let html = writer.get_content();
13751 assert_eq!(html.matches(&head).count(), 1);
13753 assert_eq!(html.matches(&body).count(), 1);
13754 for other in 0..N_THREADS {
13756 if other == tid {
13757 continue;
13758 }
13759 let other_head = format!("<HEAD-T{other}>");
13760 let other_body = format!("<BODY-T{other}>");
13761 assert!(
13762 !html.contains(&other_head),
13763 "tid {tid} saw {other}'s head_inject"
13764 );
13765 assert!(
13766 !html.contains(&other_body),
13767 "tid {tid} saw {other}'s body_inject"
13768 );
13769 }
13770 }));
13771 }
13772 for h in handles {
13773 h.join().expect("worker panicked");
13774 }
13775 }
13776
13777 #[test]
13782 fn large_inject_roundtrips_without_truncation() {
13783 let protocol = build_head_body_protocol();
13784 let state = test_json!({});
13785 let mut writer = TestWriter::new();
13786 let big = "x".repeat(1024 * 1024);
13787 let opts = RenderOptions::new("index.html", "/").with_head_inject(&big);
13788 handle(&protocol, &state, &opts, &mut writer).unwrap();
13789 let html = writer.get_content();
13790 assert!(
13791 html.contains(&big),
13792 "large head_inject must be present verbatim ({} bytes)",
13793 big.len()
13794 );
13795 assert_eq!(html.matches(&big).count(), 1);
13797 }
13798
13799 #[test]
13804 fn empty_nonce_treated_as_unset() {
13805 let protocol = build_head_body_protocol();
13806 let state = test_json!({});
13807 let mut writer = TestWriter::new();
13808 let opts = RenderOptions::new("index.html", "/").with_nonce("");
13809 handle(&protocol, &state, &opts, &mut writer).unwrap();
13810 assert!(
13811 !writer.get_content().contains("webui-nonce"),
13812 "empty nonce must not emit <meta name=\"webui-nonce\">"
13813 );
13814 }
13815
13816 #[test]
13834 fn empty_field_bypass_is_normalised_at_handler_init() {
13835 let protocol = build_head_body_protocol();
13836 let state = test_json!({});
13837
13838 let opts_with_empty_nonce = RenderOptions {
13840 nonce: Some(""),
13841 ..RenderOptions::new("index.html", "/")
13842 };
13843 let mut writer = TestWriter::new();
13844 handle(&protocol, &state, &opts_with_empty_nonce, &mut writer).unwrap();
13845 let html = writer.get_content();
13846 assert!(
13847 !html.contains("webui-nonce"),
13848 "field-bypass empty nonce must not emit `<meta name=\"webui-nonce\">`"
13849 );
13850 assert!(
13851 !html.contains("nonce=\"\""),
13852 "field-bypass empty nonce must not emit `nonce=\"\"` (would be a hard CSP failure)"
13853 );
13854
13855 let opts_with_empty_injects = RenderOptions {
13857 head_inject: Some(""),
13858 body_inject: Some(""),
13859 ..RenderOptions::new("index.html", "/")
13860 };
13861 let mut writer = TestWriter::new();
13862 handle(&protocol, &state, &opts_with_empty_injects, &mut writer).unwrap();
13863 }
13867
13868 #[test]
13901 fn nested_for_loops_reusing_same_variable_name_dont_corrupt_scope() {
13902 let mut fragments = HashMap::new();
13903 fragments.insert(
13904 "index.html".to_string(),
13905 FragmentList {
13906 fragments: vec![
13907 WebUIFragment::raw("["),
13908 WebUIFragment::for_loop("item", "outer", "outer_body"),
13909 WebUIFragment::raw("]"),
13910 ],
13911 contains_boundary: false,
13912 },
13913 );
13914 fragments.insert(
13915 "outer_body".to_string(),
13916 FragmentList {
13917 fragments: vec![
13918 WebUIFragment::raw("(O="),
13919 WebUIFragment::signal("item.tag", false),
13920 WebUIFragment::for_loop("item", "inner", "inner_body"),
13921 WebUIFragment::raw(",O="),
13922 WebUIFragment::signal("item.tag", false),
13923 WebUIFragment::raw(")"),
13924 ],
13925 contains_boundary: false,
13926 },
13927 );
13928 fragments.insert(
13929 "inner_body".to_string(),
13930 FragmentList {
13931 fragments: vec![
13932 WebUIFragment::raw("[I="),
13933 WebUIFragment::signal("item.tag", false),
13934 WebUIFragment::raw("]"),
13935 ],
13936 contains_boundary: false,
13937 },
13938 );
13939 let protocol = WebUIProtocol::new(fragments);
13940 let state = test_json!({
13941 "outer": [{"tag": "A"}, {"tag": "B"}],
13942 "inner": [{"tag": "X"}, {"tag": "Y"}],
13943 });
13944 let mut writer = TestWriter::new();
13945 handle(
13946 &protocol,
13947 &state,
13948 &RenderOptions::new("index.html", "/"),
13949 &mut writer,
13950 )
13951 .unwrap();
13952 assert_eq!(
13964 writer.get_content(),
13965 "[(O=A[I=X][I=Y],O=A)(O=B[I=X][I=Y],O=B)]",
13966 "outer `item` must stay bound to its iteration value across the inner loop's save/restore"
13967 );
13968 }
13969 #[derive(Default)]
13970 struct FlushTestWriter {
13971 output: String,
13972 flushes: Vec<usize>,
13973 fail_flush: bool,
13974 fail_flush_at: Option<usize>,
13975 flush_attempts: usize,
13976 ended: bool,
13977 }
13978
13979 impl ResponseWriter for FlushTestWriter {
13980 fn write(&mut self, content: &str) -> Result<()> {
13981 self.output.push_str(content);
13982 Ok(())
13983 }
13984
13985 fn end(&mut self) -> Result<()> {
13986 self.ended = true;
13987 Ok(())
13988 }
13989 }
13990
13991 impl FlushWriter for FlushTestWriter {
13992 fn flush(&mut self) -> Result<()> {
13993 let attempt = self.flush_attempts;
13994 self.flush_attempts += 1;
13995 if self.fail_flush || self.fail_flush_at == Some(attempt) {
13996 return Err(HandlerError::ClientDisconnected);
13997 }
13998 self.flushes.push(self.output.len());
13999 Ok(())
14000 }
14001 }
14002
14003 fn streaming_protocol(with_boundaries: bool) -> Protocol {
14004 streaming_protocol_with_state_strategy(with_boundaries, InitialStateStrategy::Components)
14005 }
14006
14007 fn streaming_protocol_with_state_strategy(
14008 with_boundaries: bool,
14009 state_strategy: InitialStateStrategy,
14010 ) -> Protocol {
14011 let mut fragments = HashMap::new();
14012 let mut entry = vec![
14013 WebUIFragment::raw("<!DOCTYPE html><html><HEAD data-shell=\"main\">"),
14014 structural_fragment("head_start"),
14015 WebUIFragment::raw("<script type=\"module\" async src=\"/index.js\"></script>"),
14016 structural_fragment("head_end"),
14017 WebUIFragment::raw("</HEAD><body>"),
14018 structural_fragment("body_start"),
14019 ];
14020 if with_boundaries {
14021 entry.push(WebUIFragment::boundary(0, "index.html", "boundary-0", None));
14022 }
14023 entry.extend([
14024 WebUIFragment::raw("<my-counter"),
14025 structural_fragment("streaming_root:my-counter"),
14026 WebUIFragment::raw(">"),
14027 WebUIFragment::component("my-counter"),
14028 WebUIFragment::raw("</my-counter>"),
14029 ]);
14030 if with_boundaries {
14031 entry.push(WebUIFragment::boundary_end(0));
14032 }
14033 entry.extend([
14034 WebUIFragment::raw("<app-footer>slow tail</app-footer>"),
14035 structural_fragment("body_end"),
14036 WebUIFragment::raw("</body></html>"),
14037 ]);
14038 fragments.insert(
14039 "index.html".to_string(),
14040 FragmentList {
14041 fragments: entry,
14042 contains_boundary: with_boundaries,
14043 },
14044 );
14045 fragments.insert(
14046 "my-counter".to_string(),
14047 FragmentList {
14048 fragments: vec![WebUIFragment::raw("<button>Count</button>")],
14049 contains_boundary: false,
14050 },
14051 );
14052
14053 let mut document = WebUIProtocol::new(fragments);
14054 document.initial_state_strategy = state_strategy as i32;
14055 document.components.insert(
14056 "my-counter".to_string(),
14057 webui_protocol::ComponentData {
14058 template_json: r#"{"h":"<button>Count</button>","th":1}"#.to_string(),
14059 template_functions: "[function(){return true}]".to_string(),
14060 hydration_mode: StateProjectionMode::Keys as i32,
14061 hydration_keys: vec!["count".to_string()],
14062 ..Default::default()
14063 },
14064 );
14065 Protocol::new(document)
14066 }
14067
14068 #[test]
14069 fn handler_error_stays_small() {
14070 assert!(
14077 std::mem::size_of::<HandlerError>()
14078 <= std::mem::size_of::<String>() + std::mem::size_of::<usize>(),
14079 "HandlerError grew to {} bytes",
14080 std::mem::size_of::<HandlerError>()
14081 );
14082 }
14083
14084 #[test]
14085 fn streaming_render_flushes_bootstrap_before_slow_tail_and_emits_terminal() {
14086 let protocol = streaming_protocol(true);
14087 let handler = WebUIHandler::with_plugin(|| {
14088 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14089 });
14090 let state = test_json!({ "count": 1, "serverOnly": "secret" });
14091 let mut writer = FlushTestWriter::default();
14092
14093 handler
14094 .render_streaming(
14095 &protocol,
14096 &state,
14097 &RenderOptions::new("index.html", "/"),
14098 &mut writer,
14099 )
14100 .unwrap();
14101
14102 assert!(writer.ended);
14103 assert_eq!(
14104 writer.flushes.len(),
14105 3,
14106 "bootstrap, boundary commit, and one coalesced terminal-tail flush"
14107 );
14108 let boundary_flush = &writer.output[writer.flushes[0]..writer.flushes[1]];
14109 assert!(boundary_flush.contains("<!--wb:0-->"));
14110 assert!(boundary_flush.contains("<!--/wb:0-->"));
14111 assert!(
14112 boundary_flush.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
14113 "boundary flush: {boundary_flush}"
14114 );
14115 assert!(boundary_flush.contains(r#""inventory":"01","state":{"count":1}"#));
14116 assert!(boundary_flush.contains(r#""templates":{"my-counter":"#));
14117 assert!(!boundary_flush.contains("slow tail"));
14118 assert!(!writer.output.contains("id=\"webui-data\""));
14119 let terminal_flush = &writer.output[writer.flushes[1]..writer.flushes[2]];
14122 assert!(terminal_flush.contains("slow tail"));
14123 assert!(writer.output.contains("[1,4,0,{}]"));
14124 assert!(!writer.output.contains("[1,0,1,"));
14125 assert!(!writer.output.contains("[2,4,0,{}]"));
14126
14127 let marker = writer
14128 .output
14129 .find(STREAMING_MARKER)
14130 .expect("streaming marker");
14131 let attributed_head = writer
14132 .output
14133 .find("<HEAD data-shell=\"main\">")
14134 .expect("attributed mixed-case head");
14135 let authored_script = writer
14136 .output
14137 .find("src=\"/index.js\"")
14138 .expect("entry script");
14139 assert!(attributed_head < marker && marker < authored_script);
14140 }
14141
14142 #[test]
14143 fn streaming_terminal_tail_never_resends_full_state() {
14144 let protocol = streaming_protocol_with_state_strategy(true, InitialStateStrategy::Full);
14145 let handler = WebUIHandler::with_plugin(|| {
14146 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14147 });
14148 let mut writer = FlushTestWriter::default();
14149
14150 handler
14151 .render_streaming(
14152 &protocol,
14153 &test_json!({ "count": 1, "serverOnly": "secret" }),
14154 &RenderOptions::new("index.html", "/").with_body_inject(" \n"),
14155 &mut writer,
14156 )
14157 .unwrap();
14158
14159 assert_eq!(
14160 writer.output.matches("serverOnly").count(),
14161 1,
14162 "full state belongs only to the interactive boundary"
14163 );
14164 assert!(writer.output.contains("[1,4,0,{}]"));
14165 }
14166
14167 #[test]
14168 fn streaming_terminal_record_emits_before_body_close() {
14169 let fragments = HashMap::from([(
14170 "index.html".to_string(),
14171 FragmentList {
14172 fragments: vec![
14173 WebUIFragment::raw("<html><head>"),
14174 structural_fragment("head_start"),
14175 structural_fragment("head_end"),
14176 WebUIFragment::raw("</head><body>"),
14177 structural_fragment("body_start"),
14178 WebUIFragment::raw("<main>static</main>"),
14179 structural_fragment("body_end"),
14180 WebUIFragment::raw("</body></html>"),
14181 ],
14182 contains_boundary: false,
14183 },
14184 )]);
14185 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14186 let mut writer = FlushTestWriter::default();
14187
14188 WebUIHandler::new()
14189 .render_streaming(
14190 &protocol,
14191 &test_json!({}),
14192 &RenderOptions::new("index.html", "/"),
14193 &mut writer,
14194 )
14195 .unwrap();
14196
14197 let terminal = writer.output.find("[0,4,0,{}]").expect("terminal");
14198 let body_close = writer.output.find("</body>").expect("body close");
14199 assert!(
14200 terminal < body_close,
14201 "terminal boundary record must stay inside <body>: {}",
14202 writer.output
14203 );
14204 }
14205
14206 #[test]
14210 fn state_inject_placement_matches_between_render_modes() {
14211 let entry = vec![
14212 WebUIFragment::raw("<html><head>"),
14213 structural_fragment("head_start"),
14214 structural_fragment("head_end"),
14215 WebUIFragment::raw("</head><body>"),
14216 structural_fragment("body_start"),
14217 WebUIFragment::raw("<main>static</main>"),
14218 structural_fragment("body_end"),
14219 WebUIFragment::raw("</body></html>"),
14220 ];
14221 let fragments = HashMap::from([(
14222 "index.html".to_string(),
14223 FragmentList {
14224 fragments: entry,
14225 contains_boundary: false,
14226 },
14227 )]);
14228 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14229 let state = test_json!({
14230 "$webui": {
14231 "headEnd": "<meta name=he>",
14232 "bodyStart": "<span id=bs></span>",
14233 "bodyEnd": "<script>be</script>",
14234 }
14235 });
14236 let options = RenderOptions::new("index.html", "/");
14237
14238 let mut ordinary = TestWriter::new();
14239 WebUIHandler::new()
14240 .render(&protocol, &state, &options, &mut ordinary)
14241 .unwrap();
14242 let ordinary_html = ordinary.get_content().to_string();
14243
14244 let mut streamed = FlushTestWriter::default();
14245 WebUIHandler::new()
14246 .render_streaming(&protocol, &state, &options, &mut streamed)
14247 .unwrap();
14248 let streamed_html = &streamed.output;
14249
14250 for html in [ordinary_html.as_str(), streamed_html.as_str()] {
14251 let head_end = html.find("<meta name=he>").expect("headEnd missing");
14252 let head_close = html.find("</head>").expect("</head> missing");
14253 let body_start = html.find("<span id=bs></span>").expect("bodyStart missing");
14254 let main = html.find("<main>static</main>").expect("content missing");
14255 let body_end = html.find("<script>be</script>").expect("bodyEnd missing");
14256 let body_close = html.find("</body>").expect("</body> missing");
14257 assert!(head_end < head_close, "headEnd misplaced: {html}");
14258 assert!(body_start < main, "bodyStart misplaced: {html}");
14259 assert!(
14260 main < body_end && body_end < body_close,
14261 "bodyEnd misplaced: {html}"
14262 );
14263 }
14264
14265 assert!(
14268 streamed_html.contains(",4,0,{}]"),
14269 "streaming must still end in one empty terminal record: {streamed_html}"
14270 );
14271 }
14272
14273 #[test]
14274 fn streaming_state_inject_emits_and_strips_reserved_key() {
14275 let entry = vec![
14276 WebUIFragment::raw("<html><head>"),
14277 structural_fragment("head_start"),
14278 structural_fragment("head_end"),
14279 WebUIFragment::raw("</head><body>"),
14280 structural_fragment("body_start"),
14281 structural_fragment("body_end"),
14282 WebUIFragment::raw("</body></html>"),
14283 ];
14284 let fragments = HashMap::from([(
14285 "index.html".to_string(),
14286 FragmentList {
14287 fragments: entry,
14288 contains_boundary: false,
14289 },
14290 )]);
14291 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14292 let state = test_json!({ "$webui": { "bodyEnd": "<script>be</script>" } });
14293
14294 let mut writer = FlushTestWriter::default();
14295 WebUIHandler::new()
14296 .render_streaming(
14297 &protocol,
14298 &state,
14299 &RenderOptions::new("index.html", "/"),
14300 &mut writer,
14301 )
14302 .unwrap();
14303 assert!(writer.output.contains("<script>be</script>"));
14304 assert!(!writer.output.contains("$webui"));
14305 }
14306
14307 #[test]
14308 fn static_streaming_document_uses_one_empty_terminal_record() {
14309 let fragments = HashMap::from([(
14310 "index.html".to_string(),
14311 FragmentList {
14312 fragments: vec![
14313 WebUIFragment::raw("<html><head>"),
14314 structural_fragment("head_start"),
14315 structural_fragment("head_end"),
14316 WebUIFragment::raw("</head><body>"),
14317 structural_fragment("body_start"),
14318 WebUIFragment::raw("<main>static</main>"),
14319 structural_fragment("body_end"),
14320 WebUIFragment::raw("</body></html>"),
14321 ],
14322 contains_boundary: false,
14323 },
14324 )]);
14325 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14326 let mut writer = FlushTestWriter::default();
14327
14328 WebUIHandler::new()
14329 .render_streaming(
14330 &protocol,
14331 &test_json!({ "serverOnly": "secret" }),
14332 &RenderOptions::new("index.html", "/"),
14333 &mut writer,
14334 )
14335 .unwrap();
14336
14337 assert_eq!(writer.flushes.len(), 1);
14338 assert!(writer.output.contains(STREAMING_MARKER));
14339 assert!(writer.output.contains("[0,4,0,{}]"));
14340 assert!(!writer.output.contains("serverOnly"));
14341 assert!(!writer.output.contains("id=\"webui-data\""));
14342 assert!(!writer.output.contains("<!--wb:"));
14343 }
14344
14345 #[test]
14346 fn streaming_render_emits_nonce_and_functions_before_sentinel() {
14347 let protocol = streaming_protocol(true);
14348 let handler = WebUIHandler::with_plugin(|| {
14349 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14350 });
14351 let mut writer = FlushTestWriter::default();
14352 handler
14353 .render_streaming(
14354 &protocol,
14355 &test_json!({ "count": 1 }),
14356 &RenderOptions::new("index.html", "/").with_nonce("test-nonce-123"),
14357 &mut writer,
14358 )
14359 .unwrap();
14360
14361 let boundary_end = writer.output.find("<!--/wb:0-->").expect("end marker");
14362 let envelope = writer.output[boundary_end..]
14363 .find("data-webui-boundary nonce=\"test-nonce-123\"")
14364 .map(|index| index + boundary_end)
14365 .expect("nonce-bearing envelope");
14366 let functions = writer
14367 .output
14368 .find("templateFns")
14369 .expect("function side channel");
14370 let sentinel = writer
14371 .output
14372 .find("<webui-hydrate>")
14373 .expect("hydration sentinel");
14374 assert!(boundary_end < envelope && envelope < functions && functions < sentinel);
14375 assert!(writer.output.contains("<script nonce=\"test-nonce-123\">"));
14376 }
14377
14378 #[test]
14379 fn streaming_render_rejects_out_of_order_and_nested_boundaries() {
14380 fn protocol(boundaries: Vec<WebUIFragment>) -> Protocol {
14381 let mut fragments = vec![structural_fragment("head_start")];
14382 fragments.extend(boundaries);
14383 let fragments = HashMap::from([(
14384 "index.html".to_string(),
14385 FragmentList {
14386 fragments,
14387 contains_boundary: true,
14388 },
14389 )]);
14390 Protocol::new(WebUIProtocol::new(fragments))
14391 }
14392
14393 let handler = WebUIHandler::new();
14394 let mut writer = FlushTestWriter::default();
14395 let result = handler.render_streaming(
14396 &protocol(vec![WebUIFragment::boundary_end(0)]),
14397 &test_json!({}),
14398 &RenderOptions::new("index.html", "/"),
14399 &mut writer,
14400 );
14401 match result {
14402 Err(HandlerError::StreamingBoundary(err)) => {
14403 assert_eq!(err.signal, "boundary");
14404 assert!(err.reason.contains("no active occurrence"));
14405 }
14406 other => panic!("expected ordered-boundary error, got {other:?}"),
14407 }
14408
14409 let mut writer = FlushTestWriter::default();
14410 let result = handler.render_streaming(
14411 &protocol(vec![
14412 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14413 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
14414 ]),
14415 &test_json!({}),
14416 &RenderOptions::new("index.html", "/"),
14417 &mut writer,
14418 );
14419 assert!(matches!(result, Err(HandlerError::StreamingBoundary(_))));
14420 }
14421
14422 #[test]
14423 fn streaming_render_propagates_boundary_flush_disconnect() {
14424 let protocol = streaming_protocol(true);
14425 let handler = WebUIHandler::new();
14426 let mut writer = FlushTestWriter {
14427 fail_flush: true,
14428 ..FlushTestWriter::default()
14429 };
14430 let result = handler.render_streaming(
14431 &protocol,
14432 &test_json!({}),
14433 &RenderOptions::new("index.html", "/"),
14434 &mut writer,
14435 );
14436 assert!(matches!(result, Err(HandlerError::ClientDisconnected)));
14437 assert!(!writer.ended);
14438 }
14439
14440 #[test]
14441 fn streaming_render_requires_structural_head_start_before_writing() {
14442 let fragments = HashMap::from([(
14443 "index.html".to_string(),
14444 FragmentList {
14445 fragments: vec![
14446 WebUIFragment::raw(
14447 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>",
14448 ),
14449 structural_fragment("head_end"),
14450 WebUIFragment::raw("</HEAD><body>"),
14451 structural_fragment("body_start"),
14452 structural_fragment("body_end"),
14453 ],
14454 contains_boundary: false,
14455 },
14456 )]);
14457 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14458 let mut writer = FlushTestWriter::default();
14459
14460 let result = WebUIHandler::new().render_streaming(
14461 &protocol,
14462 &test_json!({}),
14463 &RenderOptions::new("index.html", "/"),
14464 &mut writer,
14465 );
14466
14467 assert!(matches!(
14468 result,
14469 Err(HandlerError::MissingStreamingHeadStart { before: "head_end" })
14470 ));
14471 assert_eq!(
14472 writer.output,
14473 "<html><HEAD data-shell=\"main\"><script src=\"/early.js\"></script>"
14474 );
14475 assert!(writer.flushes.is_empty());
14476 assert!(!writer.ended);
14477 }
14478
14479 #[test]
14480 fn streaming_render_rejects_duplicate_head_start() {
14481 let fragments = HashMap::from([(
14482 "index.html".to_string(),
14483 FragmentList {
14484 fragments: vec![
14485 WebUIFragment::raw("<html><head>"),
14486 structural_fragment("head_start"),
14487 structural_fragment("head_start"),
14488 structural_fragment("head_end"),
14489 WebUIFragment::raw("</head><body>"),
14490 structural_fragment("body_start"),
14491 structural_fragment("body_end"),
14492 ],
14493 contains_boundary: false,
14494 },
14495 )]);
14496 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14497 let mut writer = FlushTestWriter::default();
14498
14499 let result = WebUIHandler::new().render_streaming(
14500 &protocol,
14501 &test_json!({}),
14502 &RenderOptions::new("index.html", "/"),
14503 &mut writer,
14504 );
14505
14506 assert!(matches!(
14507 result,
14508 Err(HandlerError::DuplicateStreamingHeadStart)
14509 ));
14510 assert_eq!(writer.output.matches(STREAMING_MARKER).count(), 1);
14511 assert!(writer.flushes.is_empty());
14512 }
14513
14514 #[test]
14515 fn legacy_render_is_byte_identical_when_boundary_signals_are_present() {
14516 let handler = WebUIHandler::with_plugin(|| {
14517 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14518 });
14519 let state = test_json!({
14520 "count": 1,
14521 "head_start": "must not render",
14522 "boundary_start:0": "must not render",
14523 "boundary_end:0": "must not render",
14524 });
14525 let mut with_boundaries = TestWriter::new();
14526 let mut without_boundaries = TestWriter::new();
14527 handler
14528 .render(
14529 &streaming_protocol(true),
14530 &state,
14531 &RenderOptions::new("index.html", "/"),
14532 &mut with_boundaries,
14533 )
14534 .unwrap();
14535 handler
14536 .render(
14537 &streaming_protocol(false),
14538 &state,
14539 &RenderOptions::new("index.html", "/"),
14540 &mut without_boundaries,
14541 )
14542 .unwrap();
14543 assert_eq!(
14544 with_boundaries.get_content(),
14545 without_boundaries.get_content()
14546 );
14547 }
14548
14549 #[test]
14550 fn parser_head_attributes_preserve_legacy_ordinary_render_bytes() {
14551 for source in [
14552 r#"<html><head data-theme="dark"><title>T</title></head><body>x</body></html>"#,
14553 r#"<html><head data-theme="{{theme}}"><title>T</title></head><body>x</body></html>"#,
14554 ] {
14555 let mut parser = HtmlParser::new();
14556 parser
14557 .parse("index.html", source)
14558 .expect("parse head fixture");
14559 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14560 let mut writer = TestWriter::new();
14561 WebUIHandler::new()
14562 .render(
14563 &protocol,
14564 &test_json!({ "theme": "light" }),
14565 &RenderOptions::new("index.html", "/"),
14566 &mut writer,
14567 )
14568 .unwrap();
14569
14570 assert_eq!(
14571 writer.get_content(),
14572 "<html><head><title>T</title></head><body>x</body></html>"
14573 );
14574 }
14575 }
14576
14577 #[test]
14578 fn mixed_case_native_tags_preserve_ordinary_bytes_and_stream_structurally() {
14579 let source =
14580 r#"<html><HEAD data-theme="dark"><title>T</title></HEAD><BODY>x</BODY></html>"#;
14581 let mut parser = HtmlParser::new();
14582 parser.parse("index.html", source).expect("parse fixture");
14583 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14584
14585 let mut ordinary = TestWriter::new();
14586 WebUIHandler::new()
14587 .render(
14588 &protocol,
14589 &test_json!({}),
14590 &RenderOptions::new("index.html", "/"),
14591 &mut ordinary,
14592 )
14593 .unwrap();
14594 assert_eq!(ordinary.get_content(), source);
14595
14596 let mut streaming = FlushTestWriter::default();
14597 WebUIHandler::new()
14598 .render_streaming(
14599 &protocol,
14600 &test_json!({}),
14601 &RenderOptions::new("index.html", "/"),
14602 &mut streaming,
14603 )
14604 .unwrap();
14605 assert!(streaming.output.contains(r#"<HEAD data-theme="dark">"#));
14606 assert!(streaming.output.contains("</HEAD><BODY>x"));
14607 let opening = streaming.output.find("<HEAD").expect("mixed-case head");
14608 let marker = streaming.output.find(STREAMING_MARKER).expect("marker");
14609 let title = streaming.output.find("<title>").expect("title");
14610 assert!(opening < marker && marker < title);
14611 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14612 }
14613
14614 #[test]
14617 fn unnamespaced_signal_is_ordinary_content() {
14618 let fragments = HashMap::from([(
14619 "index.html".to_string(),
14620 FragmentList {
14621 fragments: vec![
14622 WebUIFragment::raw("<html><head>"),
14623 structural_fragment("head_end"),
14624 WebUIFragment::raw("</head><body>"),
14625 WebUIFragment::signal("body_end".to_string(), false),
14626 structural_fragment("body_end"),
14627 WebUIFragment::raw("</body></html>"),
14628 ],
14629 contains_boundary: false,
14630 },
14631 )]);
14632 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14633 let mut writer = TestWriter::new();
14634 WebUIHandler::new()
14635 .render(
14636 &protocol,
14637 &test_json!({ "body_end": "content" }),
14638 &RenderOptions::new("index.html", "/"),
14639 &mut writer,
14640 )
14641 .expect("current protocol must render");
14642 assert!(writer.get_content().contains("content"));
14643 }
14644
14645 #[test]
14646 fn authored_raw_signal_keys_remain_content_in_both_render_modes() {
14647 let source = concat!(
14648 "<html><head></head><body>",
14649 "{{{head_start}}}|{{{head_end}}}|{{{body_start}}}|{{{body_end}}}|",
14650 "{{{boundary_start:0}}}|{{{boundary_end:0}}}|{{{streaming_root:forged}}}",
14651 "</body></html>",
14652 );
14653 let state = test_json!({
14654 "head_start": "hs",
14655 "head_end": "he",
14656 "body_start": "bs",
14657 "body_end": "be",
14658 "boundary_start:0": "b0s",
14659 "boundary_end:0": "b0e",
14660 "streaming_root:forged": "root",
14661 });
14662 let mut parser = HtmlParser::new();
14663 parser.parse("index.html", source).expect("parse fixture");
14664 let protocol = Protocol::new(WebUIProtocol::new(parser.into_fragment_records()));
14665 let expected = "<html><head></head><body>hs|he|bs|be|b0s|b0e|root</body></html>";
14666
14667 let mut ordinary = TestWriter::new();
14668 WebUIHandler::new()
14669 .render(
14670 &protocol,
14671 &state,
14672 &RenderOptions::new("index.html", "/"),
14673 &mut ordinary,
14674 )
14675 .unwrap();
14676 assert_eq!(ordinary.get_content(), expected);
14677
14678 let mut streaming = FlushTestWriter::default();
14679 WebUIHandler::new()
14680 .render_streaming(
14681 &protocol,
14682 &state,
14683 &RenderOptions::new("index.html", "/"),
14684 &mut streaming,
14685 )
14686 .unwrap();
14687 assert!(streaming.output.contains("<body>hs|he|bs|be|b0s|b0e|root"));
14688 assert!(!streaming.output.contains("<!--wb:"));
14689 assert_eq!(streaming.output.matches(STREAMING_MARKER).count(), 1);
14690 assert_eq!(streaming.output.matches("data-webui-boundary").count(), 1);
14691 }
14692
14693 #[test]
14694 fn streaming_rejects_structural_signals_after_terminal() {
14695 let fragments = HashMap::from([(
14696 "index.html".to_string(),
14697 FragmentList {
14698 fragments: vec![
14699 WebUIFragment::raw("<html><head>"),
14700 structural_fragment("head_start"),
14701 structural_fragment("head_end"),
14702 WebUIFragment::raw("</head><body>"),
14703 structural_fragment("body_start"),
14704 structural_fragment("body_end"),
14705 WebUIFragment::raw("</body>"),
14706 structural_fragment("boundary_start:0"),
14707 WebUIFragment::raw("</html>"),
14708 ],
14709 contains_boundary: false,
14710 },
14711 )]);
14712 let protocol = Protocol::new(WebUIProtocol::new(fragments));
14713 let mut streaming = FlushTestWriter::default();
14714 let result = WebUIHandler::new().render_streaming(
14715 &protocol,
14716 &test_json!({}),
14717 &RenderOptions::new("index.html", "/"),
14718 &mut streaming,
14719 );
14720
14721 match result {
14722 Err(HandlerError::StreamingBoundary(error)) => {
14723 assert_eq!(error.signal, "boundary_start:0");
14724 assert!(
14725 error
14726 .reason
14727 .contains("structural signal arrived after body_end"),
14728 "reason: {}",
14729 error.reason
14730 );
14731 }
14732 other => panic!("expected post-terminal rejection, got {other:?}"),
14733 }
14734 assert_eq!(
14735 streaming.output,
14736 concat!(
14737 "<html><head><meta name=\"webui-streaming\" content=\"1\"></head><body>",
14738 "<script type=\"application/json\" data-webui-boundary>[0,4,0,{}]</script>",
14739 "<webui-hydrate></webui-hydrate></body>"
14740 )
14741 );
14742 assert!(!streaming.output.contains("<!--wb:0-->"));
14743
14744 let mut ordinary = TestWriter::new();
14745 WebUIHandler::new()
14746 .render(
14747 &protocol,
14748 &test_json!({}),
14749 &RenderOptions::new("index.html", "/"),
14750 &mut ordinary,
14751 )
14752 .unwrap();
14753 assert_eq!(
14754 ordinary.get_content(),
14755 "<html><head></head><body></body></html>"
14756 );
14757 }
14758
14759 fn disjoint_streaming_protocol_ext(
14760 hosts: &[&str],
14761 emit_root_signal: bool,
14762 include_styles: bool,
14763 ) -> Protocol {
14764 let mut fragments = HashMap::new();
14765 let mut entry = vec![
14766 WebUIFragment::raw("<!DOCTYPE html><html><head>"),
14767 structural_fragment("head_start"),
14768 structural_fragment("head_end"),
14769 WebUIFragment::raw("</head><body>"),
14770 structural_fragment("body_start"),
14771 ];
14772 for (sequence, host) in hosts.iter().enumerate() {
14773 entry.push(WebUIFragment::boundary(
14774 sequence as u32,
14775 "index.html",
14776 format!("boundary-{sequence}"),
14777 None,
14778 ));
14779 entry.push(WebUIFragment::raw(format!("<{host}")));
14780 if emit_root_signal {
14781 entry.push(structural_fragment(format!("streaming_root:{host}")));
14782 }
14783 entry.push(WebUIFragment::raw(">"));
14784 entry.push(WebUIFragment::component(*host));
14785 entry.push(WebUIFragment::raw(format!("</{host}>")));
14786 entry.push(WebUIFragment::boundary_end(sequence as u32));
14787 }
14788 entry.push(structural_fragment("body_end"));
14789 entry.push(WebUIFragment::raw("</body></html>"));
14790 fragments.insert(
14791 "index.html".to_string(),
14792 FragmentList {
14793 fragments: entry,
14794 contains_boundary: true,
14795 },
14796 );
14797 fragments.insert(
14798 "comp-a".to_string(),
14799 FragmentList {
14800 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14801 contains_boundary: false,
14802 },
14803 );
14804 fragments.insert(
14805 "comp-b".to_string(),
14806 FragmentList {
14807 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14808 contains_boundary: false,
14809 },
14810 );
14811
14812 let mut document = WebUIProtocol::new(fragments);
14813 document.initial_state_strategy = InitialStateStrategy::Components as i32;
14814 document.components.insert(
14815 "comp-a".to_string(),
14816 webui_protocol::ComponentData {
14817 template_json: r#"{"h":"<i>A</i>","th":1}"#.to_string(),
14818 hydration_mode: StateProjectionMode::Keys as i32,
14819 hydration_keys: vec!["a_count".to_string()],
14820 ..Default::default()
14821 },
14822 );
14823 document.components.insert(
14824 "comp-b".to_string(),
14825 webui_protocol::ComponentData {
14826 template_json: r#"{"h":"<i>B</i>","th":1}"#.to_string(),
14827 hydration_mode: StateProjectionMode::Keys as i32,
14828 hydration_keys: vec!["b_count".to_string()],
14829 ..Default::default()
14830 },
14831 );
14832 if include_styles {
14833 document.set_css_strategy(webui_protocol::CssStrategy::Style);
14834 document
14835 .components
14836 .get_mut("comp-a")
14837 .expect("comp-a component")
14838 .css = ".comp-a{color:red}".to_string();
14839 document
14840 .components
14841 .get_mut("comp-b")
14842 .expect("comp-b component")
14843 .css = ".comp-b{color:blue}".to_string();
14844 document.populate_style_closures(&["index.html"]);
14845 }
14846 Protocol::new(document)
14847 }
14848
14849 fn disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14850 disjoint_streaming_protocol_ext(hosts, true, false)
14851 }
14852
14853 fn styled_disjoint_streaming_protocol(hosts: &[&str]) -> Protocol {
14854 disjoint_streaming_protocol_ext(hosts, true, true)
14855 }
14856
14857 fn streaming_root_validation_protocol(
14858 mut host_fragments: Vec<WebUIFragment>,
14859 tail: Vec<WebUIFragment>,
14860 ) -> Protocol {
14861 let mut entry = vec![
14862 WebUIFragment::raw("<html><head>"),
14863 structural_fragment("head_start"),
14864 structural_fragment("head_end"),
14865 WebUIFragment::raw("</head><body>"),
14866 structural_fragment("body_start"),
14867 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
14868 ];
14869 entry.append(&mut host_fragments);
14870 entry.extend(tail);
14871 let fragments = HashMap::from([
14872 (
14873 "index.html".to_string(),
14874 FragmentList {
14875 fragments: entry,
14876 contains_boundary: true,
14877 },
14878 ),
14879 (
14880 "comp-a".to_string(),
14881 FragmentList {
14882 fragments: vec![WebUIFragment::raw("<b>A</b>")],
14883 contains_boundary: false,
14884 },
14885 ),
14886 (
14887 "comp-b".to_string(),
14888 FragmentList {
14889 fragments: vec![WebUIFragment::raw("<b>B</b>")],
14890 contains_boundary: false,
14891 },
14892 ),
14893 ]);
14894 Protocol::new(WebUIProtocol::new(fragments))
14895 }
14896
14897 fn completed_streaming_tail() -> Vec<WebUIFragment> {
14898 vec![
14899 WebUIFragment::raw("</comp-a>"),
14900 WebUIFragment::boundary_end(0),
14901 structural_fragment("body_end"),
14902 WebUIFragment::raw("</body></html>"),
14903 ]
14904 }
14905
14906 fn assert_streaming_root_error(
14907 protocol: &Protocol,
14908 expected_signal: &str,
14909 expected_reason: &str,
14910 ) {
14911 let mut writer = FlushTestWriter::default();
14912 let result = WebUIHandler::new().render_streaming(
14913 protocol,
14914 &test_json!({}),
14915 &RenderOptions::new("index.html", "/"),
14916 &mut writer,
14917 );
14918 match result {
14919 Err(HandlerError::StreamingBoundary(error)) => {
14920 assert_eq!(error.signal, expected_signal);
14921 assert!(
14922 error.reason.contains(expected_reason),
14923 "reason: {}",
14924 error.reason
14925 );
14926 }
14927 other => panic!("expected streaming-root rejection, got {other:?}"),
14928 }
14929 }
14930
14931 #[test]
14932 fn streaming_root_signal_injects_data_ws_inside_boundary() {
14933 let protocol = disjoint_streaming_protocol(&["comp-a"]);
14934 let handler = WebUIHandler::with_plugin(|| {
14935 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
14936 });
14937 let mut writer = FlushTestWriter::default();
14938 handler
14939 .render_streaming(
14940 &protocol,
14941 &test_json!({ "a_count": 1 }),
14942 &RenderOptions::new("index.html", "/"),
14943 &mut writer,
14944 )
14945 .unwrap();
14946 assert!(
14949 writer.output.contains("<comp-a data-ws>"),
14950 "streamed host must carry data-ws: {}",
14951 writer.output
14952 );
14953 }
14954
14955 #[test]
14956 fn streaming_root_signal_preserves_ordinary_output_bytes() {
14957 let with_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], true, false);
14960 let without_signal = disjoint_streaming_protocol_ext(&["comp-a", "comp-b"], false, false);
14961 let state = test_json!({ "a_count": 1, "b_count": 2 });
14962 let plugin = || {
14963 WebUIHandler::with_plugin(
14964 || Box::new(crate::plugin::webui::WebUIHydrationPlugin::new()),
14965 )
14966 };
14967 let mut with_writer = TestWriter::new();
14968 let mut without_writer = TestWriter::new();
14969 plugin()
14970 .render(
14971 &with_signal,
14972 &state,
14973 &RenderOptions::new("index.html", "/"),
14974 &mut with_writer,
14975 )
14976 .unwrap();
14977 plugin()
14978 .render(
14979 &without_signal,
14980 &state,
14981 &RenderOptions::new("index.html", "/"),
14982 &mut without_writer,
14983 )
14984 .unwrap();
14985 let dom_prefix = |content: &str| -> String {
14990 content
14991 .split_once(r#"<script type="application/json" id="webui-data""#)
14992 .map_or_else(|| content.to_string(), |(head, _)| head.to_string())
14993 };
14994 assert_eq!(
14995 dom_prefix(&with_writer.get_content()),
14996 dom_prefix(&without_writer.get_content())
14997 );
14998 assert!(!with_writer.get_content().contains("data-ws"));
14999 assert!(with_writer.get_content().contains("<comp-a>"));
15000 }
15001
15002 #[test]
15003 fn streaming_component_without_root_signal_is_rejected() {
15004 let protocol = streaming_root_validation_protocol(
15005 vec![
15006 WebUIFragment::raw("<comp-a>"),
15007 WebUIFragment::component("comp-a"),
15008 ],
15009 completed_streaming_tail(),
15010 );
15011
15012 assert_streaming_root_error(
15013 &protocol,
15014 "comp-a",
15015 "has no compiler-owned streaming_root signal",
15016 );
15017 }
15018
15019 #[test]
15020 fn duplicate_and_misplaced_streaming_root_signals_are_rejected() {
15021 let duplicate = streaming_root_validation_protocol(
15022 vec![
15023 WebUIFragment::raw("<comp-a"),
15024 structural_fragment("streaming_root:comp-a"),
15025 structural_fragment("streaming_root:comp-a"),
15026 WebUIFragment::raw(">"),
15027 WebUIFragment::component("comp-a"),
15028 ],
15029 completed_streaming_tail(),
15030 );
15031 assert_streaming_root_error(&duplicate, "comp-a", "is misplaced");
15032
15033 let after_tag_close = streaming_root_validation_protocol(
15034 vec![
15035 WebUIFragment::raw("<comp-a>"),
15036 structural_fragment("streaming_root:comp-a"),
15037 WebUIFragment::component("comp-a"),
15038 ],
15039 completed_streaming_tail(),
15040 );
15041 assert_streaming_root_error(&after_tag_close, "comp-a", "is misplaced");
15042
15043 let detached_from_opening = streaming_root_validation_protocol(
15044 vec![
15045 WebUIFragment::raw("not an opening tag"),
15046 structural_fragment("streaming_root:comp-a"),
15047 WebUIFragment::raw(">"),
15048 WebUIFragment::component("comp-a"),
15049 ],
15050 completed_streaming_tail(),
15051 );
15052 assert_streaming_root_error(
15053 &detached_from_opening,
15054 "comp-a",
15055 "unclosed component opening-tag close",
15056 );
15057 }
15058
15059 #[test]
15060 fn mismatched_streaming_root_signal_is_rejected() {
15061 let protocol = streaming_root_validation_protocol(
15062 vec![
15063 WebUIFragment::raw("<comp-a"),
15064 structural_fragment("streaming_root:comp-b"),
15065 WebUIFragment::raw(">"),
15066 WebUIFragment::component("comp-a"),
15067 ],
15068 completed_streaming_tail(),
15069 );
15070
15071 assert_streaming_root_error(&protocol, "comp-b", "root signal names <comp-b>");
15072 }
15073
15074 #[test]
15075 fn pending_streaming_root_is_rejected_at_structural_ends() {
15076 let protocol = streaming_root_validation_protocol(
15077 vec![
15078 WebUIFragment::raw("<comp-a"),
15079 structural_fragment("streaming_root:comp-a"),
15080 WebUIFragment::raw(">"),
15081 ],
15082 vec![structural_fragment("boundary_end:0")],
15083 );
15084 assert_streaming_root_error(&protocol, "comp-a", "is misplaced");
15085 }
15086
15087 #[test]
15088 fn streaming_root_outside_boundary_is_rejected() {
15089 let fragments = HashMap::from([
15090 (
15091 "index.html".to_string(),
15092 FragmentList {
15093 fragments: vec![
15094 WebUIFragment::raw("<html><head>"),
15095 structural_fragment("head_start"),
15096 structural_fragment("head_end"),
15097 WebUIFragment::raw("</head><body>"),
15098 structural_fragment("body_start"),
15099 WebUIFragment::raw("<comp-a"),
15100 structural_fragment("streaming_root:comp-a"),
15101 WebUIFragment::raw(">"),
15102 WebUIFragment::component("comp-a"),
15103 WebUIFragment::raw("</comp-a>"),
15104 structural_fragment("body_end"),
15105 WebUIFragment::raw("</body></html>"),
15106 ],
15107 contains_boundary: false,
15108 },
15109 ),
15110 (
15111 "comp-a".to_string(),
15112 FragmentList {
15113 fragments: vec![WebUIFragment::raw("<b>A</b>")],
15114 contains_boundary: false,
15115 },
15116 ),
15117 ]);
15118 let protocol = Protocol::new(WebUIProtocol::new(fragments));
15119 let mut writer = FlushTestWriter::default();
15120 let result = WebUIHandler::new().render_streaming(
15121 &protocol,
15122 &test_json!({}),
15123 &RenderOptions::new("index.html", "/"),
15124 &mut writer,
15125 );
15126 match result {
15127 Err(HandlerError::StreamingBoundary(err)) => {
15128 assert_eq!(err.signal, "comp-a");
15129 assert!(
15130 err.reason
15131 .contains("no boundary or generated component span"),
15132 "reason: {}",
15133 err.reason
15134 );
15135 }
15136 other => panic!("expected outside-boundary rejection, got {other:?}"),
15137 }
15138 }
15139
15140 fn parser_route_protocol(with_boundary: bool) -> Protocol {
15141 let mut parser = HtmlParser::with_options(DomStrategy::Light);
15142 parser
15143 .component_registry_mut()
15144 .register_component(ComponentRegistration::new(
15145 "route-page",
15146 "<p>route content</p>",
15147 None,
15148 true,
15149 ))
15150 .expect("register route component");
15151 let route = r#"<route path="/" component="route-page" exact />"#;
15152 let html = if with_boundary {
15153 format!(
15154 "<html><head></head><body>\
15155 <boundary name=\"route\">{route}</boundary>\
15156 </body></html>"
15157 )
15158 } else {
15159 format!("<html><head></head><body>{route}</body></html>")
15160 };
15161 parser
15162 .parse("index.html", &html)
15163 .expect("parse route streaming fixture");
15164 let mut document = WebUIProtocol::new(parser.into_fragment_records());
15165 document.components.insert(
15166 "route-page".to_string(),
15167 webui_protocol::ComponentData::default(),
15168 );
15169 Protocol::new(document)
15170 }
15171
15172 #[test]
15173 fn streaming_parser_route_host_is_marked_inside_explicit_boundary() {
15174 let protocol = parser_route_protocol(true);
15175 let mut writer = FlushTestWriter::default();
15176 WebUIHandler::new()
15177 .render_streaming(
15178 &protocol,
15179 &test_json!({}),
15180 &RenderOptions::new("index.html", "/"),
15181 &mut writer,
15182 )
15183 .unwrap();
15184
15185 assert!(
15186 writer
15187 .output
15188 .contains("<route-page data-ws><p>route content</p></route-page>"),
15189 "matched route host must be deferred before upgrade: {}",
15190 writer.output
15191 );
15192 }
15193
15194 #[test]
15195 fn streaming_parser_route_host_outside_boundary_is_rejected() {
15196 let protocol = parser_route_protocol(false);
15197 let mut writer = FlushTestWriter::default();
15198 let result = WebUIHandler::new().render_streaming(
15199 &protocol,
15200 &test_json!({}),
15201 &RenderOptions::new("index.html", "/"),
15202 &mut writer,
15203 );
15204
15205 match result {
15206 Err(HandlerError::StreamingBoundary(error)) => {
15207 assert_eq!(error.signal, "route-page");
15208 assert!(
15209 error
15210 .reason
15211 .contains("no boundary or generated component span"),
15212 "reason: {}",
15213 error.reason
15214 );
15215 }
15216 other => panic!("expected route boundary rejection, got {other:?}"),
15217 }
15218 assert!(
15219 !writer.output.contains("<route-page>"),
15220 "an unmarked custom-element opening must never be completed"
15221 );
15222 }
15223
15224 #[test]
15225 fn streaming_checkpoint_preserves_relative_route_base_for_metadata() {
15226 let entry = vec![
15227 WebUIFragment::raw("<html><head>"),
15228 structural_fragment("head_start"),
15229 structural_fragment("head_end"),
15230 WebUIFragment::raw("</head><body>"),
15231 structural_fragment("body_start"),
15232 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15233 WebUIFragment::route_from(webui_protocol::WebUiFragmentRoute {
15234 path: "/account".into(),
15235 fragment_id: "account-shell".into(),
15236 exact: false,
15237 ..Default::default()
15238 }),
15239 WebUIFragment::boundary_end(0),
15240 structural_fragment("body_end"),
15241 WebUIFragment::raw("</body></html>"),
15242 ];
15243 let fragments = HashMap::from([
15244 (
15245 "index.html".to_string(),
15246 FragmentList {
15247 fragments: entry,
15248 contains_boundary: true,
15249 },
15250 ),
15251 (
15252 "account-shell".to_string(),
15253 FragmentList {
15254 fragments: vec![WebUIFragment::route_from(
15255 webui_protocol::WebUiFragmentRoute {
15256 path: "./details".into(),
15257 fragment_id: "details-page".into(),
15258 exact: true,
15259 pending_component: "details-loading".into(),
15260 error_component: "details-error".into(),
15261 ..Default::default()
15262 },
15263 )],
15264 contains_boundary: false,
15265 },
15266 ),
15267 (
15268 "details-page".to_string(),
15269 FragmentList {
15270 fragments: vec![WebUIFragment::raw("<p>details</p>")],
15271 contains_boundary: false,
15272 },
15273 ),
15274 (
15275 "details-loading".to_string(),
15276 FragmentList {
15277 fragments: vec![WebUIFragment::raw("<p>loading</p>")],
15278 contains_boundary: false,
15279 },
15280 ),
15281 (
15282 "details-error".to_string(),
15283 FragmentList {
15284 fragments: vec![WebUIFragment::raw("<p>error</p>")],
15285 contains_boundary: false,
15286 },
15287 ),
15288 ]);
15289 let mut document = WebUIProtocol::new(fragments);
15290 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15291 for name in [
15292 "account-shell",
15293 "details-page",
15294 "details-loading",
15295 "details-error",
15296 ] {
15297 document.components.insert(
15298 name.to_string(),
15299 webui_protocol::ComponentData {
15300 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15301 ..Default::default()
15302 },
15303 );
15304 }
15305 let protocol = Protocol::new(document);
15306 let handler = WebUIHandler::with_plugin(|| {
15307 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15308 });
15309 let mut writer = FlushTestWriter::default();
15310
15311 handler
15312 .render_streaming(
15313 &protocol,
15314 &test_json!({}),
15315 &RenderOptions::new("index.html", "/account/details"),
15316 &mut writer,
15317 )
15318 .unwrap();
15319
15320 let first = &writer.output[writer.flushes[0]..writer.flushes[1]];
15321 assert!(
15322 first.contains(r#""details-loading":"#),
15323 "relative-route pending metadata missing: {first}"
15324 );
15325 assert!(
15326 first.contains(r#""details-error":"#),
15327 "relative-route error metadata missing: {first}"
15328 );
15329 }
15330
15331 #[test]
15332 fn streaming_checkpoint_keeps_independent_siblings_beside_route_roots() {
15333 let entry = vec![
15334 WebUIFragment::raw("<html><head>"),
15335 structural_fragment("head_start"),
15336 structural_fragment("head_end"),
15337 WebUIFragment::raw("</head><body>"),
15338 structural_fragment("body_start"),
15339 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15340 WebUIFragment::raw("<static-shell"),
15341 structural_fragment("streaming_root:static-shell"),
15342 WebUIFragment::raw(">"),
15343 WebUIFragment::component("static-shell"),
15344 WebUIFragment::raw("</static-shell>"),
15345 WebUIFragment::raw("<route-shell"),
15346 structural_fragment("streaming_root:route-shell"),
15347 WebUIFragment::raw(">"),
15348 WebUIFragment::component("route-shell"),
15349 WebUIFragment::raw("</route-shell>"),
15350 WebUIFragment::boundary_end(0),
15351 structural_fragment("body_end"),
15352 WebUIFragment::raw("</body></html>"),
15353 ];
15354 let fragments = HashMap::from([
15355 (
15356 "index.html".to_string(),
15357 FragmentList {
15358 fragments: entry,
15359 contains_boundary: true,
15360 },
15361 ),
15362 (
15363 "static-shell".to_string(),
15364 FragmentList {
15365 fragments: vec![WebUIFragment::if_cond(
15366 ConditionExpr::identifier("show_hidden"),
15367 "static-hidden-if",
15368 )],
15369 contains_boundary: false,
15370 },
15371 ),
15372 (
15373 "static-hidden-if".to_string(),
15374 FragmentList {
15375 fragments: vec![WebUIFragment::component("static-hidden")],
15376 contains_boundary: false,
15377 },
15378 ),
15379 (
15380 "static-hidden".to_string(),
15381 FragmentList {
15382 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15383 contains_boundary: false,
15384 },
15385 ),
15386 (
15387 "route-shell".to_string(),
15388 FragmentList {
15389 fragments: vec![WebUIFragment::route_from(
15390 webui_protocol::WebUiFragmentRoute {
15391 path: "/account".into(),
15392 fragment_id: "route-page".into(),
15393 exact: true,
15394 ..Default::default()
15395 },
15396 )],
15397 contains_boundary: false,
15398 },
15399 ),
15400 (
15401 "route-page".to_string(),
15402 FragmentList {
15403 fragments: vec![WebUIFragment::raw("<p>route</p>")],
15404 contains_boundary: false,
15405 },
15406 ),
15407 ]);
15408 let mut document = WebUIProtocol::new(fragments);
15409 document.set_css_strategy(webui_protocol::CssStrategy::Link);
15410 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15411 for (name, keys) in [
15412 ("static-shell", vec!["show_hidden", "static_count"]),
15413 ("static-hidden", vec!["hidden_count"]),
15414 ("route-shell", vec!["route_shell_count"]),
15415 ("route-page", vec!["route_count"]),
15416 ] {
15417 document.components.insert(
15418 name.to_string(),
15419 webui_protocol::ComponentData {
15420 template_json: format!(r#"{{"h":"<{name}></{name}>","th":1}}"#),
15421 hydration_mode: StateProjectionMode::Keys as i32,
15422 hydration_keys: keys.into_iter().map(str::to_string).collect(),
15423 css_href: format!("/{name}.css"),
15424 ..Default::default()
15425 },
15426 );
15427 }
15428 document.populate_style_closures(&["index.html"]);
15429 let protocol = Protocol::new(document);
15430 assert_eq!(
15431 protocol
15432 .component_reachability()
15433 .is_route_dependent(protocol.component_index()["route-shell"]),
15434 Some(true),
15435 "fixture must exercise request-aware checkpoint reachability"
15436 );
15437 let handler = WebUIHandler::with_plugin(|| {
15438 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15439 });
15440 let mut writer = FlushTestWriter::default();
15441
15442 handler
15443 .render_streaming(
15444 &protocol,
15445 &test_json!({
15446 "show_hidden": false,
15447 "static_count": 1,
15448 "hidden_count": 2,
15449 "route_shell_count": 3,
15450 "route_count": 4,
15451 "unrelated": "private",
15452 }),
15453 &RenderOptions::new("index.html", "/account"),
15454 &mut writer,
15455 )
15456 .unwrap();
15457
15458 let checkpoint = &writer.output[writer.flushes[0]..writer.flushes[1]];
15459 for template in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15460 assert!(
15461 checkpoint.contains(&format!(r#""{template}":"#)),
15462 "{template} metadata missing: {checkpoint}"
15463 );
15464 }
15465 for state in [
15466 r#""show_hidden":false"#,
15467 r#""static_count":1"#,
15468 r#""hidden_count":2"#,
15469 r#""route_shell_count":3"#,
15470 r#""route_count":4"#,
15471 ] {
15472 assert!(
15473 checkpoint.contains(state),
15474 "{state} state missing: {checkpoint}"
15475 );
15476 }
15477 assert!(
15478 !checkpoint.contains("unrelated"),
15479 "checkpoint leaked unrelated state: {checkpoint}"
15480 );
15481 assert!(
15482 checkpoint.contains(
15483 r#""css":["/static-shell.css","/static-hidden.css","/route-shell.css","/route-page.css"]"#
15484 ),
15485 "checkpoint metadata lost source order: {checkpoint}"
15486 );
15487 assert!(
15488 checkpoint.contains(r#""componentStyles":"#)
15489 && checkpoint.contains(r#""strategy":"link""#)
15490 && checkpoint.contains(r#""static-shell":["static-shell","static-hidden"]"#),
15491 "checkpoint is missing tree-local style metadata: {checkpoint}"
15492 );
15493 for resource in ["static-shell", "static-hidden", "route-shell", "route-page"] {
15494 assert_eq!(
15495 writer
15496 .output
15497 .matches(&format!(r#"data-webui-resource="{resource}""#))
15498 .count(),
15499 1,
15500 "Document delivery state must persist across streamed checkpoints"
15501 );
15502 }
15503 }
15504
15505 #[test]
15506 fn streaming_checkpoints_carry_boundary_local_templates_and_state() {
15507 let protocol = disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15512 let handler = WebUIHandler::with_plugin(|| {
15513 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15514 });
15515 let state = test_json!({ "a_count": 1, "b_count": 2, "serverOnly": "secret" });
15516 let mut writer = FlushTestWriter::default();
15517 handler
15518 .render_streaming(
15519 &protocol,
15520 &state,
15521 &RenderOptions::new("index.html", "/"),
15522 &mut writer,
15523 )
15524 .unwrap();
15525
15526 assert_eq!(
15527 writer.flushes.len(),
15528 5,
15529 "bootstrap, three boundary commits, and one terminal"
15530 );
15531 let segment = |index: usize| -> &str {
15532 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15533 };
15534 let b0 = segment(0);
15535 let b1 = segment(1);
15536 let b2 = segment(2);
15537
15538 assert!(b0.contains(r#""templates":{"comp-a":"#), "b0: {b0}");
15540 assert!(b0.contains(r#""a_count":1"#), "b0: {b0}");
15541 assert!(
15542 b0.contains(r#"[0,0,0,{"declarationId":0,"componentStyles":"#),
15543 "b0: {b0}"
15544 );
15545 assert!(b0.contains(r#""inventory":"01""#), "b0: {b0}");
15546 assert!(!b0.contains("comp-b"), "b0 leaked comp-b: {b0}");
15547 assert!(!b0.contains("b_count"), "b0 leaked b_count: {b0}");
15548
15549 assert!(b1.contains(r#""templates":{"comp-b":"#), "b1: {b1}");
15551 assert!(b1.contains(r#""b_count":2"#), "b1: {b1}");
15552 assert!(
15553 b1.contains(r#"[1,0,1,{"declarationId":1,"componentStyles":"#),
15554 "b1: {b1}"
15555 );
15556 assert!(b1.contains(r#""inventory":"02""#), "b1: {b1}");
15557 assert!(
15558 !b1.contains(r#""templates":{"comp-a"#),
15559 "b1 re-sent comp-a: {b1}"
15560 );
15561 assert!(!b1.contains("a_count"), "b1 leaked a_count: {b1}");
15562
15563 assert!(b2.contains(r#""a_count":1"#), "b2: {b2}");
15565 assert!(
15566 b2.contains(r#"[2,0,2,{"declarationId":2,"componentStyles":"#),
15567 "b2: {b2}"
15568 );
15569 assert!(b2.contains(r#""inventory":"""#), "b2: {b2}");
15570 assert!(
15571 !b2.contains(r#""templates""#),
15572 "b2 re-sent a template: {b2}"
15573 );
15574
15575 assert!(!writer.output.contains("serverOnly"));
15577 }
15578
15579 #[test]
15580 fn streaming_checkpoints_emit_style_resources_and_closures_once() {
15581 let protocol = styled_disjoint_streaming_protocol(&["comp-a", "comp-b", "comp-a"]);
15582 let handler = WebUIHandler::with_plugin(|| {
15583 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15584 });
15585 let mut writer = FlushTestWriter::default();
15586 handler
15587 .render_streaming(
15588 &protocol,
15589 &test_json!({ "a_count": 1, "b_count": 2 }),
15590 &RenderOptions::new("index.html", "/"),
15591 &mut writer,
15592 )
15593 .unwrap();
15594
15595 let segment = |index: usize| -> &str {
15596 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15597 };
15598 let checkpoint_styles = |checkpoint: &str| -> Value {
15599 let script = checkpoint
15600 .rfind(r#"<script type="application/json" data-webui-boundary"#)
15601 .unwrap();
15602 let payload_start = script + checkpoint[script..].find('>').unwrap() + 1;
15603 let payload_end =
15604 payload_start + checkpoint[payload_start..].find("</script>").unwrap();
15605 let record: Value =
15606 serde_json::from_str(&checkpoint[payload_start..payload_end]).unwrap();
15607 record[3]["componentStyles"].clone()
15608 };
15609
15610 let first = checkpoint_styles(segment(0));
15611 assert!(first["resources"]["comp-a"].is_object());
15612 assert!(first["resources"]["comp-b"].is_object());
15613 assert!(first["closures"]["index.html"].is_array());
15614 assert!(first["closures"]["comp-a"].is_array());
15615
15616 let second = checkpoint_styles(segment(1));
15617 assert_eq!(second["resources"], test_json!({}));
15618 assert_eq!(second["closures"], test_json!({ "comp-b": ["comp-b"] }));
15619
15620 let repeated = checkpoint_styles(segment(2));
15621 assert_eq!(repeated["resources"], test_json!({}));
15622 assert_eq!(repeated["closures"], test_json!({}));
15623 for resource in ["comp-a", "comp-b"] {
15624 assert_eq!(
15625 [&first, &second, &repeated]
15626 .iter()
15627 .filter(|styles| {
15628 styles["resources"]
15629 .as_object()
15630 .is_some_and(|resources| resources.contains_key(resource))
15631 })
15632 .count(),
15633 1,
15634 "{resource} style metadata was serialized more than once"
15635 );
15636 }
15637 }
15638
15639 #[test]
15640 fn streaming_checkpoint_carries_reachable_unrendered_metadata_without_inventory() {
15641 let entry = vec![
15642 WebUIFragment::raw("<html><head>"),
15643 structural_fragment("head_start"),
15644 structural_fragment("head_end"),
15645 WebUIFragment::raw("</head><body>"),
15646 structural_fragment("body_start"),
15647 WebUIFragment::boundary(0, "index.html", "boundary-0", None),
15648 WebUIFragment::raw("<comp-a"),
15649 structural_fragment("streaming_root:comp-a"),
15650 WebUIFragment::raw(">"),
15651 WebUIFragment::component("comp-a"),
15652 WebUIFragment::raw("</comp-a>"),
15653 WebUIFragment::boundary_end(0),
15654 WebUIFragment::boundary(1, "index.html", "boundary-1", None),
15655 WebUIFragment::raw("<comp-hidden"),
15656 structural_fragment("streaming_root:comp-hidden"),
15657 WebUIFragment::raw(">"),
15658 WebUIFragment::component("comp-hidden"),
15659 WebUIFragment::raw("</comp-hidden>"),
15660 WebUIFragment::boundary_end(1),
15661 structural_fragment("body_end"),
15662 WebUIFragment::raw("</body></html>"),
15663 ];
15664 let fragments = HashMap::from([
15665 (
15666 "index.html".to_string(),
15667 FragmentList {
15668 fragments: entry,
15669 contains_boundary: true,
15670 },
15671 ),
15672 (
15673 "comp-a".to_string(),
15674 FragmentList {
15675 fragments: vec![WebUIFragment::if_cond(
15676 ConditionExpr::identifier("show_hidden"),
15677 "hidden-if",
15678 )],
15679 contains_boundary: false,
15680 },
15681 ),
15682 (
15683 "hidden-if".to_string(),
15684 FragmentList {
15685 fragments: vec![WebUIFragment::component("comp-hidden")],
15686 contains_boundary: false,
15687 },
15688 ),
15689 (
15690 "comp-hidden".to_string(),
15691 FragmentList {
15692 fragments: vec![WebUIFragment::raw("<p>hidden</p>")],
15693 contains_boundary: false,
15694 },
15695 ),
15696 ]);
15697 let mut document = WebUIProtocol::new(fragments);
15698 document.set_css_strategy(webui_protocol::CssStrategy::Module);
15699 document.initial_state_strategy = InitialStateStrategy::Components as i32;
15700 document.components.insert(
15701 "comp-a".to_string(),
15702 webui_protocol::ComponentData {
15703 template_json: r#"{"h":"<div></div>","th":1}"#.to_string(),
15704 hydration_mode: StateProjectionMode::Keys as i32,
15705 hydration_keys: vec!["show_hidden".to_string()],
15706 ..Default::default()
15707 },
15708 );
15709 document.components.insert(
15710 "comp-hidden".to_string(),
15711 webui_protocol::ComponentData {
15712 template_json: r#"{"h":"<p>hidden</p>","th":1}"#.to_string(),
15713 css: ".hidden{display:block}".to_string(),
15714 hydration_mode: StateProjectionMode::Keys as i32,
15715 hydration_keys: vec!["hidden_count".to_string()],
15716 ..Default::default()
15717 },
15718 );
15719 document.populate_style_closures(&["index.html"]);
15720 let protocol = Protocol::new(document);
15721 let handler = WebUIHandler::with_plugin(|| {
15722 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15723 });
15724 let mut writer = FlushTestWriter::default();
15725
15726 handler
15727 .render_streaming(
15728 &protocol,
15729 &test_json!({
15730 "show_hidden": false,
15731 "hidden_count": 7,
15732 "unrelated": "private",
15733 }),
15734 &RenderOptions::new("index.html", "/"),
15735 &mut writer,
15736 )
15737 .unwrap();
15738
15739 let boundary = |index: usize| -> &str {
15740 &writer.output[writer.flushes[index]..writer.flushes[index + 1]]
15741 };
15742 let first = boundary(0);
15743 let second = boundary(1);
15744
15745 assert!(first.contains(r#""comp-a":"#), "first: {first}");
15746 assert!(first.contains(r#""comp-hidden":"#), "first: {first}");
15747 assert!(first.contains(r#""show_hidden":false"#), "first: {first}");
15748 assert!(first.contains(r#""hidden_count":7"#), "first: {first}");
15749 assert!(first.contains(r#""inventory":"01""#), "first: {first}");
15750 assert!(
15751 first.contains(r#"<script type="importmap""#),
15752 "first: {first}"
15753 );
15754 assert!(
15755 !first.contains("unrelated"),
15756 "first leaked unrelated state: {first}"
15757 );
15758
15759 assert!(
15760 !second.contains(r#""templates""#),
15761 "second re-sent metadata: {second}"
15762 );
15763 assert!(
15764 !second.contains(r#"<script type="importmap""#),
15765 "second re-sent CSS: {second}"
15766 );
15767 assert!(second.contains(r#""hidden_count":7"#), "second: {second}");
15768 assert!(second.contains(r#""inventory":"02""#), "second: {second}");
15769 assert_eq!(
15770 writer
15771 .output
15772 .matches(r#"<script type="importmap">"#)
15773 .count(),
15774 1,
15775 "import-map output: {}",
15776 writer.output
15777 );
15778 }
15779
15780 #[test]
15781 fn streaming_flush_count_is_boundaries_plus_one() {
15782 for &count in &[1usize, 3, 10, 100] {
15783 let hosts: Vec<&str> = (0..count)
15784 .map(|index| if index % 2 == 0 { "comp-a" } else { "comp-b" })
15785 .collect();
15786 let protocol = disjoint_streaming_protocol(&hosts);
15787 let handler = WebUIHandler::with_plugin(|| {
15788 Box::new(crate::plugin::webui::WebUIHydrationPlugin::new())
15789 });
15790 let mut writer = FlushTestWriter::default();
15791 handler
15792 .render_streaming(
15793 &protocol,
15794 &test_json!({ "a_count": 1, "b_count": 2 }),
15795 &RenderOptions::new("index.html", "/"),
15796 &mut writer,
15797 )
15798 .unwrap();
15799 assert_eq!(
15800 writer.flushes.len(),
15801 count + 2,
15802 "expected bootstrap + boundaries + terminal flushes for {count} boundaries"
15803 );
15804 }
15805 }
15806
15807 #[test]
15808 fn checkpoint_state_key_scratch_reuses_allocation_and_stays_local() {
15809 let mut fragments = HashMap::new();
15810 fragments.insert("comp-a".to_string(), FragmentList::default());
15811 fragments.insert("comp-b".to_string(), FragmentList::default());
15812 let mut protocol = WebUIProtocol::new(fragments);
15813 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
15814 protocol.components.insert(
15815 "comp-a".to_string(),
15816 webui_protocol::ComponentData {
15817 hydration_mode: StateProjectionMode::Keys as i32,
15818 hydration_keys: vec!["a".to_string(), "shared".to_string()],
15819 ..Default::default()
15820 },
15821 );
15822 protocol.components.insert(
15823 "comp-b".to_string(),
15824 webui_protocol::ComponentData {
15825 hydration_mode: StateProjectionMode::Keys as i32,
15826 hydration_keys: vec!["b".to_string(), "shared".to_string()],
15827 ..Default::default()
15828 },
15829 );
15830
15831 let protocol = Protocol::new(protocol);
15832 let component_index = protocol.component_index();
15833 let reachability = protocol.component_reachability();
15834 let mut scratch = Vec::with_capacity(INITIAL_KEY_CAPACITY);
15835 assert!(!collect_hydration_key_ids_into(
15836 protocol.protocol(),
15837 reachability,
15838 [component_index["comp-a"]],
15839 &mut scratch,
15840 ));
15841 let keys = scratch
15842 .iter()
15843 .filter_map(|id| reachability.hydration_key(*id))
15844 .collect::<Vec<_>>();
15845 assert_eq!(keys, ["a", "shared"]);
15846 let pointer = scratch.as_ptr();
15847 let capacity = scratch.capacity();
15848
15849 assert!(!collect_hydration_key_ids_into(
15850 protocol.protocol(),
15851 reachability,
15852 [component_index["comp-b"]],
15853 &mut scratch,
15854 ));
15855 let keys = scratch
15856 .iter()
15857 .filter_map(|id| reachability.hydration_key(*id))
15858 .collect::<Vec<_>>();
15859 assert_eq!(keys, ["b", "shared"]);
15860 assert_eq!(scratch.as_ptr(), pointer);
15861 assert_eq!(scratch.capacity(), capacity);
15862 }
15863}