1use crate::csp::{self, CspNonce, NonceError, PolicyError, PolicyExtensions};
5use crate::head::{HeadLink, ModulePreloads};
6use crate::response::{PageResponse, RenderCounts};
7use crate::types::PageConfig;
8use forma_ir::{IrError, WalkLimits};
9use std::fmt;
10use std::panic::{catch_unwind, AssertUnwindSafe};
11use std::time::Instant;
12
13#[derive(Debug, Clone, Default)]
30pub struct RenderCtx {
31 #[cfg(feature = "navigation")]
32 navigation: Option<crate::navigation::NavigationConfig>,
33 policy: PolicyExtensions,
34 limits: Option<WalkLimits>,
35 module_preloads: Option<ModulePreloads>,
36 head_links: Vec<HeadLink>,
37 title: Option<String>,
38 body_class: Option<String>,
39 config_json: Option<serde_json::Value>,
40 personality_css: Option<String>,
41 body_prefix: Option<crate::RawHtml>,
42}
43
44impl RenderCtx {
45 pub fn new() -> Self {
47 Self::default()
48 }
49
50 #[cfg(feature = "navigation")]
53 #[must_use]
54 pub fn with_navigation(mut self, config: crate::navigation::NavigationConfig) -> Self {
55 self.navigation = Some(config);
56 self
57 }
58
59 #[cfg(feature = "navigation")]
60 pub(crate) fn navigation(&self) -> Option<&crate::navigation::NavigationConfig> {
61 self.navigation.as_ref()
62 }
63
64 #[cfg(feature = "navigation")]
65 pub(crate) fn policy_extensions(&self) -> &PolicyExtensions {
66 &self.policy
67 }
68
69 #[must_use]
71 pub fn with_title(mut self, title: impl Into<String>) -> Self {
72 self.title = Some(title.into());
73 self
74 }
75
76 #[must_use]
78 pub fn with_body_class(mut self, class: impl Into<String>) -> Self {
79 self.body_class = Some(class.into());
80 self
81 }
82
83 #[must_use]
86 pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
87 self.config_json = Some(value);
88 self
89 }
90
91 #[must_use]
93 pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
94 self.personality_css = Some(css.into());
95 self
96 }
97
98 #[must_use]
100 pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
101 self.body_prefix = Some(html);
102 self
103 }
104
105 pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
106 self.config_json.as_ref()
107 }
108 pub(crate) fn personality_css(&self) -> Option<&str> {
109 self.personality_css.as_deref()
110 }
111 pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
112 self.body_prefix.as_ref()
113 }
114
115 pub(crate) fn title(&self) -> Option<&str> {
116 self.title.as_deref()
117 }
118 pub(crate) fn body_class(&self) -> Option<&str> {
119 self.body_class.as_deref()
120 }
121
122 #[must_use]
124 pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
125 self.policy = policy;
126 self
127 }
128
129 #[must_use]
133 pub fn with_limits(mut self, limits: WalkLimits) -> Self {
134 self.limits = Some(limits);
135 self
136 }
137
138 pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
140 self.limits.unwrap_or(inherited)
141 }
142
143 #[must_use]
166 pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
167 self.module_preloads = Some(preloads.clone());
168 self
169 }
170
171 #[must_use]
178 pub fn with_head_link(mut self, link: HeadLink) -> Self {
179 self.head_links.push(link);
180 self
181 }
182
183 pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
185 self.module_preloads
186 .as_ref()
187 .map_or(&[], |table| table.route(route_pattern))
188 }
189
190 pub(crate) fn head_links(&self) -> &[HeadLink] {
192 &self.head_links
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
224#[non_exhaustive]
225pub enum RenderError {
226 #[cfg(feature = "navigation")]
228 #[non_exhaustive]
229 Navigation {
230 route: String,
232 message: String,
234 },
235 #[non_exhaustive]
237 StaticRoute {
238 route: String,
240 message: String,
242 },
243 #[non_exhaustive]
245 Manifest {
246 route: String,
248 message: String,
250 },
251 #[non_exhaustive]
253 RouteMode {
254 route: String,
256 },
257 #[non_exhaustive]
259 Unfinalized {
260 route: String,
262 },
263 #[non_exhaustive]
265 Props {
266 cause: forma_ir::props::PropsError,
268 },
269 #[non_exhaustive]
271 Binding {
272 route: String,
274 errors: Vec<forma_ir::props::PropsError>,
276 },
277 #[non_exhaustive]
281 MissingModule {
282 route: String,
284 },
285 #[non_exhaustive]
287 Walk {
288 route: String,
290 component_path: Vec<String>,
292 counts: RenderCounts,
294 cause: IrError,
296 },
297 #[non_exhaustive]
299 IslandState {
300 route: String,
302 component_path: Vec<String>,
304 counts: RenderCounts,
306 cause: IrError,
308 },
309 #[non_exhaustive]
312 Policy {
313 route: String,
315 cause: PolicyError,
317 },
318 #[non_exhaustive]
320 Nonce {
321 route: String,
323 cause: NonceError,
325 },
326 #[non_exhaustive]
329 Budget {
330 route: String,
332 component_path: Vec<String>,
334 counts: RenderCounts,
336 cause: IrError,
338 },
339 #[non_exhaustive]
343 Fault {
344 route: String,
346 component_path: Vec<String>,
348 counts: RenderCounts,
350 message: String,
352 },
353}
354
355impl RenderError {
356 pub fn code(&self) -> &'static str {
358 match self {
359 #[cfg(feature = "navigation")]
360 RenderError::Navigation { .. } => "RENDER-601",
361 RenderError::StaticRoute { .. } => "RENDER-105",
362 RenderError::Manifest { .. } => "RENDER-104",
363 RenderError::RouteMode { .. } => "RENDER-103",
364 RenderError::Unfinalized { .. } => "RENDER-406",
365 RenderError::Props { cause } => cause.code(),
366 RenderError::Binding { .. } => "RENDER-405",
367 RenderError::MissingModule { .. } => "RENDER-101",
368 RenderError::Walk { cause, .. } => match cause {
369 IrError::InvalidListKey(_) => "RENDER-503",
372 _ => "RENDER-102",
373 },
374 RenderError::IslandState { cause, .. } => match cause {
375 IrError::IslandStateLimitExceeded { .. } => "RENDER-502",
376 IrError::InvalidIslandState(_) => "RENDER-501",
377 _ => "RENDER-501",
378 },
379 RenderError::Policy { .. } => "RENDER-201",
380 RenderError::Nonce { .. } => "RENDER-202",
381 RenderError::Budget { cause, .. } => match cause {
382 IrError::OutputLimitExceeded { .. } => "RENDER-301",
383 IrError::ListBudgetExceeded { .. } => "RENDER-302",
384 _ => "RENDER-303",
385 },
386 RenderError::Fault { .. } => "RENDER-399",
387 }
388 }
389
390 pub fn route(&self) -> &str {
392 match self {
393 #[cfg(feature = "navigation")]
394 RenderError::Navigation { route, .. } => route,
395 RenderError::StaticRoute { route, .. }
396 | RenderError::Manifest { route, .. }
397 | RenderError::RouteMode { route }
398 | RenderError::Unfinalized { route } => route,
399 RenderError::Props { cause } => cause.route(),
400 RenderError::Binding { route, .. } => route,
401 RenderError::MissingModule { route }
402 | RenderError::Walk { route, .. }
403 | RenderError::IslandState { route, .. }
404 | RenderError::Policy { route, .. }
405 | RenderError::Nonce { route, .. }
406 | RenderError::Budget { route, .. }
407 | RenderError::Fault { route, .. } => route,
408 }
409 }
410
411 pub fn property_path(&self) -> Option<&str> {
413 match self {
414 Self::Props { cause } => Some(cause.path()),
415 Self::IslandState {
416 cause: IrError::InvalidIslandState(problem),
417 ..
418 } => Some(&problem.path),
419 _ => None,
420 }
421 }
422
423 pub fn component_path(&self) -> &[String] {
428 match self {
429 RenderError::Walk { component_path, .. }
430 | RenderError::IslandState { component_path, .. }
431 | RenderError::Budget { component_path, .. }
432 | RenderError::Fault { component_path, .. } => component_path,
433 _ => &[],
434 }
435 }
436
437 pub fn counts(&self) -> Option<RenderCounts> {
439 match self {
440 RenderError::Walk { counts, .. }
441 | RenderError::IslandState { counts, .. }
442 | RenderError::Budget { counts, .. }
443 | RenderError::Fault { counts, .. } => Some(*counts),
444 _ => None,
445 }
446 }
447}
448
449impl fmt::Display for RenderError {
450 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451 write!(f, "{} {}", self.code(), self.route())?;
452 let path = self.component_path();
453 if !path.is_empty() {
454 write!(f, " in {}", path.join(" > "))?;
455 }
456 match self {
457 #[cfg(feature = "navigation")]
458 RenderError::Navigation { message, .. } => write!(f, ": {message}"),
459 RenderError::StaticRoute { message, .. } | RenderError::Manifest { message, .. } => {
460 write!(f, ": {message}")
461 }
462 RenderError::RouteMode { .. } => {
463 write!(f, ": route is not explicitly browser-rendered")
464 }
465 RenderError::Unfinalized { .. } => write!(
466 f,
467 ": bind every SSR route and finalize Pages before serving"
468 ),
469 RenderError::Props { cause } => write!(f, ": {cause}"),
470 RenderError::Binding { errors, .. } => {
471 for error in errors {
472 write!(f, "; {error}")?;
473 }
474 Ok(())
475 }
476 RenderError::MissingModule { .. } => {
477 write!(f, ": no module or slots to render on the server")
478 }
479 RenderError::Walk { cause, .. }
480 | RenderError::IslandState { cause, .. }
481 | RenderError::Budget { cause, .. } => {
482 write!(f, ": {cause}")
483 }
484 RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
485 RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
486 RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
487 }?;
488 if let Some(c) = self.counts() {
489 write!(
490 f,
491 " (after {} nodes, {} bytes, {} list rows)",
492 c.nodes, c.bytes, c.list_iterations
493 )?;
494 }
495 Ok(())
496 }
497}
498
499impl std::error::Error for RenderError {
500 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
501 match self {
502 RenderError::Walk { cause, .. }
503 | RenderError::IslandState { cause, .. }
504 | RenderError::Budget { cause, .. } => Some(cause),
505 RenderError::Props { cause } => Some(cause),
506 RenderError::Binding { errors, .. } => {
507 errors.first().map(|error| error as &dyn std::error::Error)
508 }
509 RenderError::Policy { cause, .. } => Some(cause),
510 RenderError::Nonce { cause, .. } => Some(cause),
511 _ => None,
512 }
513 }
514}
515
516pub(crate) fn render_bound(
518 config: &PageConfig,
519 ctx: &RenderCtx,
520 module: Option<&forma_ir::IrModule>,
521 slots: Option<&forma_ir::SlotData>,
522 limits: WalkLimits,
523) -> Result<PageResponse, RenderError> {
524 render_guarded(
525 config,
526 ctx,
527 CspNonce::try_generate,
528 Root::Walk {
529 module,
530 slots,
531 limits,
532 },
533 )
534}
535
536pub fn render_browser_shell(
545 config: &PageConfig,
546 ctx: &RenderCtx,
547) -> Result<PageResponse, RenderError> {
548 if !config
549 .manifest
550 .route(config.route_pattern)
551 .is_some_and(|route| route.mode == crate::RouteMode::Browser)
552 {
553 return Err(RenderError::RouteMode {
554 route: config.route_pattern.into(),
555 });
556 }
557 config
558 .manifest
559 .validate()
560 .map_err(|message| RenderError::Manifest {
561 route: config.route_pattern.into(),
562 message,
563 })?;
564 render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
565}
566
567#[derive(Debug, Clone, Copy)]
569enum Root<'a> {
570 Walk {
572 module: Option<&'a forma_ir::IrModule>,
573 slots: Option<&'a forma_ir::SlotData>,
574 limits: WalkLimits,
575 },
576 Shell,
578}
579
580fn render_guarded(
588 config: &PageConfig,
589 ctx: &RenderCtx,
590 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
591 root: Root<'_>,
592) -> Result<PageResponse, RenderError> {
593 guard(config.route_pattern, || render(config, ctx, draw, root))
594}
595
596pub(crate) fn guard<T>(
598 route: &str,
599 render: impl FnOnce() -> Result<T, RenderError>,
600) -> Result<T, RenderError> {
601 match catch_unwind(AssertUnwindSafe(render)) {
602 Ok(result) => result,
603 Err(payload) => Err(RenderError::Fault {
604 route: route.to_string(),
605 component_path: Vec::new(),
606 counts: RenderCounts::default(),
607 message: panic_message(payload.as_ref()),
608 }),
609 }
610}
611
612fn render_content(
613 config: &PageConfig,
614 ctx: &RenderCtx,
615 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
616 root: Root<'_>,
617) -> Result<RenderedContent, RenderError> {
618 let route = || config.route_pattern.to_string();
619 let static_route = config
620 .manifest
621 .route(config.route_pattern)
622 .is_some_and(crate::RouteAssets::is_static_ssr);
623 if static_route {
624 let message = if config.config_json.is_some() {
625 Some("static SSR cannot execute config_json; publish a JavaScript entry for client configuration")
626 } else if !ctx.module_preloads(config.route_pattern).is_empty()
627 || config
628 .manifest
629 .route(config.route_pattern)
630 .is_some_and(|assets| !assets.modulepreload.is_empty())
631 {
632 Some("static SSR cannot load module preloads; rebuild the route and its request preload table together")
633 } else if matches!(root, Root::Walk { module: Some(module), .. } if !module.islands.is_empty())
634 {
635 Some("static SSR contains declared islands but has no JavaScript entry")
636 } else {
637 None
638 };
639 if let Some(message) = message {
640 return Err(RenderError::StaticRoute {
641 route: route(),
642 message: message.into(),
643 });
644 }
645 }
646 let nonce = draw().map_err(|cause| RenderError::Nonce {
647 route: route(),
648 cause,
649 })?;
650 let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
652 route: route(),
653 cause,
654 })?;
655
656 let output_limit = match root {
657 Root::Walk { limits, .. } => limits.max_output_bytes,
658 Root::Shell => ctx.limits(WalkLimits::default()).max_output_bytes,
659 };
660 let (ssr_body, island_state, counts) = match root {
661 Root::Shell => (None, None, RenderCounts::default()),
662 Root::Walk {
663 module,
664 slots,
665 limits,
666 } => {
667 let (Some(module), Some(slots)) = (module, slots) else {
668 return Err(RenderError::MissingModule { route: route() });
669 };
670 let started = Instant::now();
671 let mode = if static_route {
672 forma_ir::RenderMode::Static
673 } else {
674 forma_ir::RenderMode::Adoptable
675 };
676 let walked = forma_ir::walk_to_html_counted_with_mode(module, slots, limits, mode);
677 let walk_time = started.elapsed();
678 match walked {
679 Ok(out) => (
680 Some(out.html),
681 (!static_route).then_some(out.island_state),
682 RenderCounts::from_walk(out.counts, walk_time),
683 ),
684 Err(failure) => {
685 let route = route();
686 let component_path = failure.component_path;
687 let counts = RenderCounts::from_walk(failure.counts, walk_time);
688 let cause = failure.error;
689 return Err(match cause {
690 IrError::InvalidIslandState(_)
691 | IrError::IslandStateLimitExceeded { .. } => RenderError::IslandState {
692 route,
693 component_path,
694 counts,
695 cause,
696 },
697 IrError::OutputLimitExceeded { .. }
698 | IrError::ListBudgetExceeded { .. }
699 | IrError::ListDepthExceeded { .. }
700 | IrError::RecursionLimitExceeded => RenderError::Budget {
701 route,
702 component_path,
703 counts,
704 cause,
705 },
706 IrError::Panicked { message } => RenderError::Fault {
707 route,
708 component_path,
709 counts,
710 message,
711 },
712 cause => RenderError::Walk {
713 route,
714 component_path,
715 counts,
716 cause,
717 },
718 });
719 }
720 }
721 }
722 };
723
724 Ok(RenderedContent {
725 nonce,
726 policy,
727 ssr_body,
728 island_state,
729 counts,
730 output_limit,
731 })
732}
733
734pub(crate) struct RenderedContent {
735 pub(crate) nonce: CspNonce,
736 pub(crate) policy: csp::PagePolicy,
737 pub(crate) ssr_body: Option<String>,
738 pub(crate) island_state: Option<forma_ir::IslandState>,
739 pub(crate) counts: RenderCounts,
740 pub(crate) output_limit: usize,
741}
742
743fn render(
744 config: &PageConfig,
745 ctx: &RenderCtx,
746 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
747 root: Root<'_>,
748) -> Result<PageResponse, RenderError> {
749 let RenderedContent {
750 nonce,
751 policy,
752 ssr_body,
753 island_state,
754 counts,
755 output_limit,
756 } = render_content(config, ctx, draw, root)?;
757 let html = crate::template::write_document(
758 config,
759 &nonce,
760 ssr_body.as_deref(),
761 island_state.as_ref(),
762 ctx,
763 output_limit,
764 counts,
765 )?;
766 if let Some(reason) = policy.relaxed() {
767 csp::log_relaxation(config.route_pattern, reason);
768 }
769 let response = PageResponse::with_policy(html, nonce, policy, counts);
770 #[cfg(feature = "navigation")]
771 let response = if ctx.navigation().is_some() {
772 response.with_navigation()
773 } else {
774 response
775 };
776 Ok(response)
777}
778
779#[cfg(feature = "experimental-router")]
781pub(crate) fn render_bound_navigation(
782 config: &PageConfig,
783 ctx: &RenderCtx,
784 module: &forma_ir::IrModule,
785 slots: &forma_ir::SlotData,
786 limits: WalkLimits,
787) -> Result<crate::navigation::NavigationResponse, RenderError> {
788 crate::navigation::validate_context(config, ctx)?;
789 let content = render_content(
790 config,
791 ctx,
792 CspNonce::try_generate,
793 Root::Walk {
794 module: Some(module),
795 slots: Some(slots),
796 limits,
797 },
798 )?;
799 if let Some(reason) = content.policy.relaxed() {
800 csp::log_relaxation(config.route_pattern, reason);
801 }
802 crate::navigation::NavigationResponse::from_render(
803 config,
804 ctx,
805 content.ssr_body.as_deref().unwrap_or(""),
806 content.island_state.as_ref(),
807 content.counts,
808 content.output_limit,
809 )
810}
811
812#[cfg(feature = "navigation")]
814pub(crate) fn render_bound_owner(
815 config: &PageConfig,
816 ctx: &RenderCtx,
817 module: &forma_ir::IrModule,
818 slots: &forma_ir::SlotData,
819 limits: WalkLimits,
820 values: &serde_json::Value,
821 reuse_key: &str,
822) -> Result<crate::NavigationOwner, RenderError> {
823 crate::navigation::validate_sealed(config, ctx)?;
824 let content = render_content(
825 config,
826 ctx,
827 CspNonce::try_generate,
828 Root::Walk {
829 module: Some(module),
830 slots: Some(slots),
831 limits,
832 },
833 )?;
834 if let Some(reason) = content.policy.relaxed() {
835 csp::log_relaxation(config.route_pattern, reason);
836 }
837 crate::NavigationOwner::from_render(config, ctx, module, values, reuse_key, content)
838}
839
840fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
843 if let Some(message) = payload.downcast_ref::<&str>() {
844 (*message).to_string()
845 } else if let Some(message) = payload.downcast_ref::<String>() {
846 message.clone()
847 } else {
848 "a panic with no message".to_string()
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use crate::types::AssetManifest;
856 use std::collections::HashMap;
857
858 fn manifest() -> AssetManifest {
859 AssetManifest {
860 version: 2,
861 writer: "3.0.0".into(),
862 app: "ledger".into(),
863 aliases: HashMap::new(),
864 build: "test".to_string(),
865 assets: HashMap::new(),
866 routes: HashMap::new(),
867 }
868 }
869
870 #[allow(deprecated)]
871 fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
872 PageConfig {
873 title: "Seam",
874 route_pattern: "/seam",
875 manifest,
876 config_json: None,
877 body_class: None,
878 personality_css: None,
879 body_prefix: None,
880 }
881 }
882
883 #[test]
886 fn a_keyed_row_refusal_is_render_503_and_other_walk_refusals_stay_render_102() {
887 use forma_ir::{ListKeyReason, ListKeyViolation};
888 let walk = |cause: IrError| RenderError::Walk {
889 route: "/seam".into(),
890 component_path: vec!["Rows".into()],
891 counts: RenderCounts::default(),
892 cause,
893 };
894 let keyed = walk(IrError::InvalidListKey(Box::new(ListKeyViolation::new(
895 30,
896 2,
897 "user.id",
898 ListKeyReason::Duplicate { first_row: 0 },
899 ))));
900 assert_eq!(keyed.code(), "RENDER-503");
901 assert_eq!(keyed.component_path(), ["Rows".to_string()]);
902 let text = keyed.to_string();
903 assert!(
904 text.contains("RENDER-503: LIST at bytecode offset 30, row 2, key path `user.id`: the row repeats the key row 0 already rendered"),
905 "{text}"
906 );
907 assert_eq!(walk(IrError::InvalidOpcode(0xff)).code(), "RENDER-102");
908 }
909
910 #[test]
911 fn static_island_mismatch_refuses_before_nonce_or_walk() {
912 use forma_ir::parser::test_helpers::{
913 build_declared_fixture, encode_close_tag, encode_open_tag,
914 };
915 let mut manifest = manifest();
916 manifest.routes.insert(
917 "/seam".into(),
918 crate::RouteAssets {
919 #[cfg(feature = "navigation")]
920 navigation: None,
921 mode: crate::RouteMode::Ssr,
922 js: vec![],
923 css: vec![],
924 fonts: vec![],
925 modulepreload: vec![],
926 ir: Some("home.ir".into()),
927 total_size_br: 0,
928 budget_warn_threshold: 0,
929 },
930 );
931 let opcodes = [
932 vec![11, 0, 0],
933 encode_open_tag(0, &[]),
934 encode_close_tag(0),
935 vec![12, 0, 0],
936 ]
937 .concat();
938 let module = forma_ir::IrModule::parse(&build_declared_fixture(
939 &["div", "Panel"],
940 &[],
941 &opcodes,
942 &[(0, 1, 0, 1, 0, &[])],
943 ))
944 .unwrap();
945 let error = render_guarded(
946 &shell_config(&manifest),
947 &RenderCtx::new(),
948 || panic!("static refusal must precede nonce generation"),
949 Root::Walk {
950 module: Some(&module),
951 slots: None,
952 limits: WalkLimits::default(),
953 },
954 )
955 .unwrap_err();
956 assert_eq!(error.code(), "RENDER-105");
957 assert_eq!(error.route(), "/seam");
958 assert!(error.to_string().contains("declared islands"));
959 assert_eq!(error.counts(), None);
960 assert!(error.component_path().is_empty());
961 }
962
963 #[test]
964 fn render_202_a_failed_nonce_draw() {
965 let manifest = manifest();
969 for root in [
970 Root::Walk {
971 module: None,
972 slots: None,
973 limits: WalkLimits::default(),
974 },
975 Root::Shell,
976 ] {
977 let error = render_guarded(
978 &shell_config(&manifest),
979 &RenderCtx::new(),
980 || Err(NonceError),
981 root,
982 )
983 .unwrap_err();
984 assert_eq!(error.code(), "RENDER-202");
985 assert_eq!(error.route(), "/seam");
986 assert!(error.component_path().is_empty());
987 assert_eq!(error.counts(), None);
988 }
989 let page = render_guarded(
991 &shell_config(&manifest),
992 &RenderCtx::new(),
993 CspNonce::try_generate,
994 Root::Shell,
995 );
996 assert!(page.is_ok());
997 }
998
999 #[test]
1000 fn a_panic_outside_the_walk_is_still_a_fault() {
1001 let manifest = manifest();
1004 let error = render_guarded(
1005 &shell_config(&manifest),
1006 &RenderCtx::new(),
1007 || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
1008 Root::Shell,
1009 )
1010 .unwrap_err();
1011 assert_eq!(error.code(), "RENDER-399");
1012 assert_eq!(error.route(), "/seam");
1013 assert!(error.component_path().is_empty());
1014 match error {
1015 RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
1016 other => panic!("expected Fault, got {other:?}"),
1017 }
1018 }
1019
1020 #[test]
1021 fn an_invalid_extension_fails_before_the_walk() {
1022 let manifest = manifest();
1024 let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
1025 let error = render_bound(
1026 &shell_config(&manifest),
1027 &ctx,
1028 None,
1029 None,
1030 WalkLimits::default(),
1031 )
1032 .unwrap_err();
1033 assert_eq!(error.code(), "RENDER-201");
1034 }
1035
1036 #[test]
1037 fn display_names_code_route_component_and_counts() {
1038 let error = RenderError::Budget {
1039 route: "/t/:tenant".to_string(),
1040 component_path: vec!["Shell".to_string(), "Branding".to_string()],
1041 counts: RenderCounts {
1042 nodes: 102,
1043 bytes: 2048,
1044 list_iterations: 50,
1045 ..RenderCounts::default()
1046 },
1047 cause: IrError::ListBudgetExceeded { max: 50 },
1048 };
1049 let text = error.to_string();
1050 assert!(
1051 text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
1052 "{text}"
1053 );
1054 assert!(
1055 text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
1056 "{text}"
1057 );
1058 }
1059}