1use prost::Message;
12use std::collections::{HashMap, HashSet};
13use std::fmt;
14use std::io;
15use thiserror::Error;
16
17pub mod plugin;
19
20#[cfg(feature = "projection-manifest")]
22pub mod projection_manifest;
23
24pub mod attrs;
26
27pub mod proto {
29 include!("gen_webui.rs");
30}
31
32pub use plugin::FastElementData;
34pub use plugin::WebUIElementData;
35pub use proto::*;
36
37pub type WebUIProtocol = WebUiProtocol;
40pub type WebUIFragment = WebUiFragment;
41pub type WebUIFragmentRaw = WebUiFragmentRaw;
42pub type WebUIFragmentComponent = WebUiFragmentComponent;
43pub type WebUIFragmentFor = WebUiFragmentFor;
44pub type WebUIFragmentSignal = WebUiFragmentSignal;
45pub type WebUIFragmentIf = WebUiFragmentIf;
46pub type WebUIFragmentAttribute = WebUiFragmentAttribute;
47pub type WebUIFragmentPlugin = WebUiFragmentPlugin;
48pub type WebUIFragmentRoute = WebUiFragmentRoute;
49pub type WebUIFragmentOutlet = WebUiFragmentOutlet;
50pub type WebUIFragmentBoundary = WebUiFragmentBoundary;
51pub type ComponentData = proto::ComponentData;
52pub type ComponentStyleClosure = proto::ComponentStyleClosure;
53pub type StyleChunk = proto::StyleChunk;
54
55pub type WebUIFragmentRecords = HashMap<String, FragmentList>;
57
58#[derive(Debug, Error)]
59pub enum ProtocolError {
60 #[error("IO error: {0}")]
61 Io(#[from] io::Error),
62
63 #[error("Protocol validation error: {0}")]
64 Validation(String),
65}
66
67pub type Result<T> = std::result::Result<T, ProtocolError>;
68
69impl fmt::Display for ComparisonOperator {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 ComparisonOperator::GreaterThan => write!(f, ">"),
75 ComparisonOperator::LessThan => write!(f, "<"),
76 ComparisonOperator::Equal => write!(f, "=="),
77 ComparisonOperator::NotEqual => write!(f, "!="),
78 ComparisonOperator::GreaterThanOrEqual => write!(f, ">="),
79 ComparisonOperator::LessThanOrEqual => write!(f, "<="),
80 ComparisonOperator::Unspecified => write!(f, "?"),
81 }
82 }
83}
84
85impl fmt::Display for LogicalOperator {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 LogicalOperator::And => write!(f, "&&"),
89 LogicalOperator::Or => write!(f, "||"),
90 LogicalOperator::Unspecified => write!(f, "?"),
91 }
92 }
93}
94
95impl fmt::Display for ConditionExpr {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match &self.expr {
98 Some(condition_expr::Expr::Identifier(id)) => write!(f, "{}", id.value),
99 Some(condition_expr::Expr::Predicate(pred)) => {
100 let op = ComparisonOperator::try_from(pred.operator)
101 .unwrap_or(ComparisonOperator::Unspecified);
102 write!(f, "{} {} {}", pred.left, op, pred.right)
103 }
104 Some(condition_expr::Expr::Not(not)) => match ¬.condition {
105 Some(inner) => write!(f, "!({})", inner),
106 None => write!(f, "!(?)"),
107 },
108 Some(condition_expr::Expr::Compound(compound)) => {
109 let op =
110 LogicalOperator::try_from(compound.op).unwrap_or(LogicalOperator::Unspecified);
111 let left_str = compound
112 .left
113 .as_ref()
114 .map(|l| l.to_string())
115 .unwrap_or_else(|| "?".to_string());
116 let right_str = compound
117 .right
118 .as_ref()
119 .map(|r| r.to_string())
120 .unwrap_or_else(|| "?".to_string());
121 write!(f, "({} {} {})", left_str, op, right_str)
122 }
123 None => write!(f, "<empty>"),
124 }
125 }
126}
127
128impl WebUiFragment {
131 pub fn raw(value: impl Into<String>) -> Self {
133 Self {
134 fragment: Some(web_ui_fragment::Fragment::Raw(WebUiFragmentRaw {
135 value: value.into(),
136 })),
137 }
138 }
139
140 pub fn component(fragment_id: impl Into<String>) -> Self {
142 Self {
143 fragment: Some(web_ui_fragment::Fragment::Component(
144 WebUiFragmentComponent {
145 fragment_id: fragment_id.into(),
146 },
147 )),
148 }
149 }
150
151 pub fn for_loop(
153 item: impl Into<String>,
154 collection: impl Into<String>,
155 fragment_id: impl Into<String>,
156 ) -> Self {
157 Self {
158 fragment: Some(web_ui_fragment::Fragment::ForLoop(WebUiFragmentFor {
159 item: item.into(),
160 collection: collection.into(),
161 fragment_id: fragment_id.into(),
162 })),
163 }
164 }
165
166 pub fn signal(value: impl Into<String>, raw: bool) -> Self {
168 Self {
169 fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
170 value: value.into(),
171 raw,
172 raw_text_context: false,
173 })),
174 }
175 }
176
177 pub fn raw_text_signal(value: impl Into<String>, raw: bool) -> Self {
197 Self {
198 fragment: Some(web_ui_fragment::Fragment::Signal(WebUiFragmentSignal {
199 value: value.into(),
200 raw,
201 raw_text_context: true,
202 })),
203 }
204 }
205
206 pub fn if_cond(condition: ConditionExpr, fragment_id: impl Into<String>) -> Self {
208 Self {
209 fragment: Some(web_ui_fragment::Fragment::IfCond(WebUiFragmentIf {
210 condition: Some(condition),
211 fragment_id: fragment_id.into(),
212 })),
213 }
214 }
215
216 pub fn boundary(
221 declaration_id: u32,
222 owner_fragment_id: impl Into<String>,
223 name: impl Into<String>,
224 key: Option<String>,
225 ) -> Self {
226 Self {
227 fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary {
228 declaration_id,
229 owner_fragment_id: owner_fragment_id.into(),
230 name: name.into(),
231 key,
232 may_repeat: false,
233 phase: BoundaryPhase::Start as i32,
234 })),
235 }
236 }
237
238 #[must_use]
240 pub fn boundary_end(declaration_id: u32) -> Self {
241 Self {
242 fragment: Some(web_ui_fragment::Fragment::Boundary(WebUiFragmentBoundary {
243 declaration_id,
244 phase: BoundaryPhase::End as i32,
245 ..Default::default()
246 })),
247 }
248 }
249
250 pub fn attribute(name: impl Into<String>, value: impl Into<String>) -> Self {
252 Self {
253 fragment: Some(web_ui_fragment::Fragment::Attribute(
254 WebUiFragmentAttribute {
255 name: name.into(),
256 value: value.into(),
257 ..Default::default()
258 },
259 )),
260 }
261 }
262
263 pub fn attribute_template(name: impl Into<String>, template: impl Into<String>) -> Self {
265 Self {
266 fragment: Some(web_ui_fragment::Fragment::Attribute(
267 WebUiFragmentAttribute {
268 name: name.into(),
269 template: template.into(),
270 ..Default::default()
271 },
272 )),
273 }
274 }
275
276 pub fn attribute_complex(name: impl Into<String>, value: impl Into<String>) -> Self {
278 Self {
279 fragment: Some(web_ui_fragment::Fragment::Attribute(
280 WebUiFragmentAttribute {
281 name: name.into(),
282 value: value.into(),
283 complex: true,
284 ..Default::default()
285 },
286 )),
287 }
288 }
289
290 pub fn attribute_boolean(name: impl Into<String>, condition_tree: ConditionExpr) -> Self {
292 Self {
293 fragment: Some(web_ui_fragment::Fragment::Attribute(
294 WebUiFragmentAttribute {
295 name: name.into(),
296 condition_tree: Some(condition_tree),
297 ..Default::default()
298 },
299 )),
300 }
301 }
302
303 pub fn plugin(data: Vec<u8>) -> Self {
306 Self {
307 fragment: Some(web_ui_fragment::Fragment::Plugin(WebUiFragmentPlugin {
308 data,
309 })),
310 }
311 }
312
313 pub fn route(path: impl Into<String>, fragment_id: impl Into<String>) -> Self {
315 Self {
316 fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
317 path: path.into(),
318 fragment_id: fragment_id.into(),
319 keep_alive: false,
320 ..Default::default()
321 })),
322 }
323 }
324
325 pub fn route_from(route: WebUiFragmentRoute) -> Self {
327 Self {
328 fragment: Some(web_ui_fragment::Fragment::Route(route)),
329 }
330 }
331
332 pub fn outlet() -> Self {
334 Self {
335 fragment: Some(web_ui_fragment::Fragment::Outlet(WebUiFragmentOutlet {})),
336 }
337 }
338}
339
340impl ConditionExpr {
341 pub fn identifier(value: impl Into<String>) -> Self {
343 Self {
344 expr: Some(condition_expr::Expr::Identifier(IdentifierCondition {
345 value: value.into(),
346 })),
347 }
348 }
349
350 pub fn predicate(
352 left: impl Into<String>,
353 operator: ComparisonOperator,
354 right: impl Into<String>,
355 ) -> Self {
356 Self {
357 expr: Some(condition_expr::Expr::Predicate(Predicate {
358 left: left.into(),
359 operator: operator as i32,
360 right: right.into(),
361 })),
362 }
363 }
364
365 pub fn negated(inner: ConditionExpr) -> Self {
367 Self {
368 expr: Some(condition_expr::Expr::Not(Box::new(NotCondition {
369 condition: Some(Box::new(inner)),
370 }))),
371 }
372 }
373
374 pub fn compound(left: ConditionExpr, op: LogicalOperator, right: ConditionExpr) -> Self {
376 Self {
377 expr: Some(condition_expr::Expr::Compound(Box::new(
378 CompoundCondition {
379 left: Some(Box::new(left)),
380 op: op as i32,
381 right: Some(Box::new(right)),
382 },
383 ))),
384 }
385 }
386}
387
388enum StyleClosureOp<'a> {
391 Fragment(&'a str),
392 Component(&'a str),
393}
394
395#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub struct StyleClosureUnit<'a> {
402 pub name: &'a str,
404 pub resource: Option<&'a str>,
406 pub chunk: Option<u32>,
408}
409
410impl CssStrategy {
411 #[must_use]
417 pub fn wire_name(self) -> &'static str {
418 match self {
419 CssStrategy::Link => "link",
420 CssStrategy::Style => "style",
421 CssStrategy::Module => "module",
422 }
423 }
424}
425
426impl WebUiProtocol {
427 #[must_use]
429 pub fn component_uses_shadow_dom(&self, tag_name: &str) -> bool {
430 self.components
431 .get(tag_name)
432 .is_some_and(|component| component.uses_shadow_dom)
433 }
434
435 #[must_use]
441 pub fn component_style_resource(&self, tag_name: &str) -> Option<&str> {
442 let component = self.components.get(tag_name)?;
443 let resource = match self.css_strategy() {
444 CssStrategy::Link => component.css_href.as_str(),
445 CssStrategy::Style | CssStrategy::Module => component.css.as_str(),
446 };
447 (!resource.is_empty()).then_some(resource)
448 }
449
450 #[must_use]
452 pub fn style_closure(&self, root: &str) -> Option<&[String]> {
453 self.style_closures
454 .get(root)
455 .map(|closure| closure.component_tags.as_slice())
456 }
457
458 pub fn populate_style_closures(&mut self, entry_fragments: &[&str]) {
465 self.style_chunks.clear();
468 let mut roots: Vec<String> = entry_fragments
469 .iter()
470 .map(|root| (*root).to_string())
471 .collect();
472 roots.extend(
473 self.components
474 .keys()
475 .filter(|tag| self.fragments.contains_key(*tag))
476 .cloned(),
477 );
478 roots.sort_unstable();
479 roots.dedup();
480
481 let has_style_resources = match self.css_strategy() {
482 CssStrategy::Link => self
483 .components
484 .values()
485 .any(|component| !component.css_href.is_empty()),
486 CssStrategy::Style | CssStrategy::Module => self
487 .components
488 .values()
489 .any(|component| !component.css.is_empty()),
490 };
491 if !has_style_resources {
492 self.style_closures = roots
493 .into_iter()
494 .map(|root| (root, ComponentStyleClosure::default()))
495 .collect();
496 return;
497 }
498
499 self.style_closures = roots
500 .into_iter()
501 .map(|root| {
502 let closure = self.build_style_closure(&root);
503 (root, closure)
504 })
505 .collect();
506 }
507
508 fn build_style_closure<'a>(&'a self, root: &'a str) -> ComponentStyleClosure {
509 let mut component_tags = Vec::new();
510 let mut seen_styles = HashSet::new();
511 let mut visited_fragments = HashSet::new();
512 let mut work = Vec::new();
513
514 if self.component_style_resource(root).is_some() {
517 seen_styles.insert(root);
518 component_tags.push(root.to_string());
519 }
520 work.push(StyleClosureOp::Fragment(root));
521
522 while let Some(op) = work.pop() {
523 match op {
524 StyleClosureOp::Fragment(fragment_id) => {
525 if !visited_fragments.insert(fragment_id) {
526 continue;
527 }
528 let Some(fragment_list) = self.fragments.get(fragment_id) else {
529 continue;
530 };
531 for fragment in fragment_list.fragments.iter().rev() {
532 match fragment.fragment.as_ref() {
533 Some(web_ui_fragment::Fragment::Component(component)) => {
534 work.push(StyleClosureOp::Component(&component.fragment_id));
535 }
536 Some(web_ui_fragment::Fragment::ForLoop(for_loop)) => {
537 work.push(StyleClosureOp::Fragment(&for_loop.fragment_id));
538 }
539 Some(web_ui_fragment::Fragment::IfCond(if_cond)) => {
540 work.push(StyleClosureOp::Fragment(&if_cond.fragment_id));
541 }
542 Some(web_ui_fragment::Fragment::Attribute(attribute))
543 if !attribute.template.is_empty() =>
544 {
545 work.push(StyleClosureOp::Fragment(&attribute.template));
546 }
547 _ => {}
548 }
549 }
550 }
551 StyleClosureOp::Component(tag_name) => {
552 if self.component_uses_shadow_dom(tag_name) {
553 continue;
554 }
555 if self.component_style_resource(tag_name).is_some()
556 && seen_styles.insert(tag_name)
557 {
558 component_tags.push(tag_name.to_string());
559 }
560 work.push(StyleClosureOp::Fragment(tag_name));
561 }
562 }
563 }
564
565 ComponentStyleClosure {
566 component_tags,
567 style_chunks: Vec::new(),
568 }
569 }
570
571 #[must_use]
577 pub fn style_chunk_resource(&self, chunk: u32) -> Option<(&str, &str)> {
578 let chunk = self.style_chunks.get(chunk as usize)?;
579 let resource = match self.css_strategy() {
580 CssStrategy::Link => chunk.css_href.as_str(),
581 CssStrategy::Style | CssStrategy::Module => chunk.css.as_str(),
582 };
583 (!resource.is_empty()).then_some((chunk.name.as_str(), resource))
584 }
585
586 #[must_use]
588 pub fn style_chunk_members(&self, chunk: u32) -> Option<&[String]> {
589 self.style_chunks
590 .get(chunk as usize)
591 .map(|chunk| chunk.component_tags.as_slice())
592 }
593
594 #[must_use]
607 pub fn style_chunk_index(&self) -> HashMap<&str, u32> {
608 let mut index = HashMap::new();
609 for (position, chunk) in self.style_chunks.iter().enumerate() {
610 let Ok(position) = u32::try_from(position) else {
611 break;
612 };
613 index.reserve(chunk.component_tags.len());
614 for tag in &chunk.component_tags {
615 index.insert(tag.as_str(), position);
616 }
617 }
618 index
619 }
620
621 #[must_use]
626 pub fn style_closure_chunks(&self, root: &str) -> Option<&[u32]> {
627 self.style_closures
628 .get(root)
629 .map(|closure| closure.style_chunks.as_slice())
630 }
631
632 #[must_use]
637 pub fn style_closure_unit_count(closure: &ComponentStyleClosure) -> usize {
638 if closure.style_chunks.is_empty() {
639 closure.component_tags.len()
640 } else {
641 closure.style_chunks.len()
642 }
643 }
644
645 #[must_use]
658 pub fn style_closure_unit<'a>(
659 &'a self,
660 closure: &'a ComponentStyleClosure,
661 chunk_index: &HashMap<&str, u32>,
662 position: usize,
663 ) -> Option<StyleClosureUnit<'a>> {
664 let chunk = if closure.style_chunks.is_empty() {
665 let tag = closure.component_tags.get(position)?.as_str();
666 let fallback = || {
667 Some(StyleClosureUnit {
668 name: tag,
669 resource: self.component_style_resource(tag),
670 chunk: None,
671 })
672 };
673 let Some(index) = chunk_index.get(tag).copied() else {
674 return fallback();
675 };
676 let Some(members) = self.style_chunk_members(index) else {
677 return fallback();
678 };
679 let Some(member_position) = members.iter().position(|member| member == tag) else {
680 return fallback();
681 };
682 let Some(start) = position.checked_sub(member_position) else {
683 return fallback();
684 };
685 let Some(end) = start.checked_add(members.len()) else {
686 return fallback();
687 };
688 if closure.component_tags.get(start..end) != Some(members) {
689 return fallback();
690 }
691 index
692 } else {
693 *closure.style_chunks.get(position)?
694 };
695 let record = self.style_chunks.get(chunk as usize)?;
696 Some(StyleClosureUnit {
697 name: record.name.as_str(),
698 resource: self
699 .style_chunk_resource(chunk)
700 .map(|(_, resource)| resource),
701 chunk: Some(chunk),
702 })
703 }
704
705 pub fn new(fragments: WebUIFragmentRecords) -> Self {
707 Self {
708 fragments,
709 tokens: Vec::new(),
710 components: HashMap::new(),
711 css_strategy: 0,
712 initial_state_strategy: InitialStateStrategy::Full as i32,
713 module_preloads: Vec::new(),
714 component_render_css: String::new(),
715 style_closures: HashMap::new(),
716 style_chunks: Vec::new(),
717 component_asset_style_preloads: Vec::new(),
718 }
719 }
720
721 pub fn with_tokens(fragments: WebUIFragmentRecords, tokens: Vec<String>) -> Self {
723 Self {
724 fragments,
725 tokens,
726 components: HashMap::new(),
727 css_strategy: 0,
728 initial_state_strategy: InitialStateStrategy::Full as i32,
729 module_preloads: Vec::new(),
730 component_render_css: String::new(),
731 style_closures: HashMap::new(),
732 style_chunks: Vec::new(),
733 component_asset_style_preloads: Vec::new(),
734 }
735 }
736}
737
738impl WebUiProtocol {
741 fn validate_protocol(protocol: Self) -> Result<Self> {
743 CssStrategy::try_from(protocol.css_strategy).map_err(|_| {
744 ProtocolError::Validation(format!(
745 "unknown CSS strategy value: {}",
746 protocol.css_strategy
747 ))
748 })?;
749 let fragments = &protocol.fragments;
750
751 let invalid_ref = fragments.iter().find_map(|(_, fragment_list)| {
752 fragment_list
753 .fragments
754 .iter()
755 .find_map(|frag| match frag.fragment.as_ref() {
756 Some(web_ui_fragment::Fragment::Component(comp))
757 if !fragments.contains_key(&comp.fragment_id) =>
758 {
759 Some(ProtocolError::Validation(format!(
760 "Component references non-existent fragment ID: {}",
761 comp.fragment_id
762 )))
763 }
764 Some(web_ui_fragment::Fragment::ForLoop(fl))
765 if !fragments.contains_key(&fl.fragment_id) =>
766 {
767 Some(ProtocolError::Validation(format!(
768 "For loop references non-existent fragment ID: {}",
769 fl.fragment_id
770 )))
771 }
772 Some(web_ui_fragment::Fragment::IfCond(ic))
773 if !fragments.contains_key(&ic.fragment_id) =>
774 {
775 Some(ProtocolError::Validation(format!(
776 "If condition references non-existent fragment ID: {}",
777 ic.fragment_id
778 )))
779 }
780 Some(web_ui_fragment::Fragment::Attribute(attr))
781 if !attr.template.is_empty() && !fragments.contains_key(&attr.template) =>
782 {
783 Some(ProtocolError::Validation(format!(
784 "Attribute references non-existent template fragment ID: {}",
785 attr.template
786 )))
787 }
788 Some(web_ui_fragment::Fragment::Boundary(boundary))
789 if boundary.phase() == BoundaryPhase::Start
790 && !fragments.contains_key(&boundary.owner_fragment_id) =>
791 {
792 Some(Self::missing_boundary_owner_error(boundary))
793 }
794 Some(web_ui_fragment::Fragment::Route(route)) => {
795 Self::validate_route_references(route, fragments)
796 }
797 _ => None,
798 })
799 });
800
801 if let Some(err) = invalid_ref {
802 return Err(err);
803 }
804
805 let mut declaration_ids = HashSet::new();
806 let mut owner_names = HashSet::new();
807 for fragment_list in fragments.values() {
808 let mut open: Option<u32> = None;
809 for fragment in &fragment_list.fragments {
810 let Some(web_ui_fragment::Fragment::Boundary(boundary)) =
811 fragment.fragment.as_ref()
812 else {
813 continue;
814 };
815 if boundary.phase() == BoundaryPhase::End {
816 match open.take() {
817 Some(declaration_id) if declaration_id == boundary.declaration_id => {}
818 _ => return Err(Self::unbalanced_boundary_tape_error(boundary)),
819 }
820 continue;
821 }
822 if open.is_some() {
823 return Err(Self::unbalanced_boundary_tape_error(boundary));
824 }
825 open = Some(boundary.declaration_id);
826 if boundary.name.trim().is_empty() {
827 return Err(Self::empty_boundary_name_error(boundary));
828 }
829 if boundary
830 .key
831 .as_ref()
832 .is_some_and(|key| key.trim().is_empty())
833 {
834 return Err(Self::empty_boundary_key_error(boundary));
835 }
836 if !declaration_ids.insert(boundary.declaration_id) {
837 return Err(Self::duplicate_boundary_id_error(boundary));
838 }
839 if !owner_names.insert((&boundary.owner_fragment_id, &boundary.name)) {
840 return Err(Self::duplicate_boundary_name_error(boundary));
841 }
842 }
843 if let Some(declaration_id) = open {
844 return Err(Self::unterminated_boundary_tape_error(declaration_id));
845 }
846 }
847
848 Ok(protocol)
849 }
850
851 fn validate_route_references(
852 root: &WebUiFragmentRoute,
853 fragments: &WebUIFragmentRecords,
854 ) -> Option<ProtocolError> {
855 let mut pending = vec![root];
856 while let Some(route) = pending.pop() {
857 for (kind, fragment_id) in [
858 ("component", route.fragment_id.as_str()),
859 ("content", route.content_fragment_id.as_str()),
860 ] {
861 if !fragment_id.is_empty() && !fragments.contains_key(fragment_id) {
862 return Some(Self::missing_route_reference_error(kind, fragment_id));
863 }
864 }
865 pending.extend(route.children.iter());
866 }
867 None
868 }
869
870 #[cold]
871 #[inline(never)]
872 fn unbalanced_boundary_tape_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
873 ProtocolError::Validation(format!(
874 "Boundary declaration {} has an unbalanced inline tape marker",
875 boundary.declaration_id
876 ))
877 }
878
879 #[cold]
880 #[inline(never)]
881 fn unterminated_boundary_tape_error(declaration_id: u32) -> ProtocolError {
882 ProtocolError::Validation(format!(
883 "Boundary declaration {declaration_id} is missing its end marker"
884 ))
885 }
886
887 #[cold]
888 #[inline(never)]
889 fn missing_boundary_owner_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
890 ProtocolError::Validation(format!(
891 "Boundary declaration {} references non-existent owner fragment ID: {}",
892 boundary.declaration_id, boundary.owner_fragment_id
893 ))
894 }
895
896 #[cold]
897 #[inline(never)]
898 fn empty_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
899 ProtocolError::Validation(format!(
900 "Boundary declaration {} has an empty authored name",
901 boundary.declaration_id
902 ))
903 }
904
905 #[cold]
906 #[inline(never)]
907 fn empty_boundary_key_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
908 ProtocolError::Validation(format!(
909 "Boundary declaration {} has an empty key expression",
910 boundary.declaration_id
911 ))
912 }
913
914 #[cold]
915 #[inline(never)]
916 fn duplicate_boundary_id_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
917 ProtocolError::Validation(format!(
918 "Duplicate boundary declaration ID: {}",
919 boundary.declaration_id
920 ))
921 }
922
923 #[cold]
924 #[inline(never)]
925 fn duplicate_boundary_name_error(boundary: &WebUiFragmentBoundary) -> ProtocolError {
926 ProtocolError::Validation(format!(
927 "Duplicate boundary name '{}' in owner fragment '{}'",
928 boundary.name, boundary.owner_fragment_id
929 ))
930 }
931
932 #[cold]
933 #[inline(never)]
934 fn missing_route_reference_error(kind: &str, fragment_id: &str) -> ProtocolError {
935 ProtocolError::Validation(format!(
936 "Route {kind} references non-existent fragment ID: {fragment_id}"
937 ))
938 }
939
940 pub fn to_json_pretty(&self) -> std::result::Result<String, serde_json::Error> {
942 serde_json::to_string_pretty(self)
943 }
944
945 pub fn to_protobuf(&self) -> Result<Vec<u8>> {
947 let len = self.encoded_len();
948 let mut buf = Vec::with_capacity(len);
949 self.encode(&mut buf)
950 .map_err(|e| ProtocolError::Validation(format!("Protobuf encode error: {e}")))?;
951 Ok(buf)
952 }
953
954 pub fn from_protobuf(bytes: &[u8]) -> Result<Self> {
956 let protocol = Self::decode(bytes)
957 .map_err(|e| ProtocolError::Validation(format!("Protobuf decode error: {e}")))?;
958 Self::validate_protocol(protocol)
959 }
960
961 pub fn from_protobuf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
963 let bytes = std::fs::read(path)?;
964 Self::from_protobuf(&bytes)
965 }
966
967 pub fn to_protobuf_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
969 let bytes = self.to_protobuf()?;
970 std::fs::write(path, bytes)?;
971 Ok(())
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 fn sample_protocol() -> WebUIProtocol {
980 let mut fragments = HashMap::new();
981 fragments.insert(
982 "index.html".to_string(),
983 FragmentList {
984 fragments: vec![
985 WebUIFragment::raw("Hello, WebUI!\n"),
986 WebUIFragment::for_loop("person", "people", "for-1"),
987 WebUIFragment::signal("description", true),
988 WebUIFragment::if_cond(ConditionExpr::identifier("contact"), "if-1"),
989 ],
990 contains_boundary: false,
991 },
992 );
993 fragments.insert(
994 "for-1".to_string(),
995 FragmentList {
996 fragments: vec![WebUIFragment::signal("person.name", false)],
997 contains_boundary: false,
998 },
999 );
1000 fragments.insert(
1001 "if-1".to_string(),
1002 FragmentList {
1003 fragments: vec![WebUIFragment::component("contact-card")],
1004 contains_boundary: false,
1005 },
1006 );
1007 fragments.insert(
1008 "contact-card".to_string(),
1009 FragmentList {
1010 fragments: vec![
1011 WebUIFragment::raw("Hello, "),
1012 WebUIFragment::signal("name", false),
1013 ],
1014 contains_boundary: false,
1015 },
1016 );
1017 WebUIProtocol::new(fragments)
1018 }
1019
1020 fn add_style_component(
1021 protocol: &mut WebUIProtocol,
1022 tag: &str,
1023 uses_shadow_dom: bool,
1024 has_css: bool,
1025 ) {
1026 protocol
1027 .fragments
1028 .entry(tag.to_string())
1029 .or_insert_with(|| FragmentList {
1030 fragments: Vec::new(),
1031 contains_boundary: false,
1032 });
1033 protocol.components.insert(
1034 tag.to_string(),
1035 ComponentData {
1036 css: has_css.then(|| format!(".{tag}{{}}")).unwrap_or_default(),
1037 uses_shadow_dom,
1038 ..Default::default()
1039 },
1040 );
1041 }
1042
1043 #[test]
1044 fn style_closure_preserves_order_deduplicates_and_cuts_at_shadow() {
1045 let mut fragments = HashMap::new();
1046 fragments.insert(
1047 "index.html".to_string(),
1048 FragmentList {
1049 fragments: vec![
1050 WebUIFragment::component("light-a"),
1051 WebUIFragment::component("light-b"),
1052 WebUIFragment::component("no-css"),
1053 WebUIFragment::component("shadow-cut"),
1054 WebUIFragment::component("after-cut"),
1055 ],
1056 contains_boundary: false,
1057 },
1058 );
1059 fragments.insert(
1060 "light-a".to_string(),
1061 FragmentList {
1062 fragments: vec![WebUIFragment::component("shared-light")],
1063 contains_boundary: false,
1064 },
1065 );
1066 fragments.insert(
1067 "light-b".to_string(),
1068 FragmentList {
1069 fragments: vec![WebUIFragment::component("shared-light")],
1070 contains_boundary: false,
1071 },
1072 );
1073 fragments.insert(
1074 "no-css".to_string(),
1075 FragmentList {
1076 fragments: vec![WebUIFragment::component("styled-descendant")],
1077 contains_boundary: false,
1078 },
1079 );
1080 fragments.insert(
1081 "shadow-cut".to_string(),
1082 FragmentList {
1083 fragments: vec![WebUIFragment::component("shadow-descendant")],
1084 contains_boundary: false,
1085 },
1086 );
1087 let mut protocol = WebUIProtocol::new(fragments);
1088 protocol.set_css_strategy(CssStrategy::Style);
1089 for tag in [
1090 "light-a",
1091 "light-b",
1092 "shared-light",
1093 "styled-descendant",
1094 "shadow-descendant",
1095 "after-cut",
1096 ] {
1097 add_style_component(&mut protocol, tag, false, true);
1098 }
1099 add_style_component(&mut protocol, "no-css", false, false);
1100 add_style_component(&mut protocol, "shadow-cut", true, true);
1101
1102 protocol.populate_style_closures(&["index.html"]);
1103
1104 assert_eq!(
1105 protocol.style_closure("index.html").expect("entry closure"),
1106 [
1107 "light-a",
1108 "shared-light",
1109 "light-b",
1110 "styled-descendant",
1111 "after-cut",
1112 ]
1113 );
1114 assert_eq!(
1115 protocol
1116 .style_closure("shadow-cut")
1117 .expect("Shadow closure"),
1118 ["shadow-cut", "shadow-descendant"]
1119 );
1120 }
1121
1122 #[test]
1123 fn style_closure_follows_dynamic_dependencies_but_not_routes() {
1124 let nested_route = WebUiFragmentRoute {
1125 fragment_id: "nested-route".to_string(),
1126 ..Default::default()
1127 };
1128 let route = WebUiFragmentRoute {
1129 fragment_id: "route-body".to_string(),
1130 children: vec![nested_route],
1131 pending_component: "route-pending".to_string(),
1132 error_component: "route-error".to_string(),
1133 ..Default::default()
1134 };
1135 let mut fragments = HashMap::new();
1136 fragments.insert(
1137 "index.html".to_string(),
1138 FragmentList {
1139 fragments: vec![
1140 WebUIFragment::for_loop("item", "items", "for-body"),
1141 WebUIFragment::if_cond(ConditionExpr::identifier("show"), "if-body"),
1142 WebUIFragment::attribute_template("title", "attribute-body"),
1143 WebUIFragment::route_from(route),
1144 ],
1145 contains_boundary: false,
1146 },
1147 );
1148 for (fragment, component) in [
1149 ("for-body", "for-card"),
1150 ("if-body", "if-card"),
1151 ("attribute-body", "attribute-card"),
1152 ] {
1153 fragments.insert(
1154 fragment.to_string(),
1155 FragmentList {
1156 fragments: vec![WebUIFragment::component(component)],
1157 contains_boundary: false,
1158 },
1159 );
1160 }
1161 let mut protocol = WebUIProtocol::new(fragments);
1162 protocol.set_css_strategy(CssStrategy::Style);
1163 for tag in [
1164 "for-card",
1165 "if-card",
1166 "attribute-card",
1167 "route-body",
1168 "nested-route",
1169 "route-pending",
1170 "route-error",
1171 ] {
1172 add_style_component(&mut protocol, tag, false, true);
1173 }
1174 protocol.fragments.insert(
1175 "route-body".to_string(),
1176 FragmentList {
1177 fragments: vec![WebUIFragment::outlet()],
1178 contains_boundary: false,
1179 },
1180 );
1181
1182 protocol.populate_style_closures(&["index.html"]);
1183
1184 assert_eq!(
1185 protocol.style_closure("index.html").expect("entry closure"),
1186 ["for-card", "if-card", "attribute-card"]
1187 );
1188 assert_eq!(
1189 protocol
1190 .style_closure("route-body")
1191 .expect("route body closure"),
1192 ["route-body"]
1193 );
1194 assert_eq!(
1195 protocol
1196 .style_closure("nested-route")
1197 .expect("nested route closure"),
1198 ["nested-route"]
1199 );
1200 }
1201
1202 #[test]
1203 fn style_closure_keeps_nested_routes_out_of_shadow_outlet_tree() {
1204 let child_route = WebUiFragmentRoute {
1205 fragment_id: "dashboard-page".to_string(),
1206 pending_component: "dashboard-pending".to_string(),
1207 error_component: "dashboard-error".to_string(),
1208 ..Default::default()
1209 };
1210 let route = WebUiFragmentRoute {
1211 fragment_id: "app-shell".to_string(),
1212 children: vec![child_route],
1213 pending_component: "shell-pending".to_string(),
1214 error_component: "shell-error".to_string(),
1215 ..Default::default()
1216 };
1217 let mut fragments = HashMap::new();
1218 fragments.insert(
1219 "index.html".to_string(),
1220 FragmentList {
1221 fragments: vec![WebUIFragment::route_from(route)],
1222 contains_boundary: false,
1223 },
1224 );
1225 fragments.insert(
1226 "app-shell".to_string(),
1227 FragmentList {
1228 fragments: vec![
1229 WebUIFragment::component("shell-header"),
1230 WebUIFragment::component("route-layout"),
1231 WebUIFragment::component("shell-footer"),
1232 ],
1233 contains_boundary: false,
1234 },
1235 );
1236 fragments.insert(
1237 "route-layout".to_string(),
1238 FragmentList {
1239 fragments: vec![WebUIFragment::outlet()],
1240 contains_boundary: false,
1241 },
1242 );
1243 fragments.insert(
1244 "dashboard-page".to_string(),
1245 FragmentList {
1246 fragments: vec![WebUIFragment::component("contact-card")],
1247 contains_boundary: false,
1248 },
1249 );
1250 let mut protocol = WebUIProtocol::new(fragments);
1251 protocol.set_css_strategy(CssStrategy::Style);
1252 for tag in [
1253 "shell-header",
1254 "route-layout",
1255 "shell-footer",
1256 "dashboard-page",
1257 "contact-card",
1258 "dashboard-pending",
1259 "dashboard-error",
1260 "shell-pending",
1261 "shell-error",
1262 ] {
1263 add_style_component(&mut protocol, tag, false, true);
1264 }
1265 add_style_component(&mut protocol, "app-shell", true, true);
1266
1267 protocol.populate_style_closures(&["index.html"]);
1268
1269 assert!(protocol
1270 .style_closure("index.html")
1271 .expect("entry closure")
1272 .is_empty());
1273 assert_eq!(
1274 protocol
1275 .style_closure("app-shell")
1276 .expect("Shadow shell closure"),
1277 ["app-shell", "shell-header", "route-layout", "shell-footer"]
1278 );
1279 assert_eq!(
1280 protocol
1281 .style_closure("dashboard-page")
1282 .expect("active route closure"),
1283 ["dashboard-page", "contact-card"]
1284 );
1285 }
1286
1287 #[test]
1288 fn style_closure_keeps_routes_out_of_nested_shadow_outlet_component() {
1289 let route = WebUiFragmentRoute {
1290 fragment_id: "light-shell".to_string(),
1291 children: vec![WebUiFragmentRoute {
1292 fragment_id: "dashboard-page".to_string(),
1293 ..Default::default()
1294 }],
1295 ..Default::default()
1296 };
1297 let mut fragments = HashMap::new();
1298 fragments.insert(
1299 "index.html".to_string(),
1300 FragmentList {
1301 fragments: vec![WebUIFragment::route_from(route)],
1302 contains_boundary: false,
1303 },
1304 );
1305 fragments.insert(
1306 "light-shell".to_string(),
1307 FragmentList {
1308 fragments: vec![WebUIFragment::component("shadow-layout")],
1309 contains_boundary: false,
1310 },
1311 );
1312 fragments.insert(
1313 "shadow-layout".to_string(),
1314 FragmentList {
1315 fragments: vec![WebUIFragment::outlet()],
1316 contains_boundary: false,
1317 },
1318 );
1319 fragments.insert(
1320 "dashboard-page".to_string(),
1321 FragmentList {
1322 fragments: vec![WebUIFragment::component("contact-card")],
1323 contains_boundary: false,
1324 },
1325 );
1326 let mut protocol = WebUIProtocol::new(fragments);
1327 protocol.set_css_strategy(CssStrategy::Style);
1328 for tag in ["light-shell", "dashboard-page", "contact-card"] {
1329 add_style_component(&mut protocol, tag, false, true);
1330 }
1331 add_style_component(&mut protocol, "shadow-layout", true, true);
1332
1333 protocol.populate_style_closures(&["index.html"]);
1334
1335 assert!(protocol
1336 .style_closure("index.html")
1337 .expect("entry closure")
1338 .is_empty());
1339 assert_eq!(
1340 protocol
1341 .style_closure("light-shell")
1342 .expect("route shell closure"),
1343 ["light-shell"]
1344 );
1345 assert_eq!(
1346 protocol
1347 .style_closure("shadow-layout")
1348 .expect("nested Shadow closure"),
1349 ["shadow-layout"]
1350 );
1351 assert_eq!(
1352 protocol
1353 .style_closure("dashboard-page")
1354 .expect("active route closure"),
1355 ["dashboard-page", "contact-card"]
1356 );
1357 }
1358
1359 #[test]
1360 fn style_closure_guards_same_tag_cycles_and_is_deterministic() {
1361 let mut fragments = HashMap::new();
1362 fragments.insert(
1363 "index.html".to_string(),
1364 FragmentList {
1365 fragments: vec![WebUIFragment::component("cycle-card")],
1366 contains_boundary: false,
1367 },
1368 );
1369 fragments.insert(
1370 "cycle-card".to_string(),
1371 FragmentList {
1372 fragments: vec![WebUIFragment::component("cycle-card")],
1373 contains_boundary: false,
1374 },
1375 );
1376 let mut protocol = WebUIProtocol::new(fragments);
1377 protocol.set_css_strategy(CssStrategy::Style);
1378 add_style_component(&mut protocol, "cycle-card", false, true);
1379
1380 protocol.populate_style_closures(&["index.html"]);
1381 let first = protocol.style_closures.clone();
1382 protocol.populate_style_closures(&["index.html"]);
1383
1384 assert_eq!(
1385 protocol.style_closure("index.html").expect("entry closure"),
1386 ["cycle-card"]
1387 );
1388 assert_eq!(
1389 protocol
1390 .style_closure("cycle-card")
1391 .expect("component closure"),
1392 ["cycle-card"]
1393 );
1394 assert_eq!(protocol.style_closures, first);
1395 }
1396
1397 #[test]
1398 fn style_closure_populates_empty_roots_without_resources() {
1399 let mut fragments = HashMap::new();
1400 fragments.insert(
1401 "index.html".to_string(),
1402 FragmentList {
1403 fragments: vec![WebUIFragment::component("light-shell")],
1404 contains_boundary: false,
1405 },
1406 );
1407 fragments.insert(
1408 "light-shell".to_string(),
1409 FragmentList {
1410 fragments: vec![WebUIFragment::component("shadow-card")],
1411 contains_boundary: false,
1412 },
1413 );
1414 fragments.insert("shadow-card".to_string(), FragmentList::default());
1415 let mut protocol = WebUIProtocol::new(fragments);
1416 add_style_component(&mut protocol, "light-shell", false, false);
1417 add_style_component(&mut protocol, "shadow-card", true, false);
1418
1419 protocol.populate_style_closures(&["index.html"]);
1420
1421 assert_eq!(protocol.style_closures.len(), 3);
1422 for root in ["index.html", "light-shell", "shadow-card"] {
1423 assert_eq!(protocol.style_closure(root), Some([].as_slice()));
1424 }
1425 }
1426
1427 #[test]
1428 fn style_closure_roundtrips() {
1429 let mut protocol = sample_protocol();
1430 protocol.set_css_strategy(CssStrategy::Link);
1431 protocol.components.insert(
1432 "contact-card".to_string(),
1433 ComponentData {
1434 css_href: "/contact-card.css".to_string(),
1435 ..Default::default()
1436 },
1437 );
1438 protocol.populate_style_closures(&["index.html"]);
1439
1440 let bytes = protocol.to_protobuf().expect("encode failed");
1441 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1442 assert_eq!(
1443 decoded.style_closure("index.html").expect("entry closure"),
1444 ["contact-card"]
1445 );
1446 }
1447
1448 #[test]
1449 fn current_json_requires_style_and_component_shadow_metadata() {
1450 let mut protocol = sample_protocol();
1451 protocol.components.insert(
1452 "my-card".to_string(),
1453 ComponentData {
1454 uses_shadow_dom: false,
1455 ..Default::default()
1456 },
1457 );
1458 protocol.populate_style_closures(&["index.html"]);
1459 let current = serde_json::to_value(&protocol).expect("JSON encode failed");
1460
1461 let mut missing_closures = current.clone();
1462 missing_closures
1463 .as_object_mut()
1464 .expect("protocol JSON object")
1465 .remove("style_closures");
1466 assert!(serde_json::from_value::<WebUIProtocol>(missing_closures).is_err());
1467
1468 let mut missing_shadow_dom = current;
1469 missing_shadow_dom["components"]["my-card"]
1470 .as_object_mut()
1471 .expect("component JSON object")
1472 .remove("uses_shadow_dom");
1473 assert!(serde_json::from_value::<WebUIProtocol>(missing_shadow_dom).is_err());
1474
1475 let mut legacy_root = serde_json::to_value(&protocol).expect("JSON encode failed");
1476 legacy_root["dom_strategy"] = serde_json::json!(0);
1477 assert!(serde_json::from_value::<WebUIProtocol>(legacy_root).is_err());
1478
1479 let mut legacy_component = serde_json::to_value(&protocol).expect("JSON encode failed");
1480 legacy_component["components"]["my-card"]["effective_dom_strategy"] = serde_json::json!(0);
1481 assert!(serde_json::from_value::<WebUIProtocol>(legacy_component).is_err());
1482 }
1483
1484 #[test]
1485 fn component_style_resource_uses_the_active_strategy_field() {
1486 let mut protocol = WebUIProtocol::new(HashMap::new());
1487 protocol.components.insert(
1488 "my-card".to_string(),
1489 ComponentData {
1490 css: ".card{}".to_string(),
1491 css_href: "/my-card.css".to_string(),
1492 ..Default::default()
1493 },
1494 );
1495
1496 protocol.set_css_strategy(CssStrategy::Link);
1497 assert_eq!(
1498 protocol.component_style_resource("my-card"),
1499 Some("/my-card.css")
1500 );
1501 protocol.set_css_strategy(CssStrategy::Style);
1502 assert_eq!(
1503 protocol.component_style_resource("my-card"),
1504 Some(".card{}")
1505 );
1506 protocol.set_css_strategy(CssStrategy::Module);
1507 assert_eq!(
1508 protocol.component_style_resource("my-card"),
1509 Some(".card{}")
1510 );
1511 assert_eq!(protocol.component_style_resource("missing-card"), None);
1512 }
1513
1514 #[test]
1515 fn declared_link_strategy_does_not_infer_module_from_css_bytes() {
1516 let mut protocol = WebUIProtocol::default();
1517 protocol.components.insert(
1518 "my-card".to_string(),
1519 ComponentData {
1520 css: ".card{}".to_string(),
1521 ..Default::default()
1522 },
1523 );
1524 assert_eq!(protocol.css_strategy(), CssStrategy::Link);
1525 assert_eq!(protocol.component_style_resource("my-card"), None);
1526 }
1527
1528 #[test]
1529 fn test_protobuf_roundtrip() {
1530 let protocol = sample_protocol();
1531 let bytes = protocol.to_protobuf().expect("encode failed");
1532 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1533 assert_eq!(protocol, decoded);
1534 }
1535
1536 #[test]
1537 fn test_protobuf_module_preloads_roundtrip_preserves_order() {
1538 let mut protocol = sample_protocol();
1542 protocol.module_preloads = vec![
1543 "/chunk-big.js".to_string(),
1544 "/chunk-mid.js".to_string(),
1545 "/chunk-small.js".to_string(),
1546 ];
1547
1548 let bytes = protocol.to_protobuf().expect("encode failed");
1549 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1550
1551 assert_eq!(decoded.module_preloads, protocol.module_preloads);
1552 assert_eq!(protocol, decoded);
1553 }
1554
1555 #[test]
1556 fn test_protobuf_no_module_preloads_is_absent_from_the_wire() {
1557 let protocol = sample_protocol();
1559 let bytes = protocol.to_protobuf().expect("encode failed");
1560 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1561
1562 assert!(decoded.module_preloads.is_empty());
1563 assert!(
1564 !bytes.windows(2).any(|w| w == [0x3a, 0x00]),
1565 "an empty repeated field must not be encoded"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_component_asset_style_preloads_roundtrip_preserves_generated_hrefs() {
1571 let mut protocol = sample_protocol();
1572 protocol.component_asset_style_preloads = vec![ComponentAssetStylePreload {
1573 root: "lazy-panel".to_string(),
1574 style_hrefs: vec![
1575 "/assets/lazy-panel-e5f6a7b8.css".to_string(),
1576 "/assets/shared-detail-10203040.css".to_string(),
1577 ],
1578 }];
1579
1580 let bytes = protocol.to_protobuf().expect("encode failed");
1581 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1582
1583 assert_eq!(
1584 decoded.component_asset_style_preloads,
1585 protocol.component_asset_style_preloads
1586 );
1587 assert_eq!(protocol, decoded);
1588 }
1589
1590 #[test]
1591 fn test_protobuf_boundary_fragment_roundtrip() {
1592 let mut protocol = sample_protocol();
1593 let main = protocol
1594 .fragments
1595 .get_mut("index.html")
1596 .expect("sample entry exists");
1597 main.fragments.push(WebUIFragment::boundary(
1598 7,
1599 "index.html",
1600 "weather shell",
1601 Some("forecast.id".to_string()),
1602 ));
1603 main.fragments
1604 .push(WebUIFragment::raw("<weather-panel></weather-panel>"));
1605 main.fragments.push(WebUIFragment::boundary_end(7));
1606 main.contains_boundary = true;
1607
1608 let bytes = protocol.to_protobuf().expect("encode failed");
1609 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
1610
1611 let markers: Vec<&WebUiFragmentBoundary> = decoded.fragments["index.html"]
1612 .fragments
1613 .iter()
1614 .filter_map(|fragment| match fragment.fragment.as_ref() {
1615 Some(web_ui_fragment::Fragment::Boundary(boundary)) => Some(boundary),
1616 _ => None,
1617 })
1618 .collect();
1619 assert_eq!(markers.len(), 2, "the tape survives roundtrip as a pair");
1620 let boundary = markers[0];
1621 assert_eq!(boundary.phase(), BoundaryPhase::Start);
1622 assert_eq!(boundary.declaration_id, 7);
1623 assert_eq!(boundary.owner_fragment_id, "index.html");
1624 assert_eq!(boundary.name, "weather shell");
1625 assert_eq!(boundary.key.as_deref(), Some("forecast.id"));
1626 assert_eq!(markers[1].phase(), BoundaryPhase::End);
1627 assert_eq!(markers[1].declaration_id, 7);
1628 assert!(decoded.fragments["index.html"].contains_boundary);
1629 assert_eq!(protocol, decoded);
1630 }
1631
1632 #[test]
1633 fn test_protocol_rejects_unbalanced_boundary_tape() {
1634 let tape_cases: [(&str, Vec<WebUIFragment>); 3] = [
1635 (
1636 "start without end",
1637 vec![WebUIFragment::boundary(0, "index.html", "ready", None)],
1638 ),
1639 ("end without start", vec![WebUIFragment::boundary_end(0)]),
1640 (
1641 "nested start",
1642 vec![
1643 WebUIFragment::boundary(0, "index.html", "outer", None),
1644 WebUIFragment::boundary(1, "index.html", "inner", None),
1645 WebUIFragment::boundary_end(1),
1646 WebUIFragment::boundary_end(0),
1647 ],
1648 ),
1649 ];
1650 for (label, fragments) in tape_cases {
1651 let protocol = WebUIProtocol::new(HashMap::from([(
1652 "index.html".to_string(),
1653 FragmentList {
1654 fragments,
1655 contains_boundary: true,
1656 },
1657 )]));
1658 let bytes = protocol.to_protobuf().expect("encode failed");
1659 let error =
1660 WebUIProtocol::from_protobuf(&bytes).expect_err("unbalanced tape must be rejected");
1661 assert!(
1662 error.to_string().contains("Boundary"),
1663 "{label}: unexpected error {error}"
1664 );
1665 }
1666 }
1667
1668 #[test]
1669 fn test_protocol_has_no_fixed_streaming_boundary_table() {
1670 let json = sample_protocol().to_json_pretty().expect("serialize JSON");
1671 assert!(!json.contains("streaming_boundaries"));
1672 assert!(!json.contains("StreamingBoundaryList"));
1673 }
1674
1675 #[test]
1676 fn test_protobuf_all_fragment_types() {
1677 let mut fragments = HashMap::new();
1678 fragments.insert(
1679 "main".to_string(),
1680 FragmentList {
1681 fragments: vec![
1682 WebUIFragment::raw("text"),
1683 WebUIFragment::component("comp"),
1684 WebUIFragment::for_loop("x", "xs", "loop"),
1685 WebUIFragment::signal("sig", true),
1686 WebUIFragment::if_cond(
1687 ConditionExpr::predicate("a", ComparisonOperator::GreaterThan, "1"),
1688 "cond",
1689 ),
1690 WebUIFragment::boundary(0, "main", "ready", None),
1691 WebUIFragment::boundary_end(0),
1692 ],
1693 contains_boundary: true,
1694 },
1695 );
1696 fragments.insert(
1697 "comp".to_string(),
1698 FragmentList {
1699 fragments: vec![WebUIFragment::raw("c")],
1700 contains_boundary: false,
1701 },
1702 );
1703 fragments.insert(
1704 "loop".to_string(),
1705 FragmentList {
1706 fragments: vec![WebUIFragment::raw("l")],
1707 contains_boundary: false,
1708 },
1709 );
1710 fragments.insert(
1711 "cond".to_string(),
1712 FragmentList {
1713 fragments: vec![WebUIFragment::raw("i")],
1714 contains_boundary: false,
1715 },
1716 );
1717 fragments.insert(
1718 "boundary".to_string(),
1719 FragmentList {
1720 fragments: vec![WebUIFragment::raw("b")],
1721 contains_boundary: false,
1722 },
1723 );
1724
1725 let protocol = WebUIProtocol::new(fragments);
1726 let bytes = protocol.to_protobuf().unwrap();
1727 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1728 assert_eq!(protocol, decoded);
1729 }
1730
1731 #[test]
1732 fn test_protobuf_all_comparison_operators() {
1733 let ops = [
1734 ComparisonOperator::GreaterThan,
1735 ComparisonOperator::LessThan,
1736 ComparisonOperator::Equal,
1737 ComparisonOperator::NotEqual,
1738 ComparisonOperator::GreaterThanOrEqual,
1739 ComparisonOperator::LessThanOrEqual,
1740 ];
1741 for op in &ops {
1742 let mut fragments = HashMap::new();
1743 fragments.insert(
1744 "main".to_string(),
1745 FragmentList {
1746 fragments: vec![WebUIFragment::if_cond(
1747 ConditionExpr::predicate("a", *op, "b"),
1748 "then",
1749 )],
1750 contains_boundary: false,
1751 },
1752 );
1753 fragments.insert(
1754 "then".to_string(),
1755 FragmentList {
1756 fragments: vec![WebUIFragment::raw("ok")],
1757 contains_boundary: false,
1758 },
1759 );
1760 let p = WebUIProtocol::new(fragments);
1761 let bytes = p.to_protobuf().unwrap();
1762 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1763 assert_eq!(p, decoded);
1764 }
1765 }
1766
1767 #[test]
1768 fn test_protobuf_nested_conditions() {
1769 let nested = ConditionExpr::compound(
1770 ConditionExpr::predicate("user.role", ComparisonOperator::Equal, "admin"),
1771 LogicalOperator::And,
1772 ConditionExpr::negated(ConditionExpr::predicate(
1773 "user.disabled",
1774 ComparisonOperator::Equal,
1775 "true",
1776 )),
1777 );
1778
1779 let mut fragments = HashMap::new();
1780 fragments.insert(
1781 "main".to_string(),
1782 FragmentList {
1783 fragments: vec![WebUIFragment::if_cond(nested, "then")],
1784 contains_boundary: false,
1785 },
1786 );
1787 fragments.insert(
1788 "then".to_string(),
1789 FragmentList {
1790 fragments: vec![WebUIFragment::raw("ok")],
1791 contains_boundary: false,
1792 },
1793 );
1794 let p = WebUIProtocol::new(fragments);
1795 let bytes = p.to_protobuf().unwrap();
1796 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1797 assert_eq!(p, decoded);
1798 }
1799
1800 #[test]
1801 fn test_protobuf_compound_or_condition() {
1802 let compound = ConditionExpr::compound(
1803 ConditionExpr::identifier("isAdmin"),
1804 LogicalOperator::Or,
1805 ConditionExpr::identifier("isEditor"),
1806 );
1807
1808 let mut fragments = HashMap::new();
1809 fragments.insert(
1810 "main".to_string(),
1811 FragmentList {
1812 fragments: vec![WebUIFragment::if_cond(compound, "body")],
1813 contains_boundary: false,
1814 },
1815 );
1816 fragments.insert(
1817 "body".to_string(),
1818 FragmentList {
1819 fragments: vec![WebUIFragment::raw("yes")],
1820 contains_boundary: false,
1821 },
1822 );
1823 let p = WebUIProtocol::new(fragments);
1824 let bytes = p.to_protobuf().unwrap();
1825 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1826 assert_eq!(p, decoded);
1827 }
1828
1829 #[test]
1830 fn test_protobuf_invalid_bytes() {
1831 let result = WebUIProtocol::from_protobuf(&[0xFF, 0xFF, 0xFF]);
1832 assert!(result.is_err());
1833 }
1834
1835 #[test]
1836 fn test_protobuf_empty_bytes() {
1837 let result = WebUIProtocol::from_protobuf(&[]);
1838 assert!(result.is_ok());
1839 assert!(result.unwrap().fragments.is_empty());
1840 }
1841
1842 #[test]
1843 fn test_protobuf_file_roundtrip() {
1844 let protocol = sample_protocol();
1845 let dir = std::env::temp_dir().join("webui-proto-test");
1846 std::fs::create_dir_all(&dir).unwrap();
1847 let path = dir.join("test.bin");
1848
1849 protocol.to_protobuf_file(&path).unwrap();
1850 let decoded = WebUIProtocol::from_protobuf_file(&path).unwrap();
1851 assert_eq!(protocol, decoded);
1852
1853 std::fs::remove_dir_all(&dir).ok();
1854 }
1855
1856 #[test]
1857 fn test_protobuf_validation_catches_missing_reference() {
1858 let mut fragments = HashMap::new();
1859 fragments.insert(
1860 "main".to_string(),
1861 FragmentList {
1862 fragments: vec![WebUIFragment::component("does-not-exist")],
1863 contains_boundary: false,
1864 },
1865 );
1866
1867 let protocol = WebUIProtocol::new(fragments);
1868 let buf = protocol.to_protobuf().unwrap();
1869
1870 let result = WebUIProtocol::from_protobuf(&buf);
1871 assert!(result.is_err());
1872 }
1873
1874 #[test]
1875 fn test_protobuf_validation_catches_missing_for_reference() {
1876 let mut fragments = HashMap::new();
1877 fragments.insert(
1878 "main".to_string(),
1879 FragmentList {
1880 fragments: vec![WebUIFragment::for_loop("item", "items", "missing-for")],
1881 contains_boundary: false,
1882 },
1883 );
1884
1885 let protocol = WebUIProtocol::new(fragments);
1886 let buf = protocol.to_protobuf().unwrap();
1887
1888 let result = WebUIProtocol::from_protobuf(&buf);
1889 assert!(result.is_err());
1890 if let Err(ProtocolError::Validation(msg)) = result {
1891 assert!(msg.contains("missing-for"));
1892 }
1893 }
1894
1895 #[test]
1896 fn test_protobuf_validation_catches_missing_if_reference() {
1897 let mut fragments = HashMap::new();
1898 fragments.insert(
1899 "main".to_string(),
1900 FragmentList {
1901 fragments: vec![WebUIFragment::if_cond(
1902 ConditionExpr::identifier("flag"),
1903 "missing-if",
1904 )],
1905 contains_boundary: false,
1906 },
1907 );
1908
1909 let protocol = WebUIProtocol::new(fragments);
1910 let buf = protocol.to_protobuf().unwrap();
1911
1912 let result = WebUIProtocol::from_protobuf(&buf);
1913 assert!(result.is_err());
1914 if let Err(ProtocolError::Validation(msg)) = result {
1915 assert!(msg.contains("missing-if"));
1916 }
1917 }
1918
1919 #[test]
1920 fn test_protobuf_signal_default_raw_false() {
1921 let mut fragments = HashMap::new();
1922 fragments.insert(
1923 "main".to_string(),
1924 FragmentList {
1925 fragments: vec![WebUIFragment::signal("name", false)],
1926 contains_boundary: false,
1927 },
1928 );
1929 let p = WebUIProtocol::new(fragments);
1930 let bytes = p.to_protobuf().unwrap();
1931 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1932 let frag = &decoded.fragments["main"].fragments[0];
1933 match frag.fragment.as_ref() {
1934 Some(web_ui_fragment::Fragment::Signal(s)) => {
1935 assert!(!s.raw);
1936 assert!(!s.raw_text_context);
1937 }
1938 _ => panic!("expected signal"),
1939 }
1940 }
1941
1942 #[test]
1943 fn raw_text_signal_does_not_own_html_range() {
1944 let fragment = WebUIFragment::raw_text_signal("tokens.light", true);
1945 match fragment.fragment.as_ref() {
1946 Some(web_ui_fragment::Fragment::Signal(signal)) => {
1947 assert!(signal.raw);
1948 assert!(signal.raw_text_context);
1949 }
1950 _ => panic!("expected signal"),
1951 }
1952 }
1953
1954 #[test]
1955 fn test_protobuf_pre_allocated_buffer() {
1956 let protocol = sample_protocol();
1957 let bytes = protocol.to_protobuf().unwrap();
1958 assert_eq!(bytes.len(), protocol.encoded_len());
1959 }
1960
1961 #[test]
1962 fn test_protocol_new_has_empty_tokens() {
1963 let protocol = WebUIProtocol::new(HashMap::new());
1964 assert!(protocol.tokens.is_empty());
1965 assert!(protocol.fragments.is_empty());
1966 assert_eq!(
1967 protocol.initial_state_strategy,
1968 InitialStateStrategy::Full as i32
1969 );
1970 }
1971
1972 #[test]
1973 fn test_projection_metadata_roundtrips() {
1974 let mut protocol = WebUIProtocol::new(HashMap::new());
1975 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
1976 protocol.components.insert(
1977 "my-card".to_string(),
1978 ComponentData {
1979 hydration_keys: vec!["name".to_string()],
1980 hydration_mode: StateProjectionMode::Keys as i32,
1981 navigation_mode: Some(StateProjectionMode::All as i32),
1982 ..Default::default()
1983 },
1984 );
1985
1986 let bytes = protocol.to_protobuf().unwrap();
1987 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
1988 assert_eq!(
1989 decoded.initial_state_strategy,
1990 InitialStateStrategy::Components as i32
1991 );
1992 let component = &decoded.components["my-card"];
1993 assert_eq!(component.hydration_mode, StateProjectionMode::Keys as i32);
1994 assert_eq!(component.hydration_keys, ["name"]);
1995 assert_eq!(
1996 component.navigation_mode,
1997 Some(StateProjectionMode::All as i32)
1998 );
1999 assert!(component.navigation_keys.is_empty());
2000 }
2001
2002 #[test]
2003 fn test_component_uses_shadow_dom_roundtrips() {
2004 let mut protocol = WebUIProtocol::new(HashMap::new());
2005 protocol.components.insert(
2006 "my-card".to_string(),
2007 ComponentData {
2008 uses_shadow_dom: true,
2009 ..Default::default()
2010 },
2011 );
2012
2013 let bytes = protocol.to_protobuf().unwrap();
2014 let decoded = WebUIProtocol::from_protobuf(&bytes).unwrap();
2015 assert!(decoded.component_uses_shadow_dom("my-card"));
2016 assert!(!decoded.component_uses_shadow_dom("missing-card"));
2017 }
2018
2019 #[test]
2020 fn test_protocol_with_tokens() {
2021 let tokens = vec!["color-primary".to_string(), "spacing-m".to_string()];
2022 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
2023 assert_eq!(protocol.tokens, tokens);
2024 }
2025
2026 #[test]
2027 fn test_protobuf_route_fragment_roundtrip() {
2028 let mut fragments = HashMap::new();
2029 fragments.insert(
2030 "main".to_string(),
2031 FragmentList {
2032 fragments: vec![WebUIFragment::route("/profile/:id", "profile-page")],
2033 contains_boundary: false,
2034 },
2035 );
2036 fragments.insert(
2037 "profile-page".to_string(),
2038 FragmentList {
2039 fragments: vec![WebUIFragment::raw("<h1>Profile</h1>")],
2040 contains_boundary: false,
2041 },
2042 );
2043 let protocol = WebUIProtocol::new(fragments);
2044 let bytes = protocol.to_protobuf().expect("encode failed");
2045 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2046 assert_eq!(protocol, decoded);
2047
2048 let frag = &decoded.fragments["main"].fragments[0];
2049 match frag.fragment.as_ref() {
2050 Some(web_ui_fragment::Fragment::Route(r)) => {
2051 assert_eq!(r.path, "/profile/:id");
2052 assert_eq!(r.fragment_id, "profile-page");
2053 }
2054 _ => panic!("expected route fragment"),
2055 }
2056 }
2057
2058 #[test]
2059 fn test_protobuf_route_fragment_all_fields() {
2060 let mut fragments = HashMap::new();
2061 let route_frag = WebUiFragment {
2062 fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
2063 path: "/users/:id/posts/:postId".to_string(),
2064 fragment_id: "user-posts".to_string(),
2065 exact: true,
2066 children: Vec::new(),
2067 allowed_query: "action,to,subject".to_string(),
2068 keep_alive: false,
2069 cache_tags: vec!["user:{id}".to_string(), "posts".to_string()],
2070 invalidates: vec!["posts".to_string(), "counts".to_string()],
2071 pending_component: "loading-skeleton".to_string(),
2072 error_component: "error-page".to_string(),
2073 content_fragment_id: String::new(),
2074 })),
2075 };
2076 fragments.insert(
2077 "main".to_string(),
2078 FragmentList {
2079 fragments: vec![route_frag],
2080 contains_boundary: false,
2081 },
2082 );
2083 fragments.insert(
2084 "user-posts".into(),
2085 FragmentList {
2086 fragments: vec![WebUIFragment::raw("posts")],
2087 contains_boundary: false,
2088 },
2089 );
2090
2091 let protocol = WebUIProtocol::new(fragments);
2092 let bytes = protocol.to_protobuf().expect("encode failed");
2093 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2094 assert_eq!(protocol, decoded);
2095 }
2096
2097 #[test]
2098 fn test_protobuf_route_validation_missing_fragment() {
2099 let mut fragments = HashMap::new();
2100 fragments.insert(
2101 "main".to_string(),
2102 FragmentList {
2103 fragments: vec![WebUIFragment::route("/test", "missing-fragment")],
2104 contains_boundary: false,
2105 },
2106 );
2107 let protocol = WebUIProtocol::new(fragments);
2108 let buf = protocol.to_protobuf().expect("encode failed");
2109 let result = WebUIProtocol::from_protobuf(&buf);
2110 assert!(result.is_err());
2111 if let Err(ProtocolError::Validation(msg)) = result {
2112 assert!(msg.contains("missing-fragment"));
2113 }
2114 }
2115
2116 #[test]
2117 fn test_protobuf_route_no_fragment_id_roundtrip() {
2118 let mut fragments = HashMap::new();
2119 let route_frag = WebUiFragment {
2120 fragment: Some(web_ui_fragment::Fragment::Route(WebUiFragmentRoute {
2121 path: "/old-path".to_string(),
2122 keep_alive: false,
2123 ..Default::default()
2124 })),
2125 };
2126 fragments.insert(
2127 "main".to_string(),
2128 FragmentList {
2129 fragments: vec![route_frag],
2130 contains_boundary: false,
2131 },
2132 );
2133 let protocol = WebUIProtocol::new(fragments);
2134 let bytes = protocol.to_protobuf().expect("encode failed");
2135 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2136 assert_eq!(protocol, decoded);
2137 }
2138
2139 #[test]
2140 fn test_protobuf_backward_compat_no_routes() {
2141 let protocol = WebUIProtocol::new(HashMap::new());
2143 let bytes = protocol.to_protobuf().expect("encode failed");
2144 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2145 assert!(decoded.fragments.is_empty());
2146 }
2147
2148 #[test]
2149 fn test_protobuf_roundtrip_with_tokens() {
2150 let mut fragments = HashMap::new();
2151 fragments.insert(
2152 "index.html".to_string(),
2153 FragmentList {
2154 fragments: vec![WebUIFragment::raw("Hello")],
2155 contains_boundary: false,
2156 },
2157 );
2158 let tokens = vec!["border-radius-m".to_string(), "color-primary".to_string()];
2159 let protocol = WebUIProtocol::with_tokens(fragments, tokens.clone());
2160
2161 let bytes = protocol.to_protobuf().expect("encode failed");
2162 let decoded = WebUIProtocol::from_protobuf(&bytes).expect("decode failed");
2163
2164 assert_eq!(decoded.tokens, tokens);
2165 assert!(decoded.fragments.contains_key("index.html"));
2166 }
2167}